diff --git a/package-lock.json b/package-lock.json index 54cb39f..9266032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,21 @@ "dependencies": { "@expo/vector-icons": "^14.0.2", "@noble/curves": "^1.6.0", + "@react-native-async-storage/async-storage": "^2.0.0", "@react-navigation/native": "^6.0.2", "@scure/base": "^1.1.8", "@scure/bip39": "^1.4.0", + "@tanstack/react-query": "^5.56.2", "expo": "~51.0.28", + "expo-clipboard": "^6.0.3", "expo-constants": "~16.0.2", "expo-font": "~12.0.9", "expo-linking": "~6.3.1", + "expo-notifications": "^0.28.16", "expo-router": "~3.5.23", + "expo-secure-store": "^13.0.2", "expo-splash-screen": "~0.27.5", + "expo-standard-web-crypto": "^1.8.1", "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", "micro-key-producer": "^0.7.0", @@ -3575,6 +3581,12 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4639,6 +4651,18 @@ "react": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.0.0.tgz", + "integrity": "sha512-af6H9JjfL6G/PktBfUivvexoiFKQTJGQCtSWxMdivLzNIY94mu9DdiY0JqCSg/LyPCLGKhHPUlRQhNvpu3/KVA==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native-community/cli": { "version": "13.6.9", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-13.6.9.tgz", @@ -7153,6 +7177,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.56.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.56.2.tgz", + "integrity": "sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.56.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.56.2.tgz", + "integrity": "sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.56.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -7729,6 +7779,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/ast-types": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", @@ -8223,6 +8286,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -10101,6 +10170,15 @@ "expo": "bin/cli" } }, + "node_modules/expo-application": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-5.9.1.tgz", + "integrity": "sha512-uAfLBNZNahnDZLRU41ZFmNSKtetHUT9Ua557/q189ua0AWV7pQjoVAx49E4953feuvqc9swtU3ScZ/hN1XO/FQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-10.0.10.tgz", @@ -10115,6 +10193,15 @@ "expo": "*" } }, + "node_modules/expo-clipboard": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-6.0.3.tgz", + "integrity": "sha512-RIKDsuHkYfaspifbFpVC8sBVFKR05L7Pj7mU2/XkbrW9m01OBNvdpGraXEMsTFCx97xMGsZpEw9pPquL4j4xVg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-constants": { "version": "16.0.2", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz", @@ -10128,6 +10215,19 @@ "expo": "*" } }, + "node_modules/expo-crypto": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz", + "integrity": "sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz", @@ -10301,6 +10401,61 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.28.16", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.28.16.tgz", + "integrity": "sha512-sj4oDip+uFNmxieGHkfS2Usrwbw2jfOTfQ22a7z5tdSo/vD6jWMlCHOnJifqYLjPxyqf9SLTsQWO3bmk7MY2Yg==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.5.0", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~5.9.0", + "expo-constants": "~16.0.0", + "fs-extra": "^9.1.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-notifications/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-notifications/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/expo-notifications/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/expo-router": { "version": "3.5.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-3.5.23.tgz", @@ -10339,6 +10494,15 @@ } } }, + "node_modules/expo-secure-store": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-13.0.2.tgz", + "integrity": "sha512-3QYgoneo8p8yeeBPBiAfokNNc2xq6+n8+Ob4fAlErEcf4H7Y72LH+K/dx0nQyWau2ZKZUXBxyyfuHFyVKrEVLg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "0.27.5", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-0.27.5.tgz", @@ -10427,6 +10591,20 @@ "node": ">= 10.0.0" } }, + "node_modules/expo-standard-web-crypto": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/expo-standard-web-crypto/-/expo-standard-web-crypto-1.8.1.tgz", + "integrity": "sha512-0T/FT45gpjRFI9LZdUAdrCLyBg+6lf1rEHFsEm8EryFxVnQ5eKknpGgx7hd6kLytGVIWWL4zc+yFSHrp2LVqXw==", + "license": "MIT", + "peerDependencies": { + "expo-crypto": "^13.0.0" + }, + "peerDependenciesMeta": { + "expo-random": { + "optional": true + } + } + }, "node_modules/expo-status-bar": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.12.1.tgz", @@ -11810,6 +11988,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -11864,6 +12058,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -15115,6 +15318,18 @@ "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==", "license": "BSD-2-Clause" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -16131,6 +16346,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", diff --git a/package.json b/package.json index 8352fca..9fb9427 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { "name": "conduit", - "main": "expo-router/entry", + "main": "src/entrypoint", "version": "1.0.0", "scripts": { "android": "expo run:android", + "build-release": "cd android && ./gradlew assembleRelease && cd ..", "ios": "expo run:ios", "test": "jest --verbose", "format": "prettier --write ./src", - "tsc": "tsc --noUnusedLocals --noUnusedParameters" + "tsc": "tsc --noUnusedLocals --noUnusedParameters", + "check": "jest && prettier --check ./src && tsc --noUnusedLocals --noUnusedParameters" }, "jest": { "preset": "jest-expo", "moduleNameMapper": { - "micro-key-producer/src/slip10.js": "micro-key-producer/slip10.js" + "micro-key-producer/src/slip10": "micro-key-producer/slip10.js" } }, "prettier": { @@ -24,15 +26,21 @@ "dependencies": { "@expo/vector-icons": "^14.0.2", "@noble/curves": "^1.6.0", + "@react-native-async-storage/async-storage": "^2.0.0", "@react-navigation/native": "^6.0.2", "@scure/base": "^1.1.8", "@scure/bip39": "^1.4.0", + "@tanstack/react-query": "^5.56.2", "expo": "~51.0.28", + "expo-clipboard": "^6.0.3", "expo-constants": "~16.0.2", "expo-font": "~12.0.9", "expo-linking": "~6.3.1", + "expo-notifications": "^0.28.16", "expo-router": "~3.5.23", + "expo-secure-store": "^13.0.2", "expo-splash-screen": "~0.27.5", + "expo-standard-web-crypto": "^1.8.1", "expo-status-bar": "~1.12.1", "expo-system-ui": "~3.0.7", "micro-key-producer": "^0.7.0", diff --git a/src/account/context.tsx b/src/account/context.tsx new file mode 100644 index 0000000..5933c67 --- /dev/null +++ b/src/account/context.tsx @@ -0,0 +1,157 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import React from "react"; + +import { + Ed25519KeyPair, + deriveEd25519KeyPair, + keyPairToBase64nopad, +} from "@/src/common/cryptography"; +import { handleError, wrapError } from "@/src/common/errors"; +import { + DEFAULT_INPROXY_LIMIT_BYTES_PER_SECOND, + DEFAULT_INPROXY_MAX_CLIENTS, +} from "@/src/constants"; +// TODO: pending new psiphon module +//import { usePsiphonVpnContext } from "@/src/psiphon/context"; +import { + InProxyParametersSchema, + formatConduitBip32Path, +} from "@/src/psiphon/inproxy"; + +export interface AccountContextValue { + rootKeyPair: Ed25519KeyPair; + conduitKeyPair: Ed25519KeyPair; +} + +const AccountContext = React.createContext(null); + +/** + * A Ryve account is defined by a BIP-39 Mnemonic, the associated blockchain + * account, and an Ed25519 key pair. The mnemonic is used to derive the rest of + * the account information. + * This context provides these values to authenticated routes within the app. + * Establishing authentication state is handled by `useAuthContext`. + */ +export function useAccountContext() { + const value = React.useContext(AccountContext); + if (!value) { + throw new Error( + "useAccountContext must be wrapped in a ", + ); + } + + return value; +} + +export function AccountProvider({ + mnemonic, + deviceNonce, + children, +}: { + mnemonic: string; + deviceNonce: number; + children: React.ReactNode; +}) { + const rootKeyPair = React.useMemo(() => { + const derived = deriveEd25519KeyPair(mnemonic); + if (derived instanceof Error) { + throw derived; + } + return derived; + }, [mnemonic]); + + const conduitKeyPair = React.useMemo(() => { + const derived = deriveEd25519KeyPair( + mnemonic, + formatConduitBip32Path(deviceNonce), + ); + if (derived instanceof Error) { + throw derived; + } + return derived; + }, [mnemonic]); + + // TODO: pending new psiphon module + //const { selectInProxyParameters } = usePsiphonVpnContext(); + + // We store the user-controllable InProxy settings in AsyncStorage, so that + // they can be persisted at the application layer instead of only at the VPN + // module layer. This also allows us to have defaults that are different + // than what the module uses. The values stored in AsyncStorage will be + // taken as the source of truth. + async function loadInProxyParameters() { + try { + // Retrieve stored inproxy parameters from the application layer + const storedInProxyMaxClients = + await AsyncStorage.getItem("InProxyMaxClients"); + + const storedInProxyLimitBytesPerSecond = await AsyncStorage.getItem( + "InProxyLimitBytesPerSecond", + ); + + // Prepare the stored/default parameters from the application layer + const storedInProxyParameters = InProxyParametersSchema.parse({ + privateKey: keyPairToBase64nopad(conduitKeyPair), + maxClients: storedInProxyMaxClients + ? parseInt(storedInProxyMaxClients) + : DEFAULT_INPROXY_MAX_CLIENTS, + limitUpstreamBytesPerSecond: storedInProxyLimitBytesPerSecond + ? parseInt(storedInProxyLimitBytesPerSecond) + : DEFAULT_INPROXY_LIMIT_BYTES_PER_SECOND, + limitDownstreamBytesPerSecond: storedInProxyLimitBytesPerSecond + ? parseInt(storedInProxyLimitBytesPerSecond) + : DEFAULT_INPROXY_LIMIT_BYTES_PER_SECOND, + }); + + // sets the inproxy parameters in the psiphon module. This call + // also updates the context's state value for the inproxy + // parameters, so an explicit call to sync them is not needed. + // TODO: pending new psiphon module + //await selectInProxyParameters(storedInProxyParameters); + + // Write the defaults to AsyncStorage if they aren't there + if (!storedInProxyMaxClients) { + await AsyncStorage.setItem( + "InProxyMaxClients", + storedInProxyParameters.maxClients.toString(), + ); + } + if (!storedInProxyLimitBytesPerSecond) { + await AsyncStorage.setItem( + "InProxyLimitBytesPerSecond", + storedInProxyParameters.limitUpstreamBytesPerSecond.toString(), + ); + } + } catch (error) { + handleError(wrapError(error, "Failed to load inproxy parameters")); + } + } + + // Loads InProxy parameters on first mount. This is done in the account + // context because it is the first place where the conduitKeyPair is ready. + // It could be done in the psiphon context, but this would require some + // refactoring of the order of the context providers in the app. + React.useEffect(() => { + // Note that right now, this means that we ALWAYS set the InProxy params + // in the module on app start, even if they have not changed. This could + // be made more precise by having this effect depend on the InProxy + // params stored in the psiphon vpn context, but since these values are + // currently stored as an object the context would need to expose state + // values that we can use in this dependency array that don't have the + // pitfals of objects as dependencies (hence why this doesn't just use + // inProxyParameters as a dependency). + // https://react.dev/learn/removing-effect-dependencies#does-some-reactive-value-change-unintentionally + loadInProxyParameters(); + }, []); + + const value = { + rootKeyPair, + conduitKeyPair, + } as AccountContextValue; + + return ( + + {children} + + ); +} diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 57fac0b..be26ea9 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -1,19 +1,25 @@ -import { Stack } from "expo-router"; +import { Redirect, Stack } from "expo-router"; import React from "react"; +import { AccountProvider } from "@/src/account/context"; +import { useAuthContext } from "@/src/auth/context"; + export default function AppLayout() { + const { mnemonic, deviceNonce } = useAuthContext(); + + if (!mnemonic || !deviceNonce) { + // We are not authenticated + return ; + } return ( - - + - + > + + + ); } diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 05430ed..8f44846 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -1,27 +1,69 @@ -import { Text, View } from "react-native"; +import * as Notifications from "expo-notifications"; +import React from "react"; +import { Pressable, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useAccountContext } from "@/src/account/context"; +import { NotificationsStatus } from "@/src/components/NotificationsStatus"; +import { ProxyID } from "@/src/components/ProxyID"; +import { getProxyId } from "@/src/psiphon/inproxy"; +import { palette, sharedStyles as ss } from "@/src/styles"; + export default function HomeScreen() { const insets = useSafeAreaInsets(); + const { conduitKeyPair } = useAccountContext(); + + const [message, setMessage] = React.useState("Conduit is OFF"); return ( + + + {">"} Conduit + + - Conduit Only + [ + ss.justifyCenter, + ss.alignCenter, + ss.whiteBorder, + ss.circle158, + { + backgroundColor: pressed + ? palette.blue + : palette.grey, + }, + ]} + onPress={async () => { + await Notifications.requestPermissionsAsync(); + setMessage("Conduit is not implemented yet!"); + setTimeout(() => setMessage("Conduit is OFF"), 5000); + }} + > + Turn ON + + {message} + + + + + Your Conduit ID:{" "} + + ); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 08ca926..ae0a524 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,10 +1,17 @@ import { DarkTheme, ThemeProvider } from "@react-navigation/native"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useFonts } from "expo-font"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; +import { polyfillWebCrypto } from "expo-standard-web-crypto"; import { useEffect } from "react"; import "react-native-reanimated"; +polyfillWebCrypto(); + +import { AuthProvider } from "@/src/auth/context"; +import { NotificationsProvider } from "@/src/notifications/context"; + // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); @@ -23,11 +30,27 @@ export default function RootLayout() { return null; } + const queryClient = new QueryClient(); + return ( - - - + + + + + + + + + ); } diff --git a/src/app/index.tsx b/src/app/index.tsx new file mode 100644 index 0000000..3bac126 --- /dev/null +++ b/src/app/index.tsx @@ -0,0 +1,49 @@ +import { router } from "expo-router"; +import React from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useAuthContext } from "@/src/auth/context"; +import { handleError } from "@/src/common/errors"; + +export default function Index() { + const insets = useSafeAreaInsets(); + const { signIn } = useAuthContext(); + + React.useEffect(() => { + signIn().then((result) => { + if (result instanceof Error) { + handleError(result); + } else { + // Route to home screen as soon as the credentials are loaded, + // this may happen before the splash animation fully completes + // replace as we do not want user to be able to go "back" to + // the splash screen + router.replace("/(app)/"); + } + }); + }, []); + + return ( + + + Loading... + + + + ); +} diff --git a/src/auth/context.tsx b/src/auth/context.tsx new file mode 100644 index 0000000..caa52f8 --- /dev/null +++ b/src/auth/context.tsx @@ -0,0 +1,91 @@ +import * as bip39 from "@scure/bip39"; +import { wordlist as englishWordlist } from "@scure/bip39/wordlists/english"; +import * as SecureStore from "expo-secure-store"; +import React, { useCallback } from "react"; + +import { wrapError } from "@/src/common/errors"; + +export interface AuthContextValue { + signIn: () => Promise; + signOut: () => void; + deleteAccount: () => void; + mnemonic?: string | null; + deviceNonce?: number | null; +} + +const AuthContext = React.createContext(null); + +/** + * The AuthContext is used to persist and access the user's BIP39 mnemonic. + * The mnemonic and the keys/account it derives are provided to the rest of the + * app via the AccountContext. Use this hook for sign in, out, and deleting the + * account. Use `useAccountContext` to access the mnemonic and derived values. + */ +export function useAuthContext() { + const value = React.useContext(AuthContext); + if (!value) { + throw new Error("useAuthContext must be wrapped in a "); + } + + return value; +} + +export function AuthProvider(props: React.PropsWithChildren) { + const [mnemonic, setMnemonic] = React.useState(null); + const [deviceNonce, setDeviceNonce] = React.useState(null); + + const signIn = React.useCallback(async () => { + try { + // Load mnemonic from SecureStore + const storedMnemonic = await SecureStore.getItemAsync("mnemonic"); + if (!storedMnemonic) { + const newMnemonic = bip39.generateMnemonic(englishWordlist); + await SecureStore.setItemAsync("mnemonic", newMnemonic); + setMnemonic(newMnemonic); + } else { + setMnemonic(storedMnemonic); + } + + // Load device nonce from SecureStore + const storedDeviceNonce = + await SecureStore.getItemAsync("deviceNonce"); + if (!storedDeviceNonce) { + const newDeviceNonce = Math.floor(Math.random() * 0x80000000); + await SecureStore.setItemAsync( + "deviceNonce", + newDeviceNonce.toString(), + ); + setDeviceNonce(newDeviceNonce); + } else { + setDeviceNonce(parseInt(storedDeviceNonce)); + } + } catch (error) { + return wrapError(error, "Error signing in"); + } + + return null; + }, [setMnemonic]); + + const signOut = useCallback(() => { + setMnemonic(null); + }, [setMnemonic]); + + const deleteAccount = useCallback(async () => { + await SecureStore.deleteItemAsync("mnemonic"); + signOut(); + }, [signOut]); + + const value = { + signIn, + signOut, + deleteAccount, + mnemonic, + deviceNonce, + } as AuthContextValue; + + return ( + + {props.children} + + ); +} diff --git a/src/common/cryptography.ts b/src/common/cryptography.ts index 4bb0537..b276695 100644 --- a/src/common/cryptography.ts +++ b/src/common/cryptography.ts @@ -1,7 +1,7 @@ import { ed25519 } from "@noble/curves/ed25519"; import { base64nopad } from "@scure/base"; import { mnemonicToSeedSync } from "@scure/bip39"; -import slip10 from "micro-key-producer/src/slip10.js"; +import slip10 from "micro-key-producer/src/slip10"; import { z } from "zod"; import { wrapError } from "@/src/common/errors"; diff --git a/src/common/validators.ts b/src/common/validators.ts index cbf24f9..85a83ab 100644 --- a/src/common/validators.ts +++ b/src/common/validators.ts @@ -1,3 +1,4 @@ +import { base64nopad } from "@scure/base"; import { z } from "zod"; export const Uint8Array32 = z @@ -5,3 +6,15 @@ export const Uint8Array32 = z .refine((value) => value.length === 32, { message: "Uint8Array must have length 32", }); + +export const Base64Unpadded32Bytes = z + .string() + .refine((v) => base64nopad.decode(v).length === 32, { + message: "string is not 32 bytes encoded as base64 (no padding)", + }); + +export const Base64Unpadded64Bytes = z + .string() + .refine((v) => base64nopad.decode(v).length === 64, { + message: "string is not 64 bytes encoded as base64 (no padding)", + }); diff --git a/src/components/NotificationsStatus.tsx b/src/components/NotificationsStatus.tsx new file mode 100644 index 0000000..c9c6c6c --- /dev/null +++ b/src/components/NotificationsStatus.tsx @@ -0,0 +1,121 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import * as Linking from "expo-linking"; +import React from "react"; +import { Modal, Pressable, Text, View } from "react-native"; + +import { useNotificationsContext } from "@/src/notifications/context"; +import { palette, sharedStyles as ss } from "@/src/styles"; + +export function NotificationsStatus() { + const { + permissionStatus, + canAskAgain, + warningDismissed, + useDismissNotificationsWarning, + } = useNotificationsContext(); + const dismissNotificationsWarning = useDismissNotificationsWarning(); + + const [modalOpen, setModalOpen] = React.useState(false); + + if (permissionStatus !== "granted") { + if (canAskAgain === false && warningDismissed === false) { + return ( + <> + setModalOpen(true)} + style={[ss.row, ss.justifyCenter, ss.alignCenter]} + > + + Notifications OFF + + + + setModalOpen(false)} + > + + + + We recommend enabling notifications + + + Notifications must be enabled in settings + + + { + dismissNotificationsWarning.mutate(); + }} + style={({ pressed }) => [ + { + backgroundColor: pressed + ? "rgba(0, 0, 0, 0.2)" + : "", + }, + ss.alignCenter, + ss.justifyCenter, + ss.doublePadded, + ss.whiteBorder, + ss.rounded20, + ]} + > + + Dismiss + + + { + Linking.openSettings(); + setModalOpen(false); + }} + style={[ + ss.alignCenter, + ss.justifyCenter, + ss.doublePadded, + ss.rounded20, + { + backgroundColor: palette.purple, + }, + ]} + > + + Go To Settings + + + + + + + ); + } + } +} diff --git a/src/components/ProxyID.tsx b/src/components/ProxyID.tsx new file mode 100644 index 0000000..f3caa4d --- /dev/null +++ b/src/components/ProxyID.tsx @@ -0,0 +1,54 @@ +import { Feather } from "@expo/vector-icons"; +import * as Clipboard from "expo-clipboard"; +import React from "react"; +import { Pressable, Text, View } from "react-native"; + +import { palette, sharedStyles as ss } from "@/src/styles"; + +export function ProxyID({ + proxyId, + copyable = true, +}: { + proxyId: string; + copyable?: boolean; +}) { + // proxyId is a base64nopad encoded X25519 public key + const [copyIcon, setCopyIcon] = React.useState( + , + ); + + function showCopySuccess() { + setCopyIcon(); + setTimeout(() => { + setCopyIcon( + , + ); + }, 2500); + } + + async function copyProxyIdToClipboard() { + await Clipboard.setStringAsync(proxyId); + showCopySuccess(); + } + + return ( + + + + {proxyId.substring(0, 4)}... + + {copyable && copyIcon} + + + ); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..164bcf4 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_INPROXY_MAX_CLIENTS = 2; +export const DEFAULT_INPROXY_LIMIT_BYTES_PER_SECOND = 10 * 1024 * 1024; // 10 MB diff --git a/src/entrypoint.js b/src/entrypoint.js new file mode 100644 index 0000000..dfbc3bd --- /dev/null +++ b/src/entrypoint.js @@ -0,0 +1,6 @@ +// first import and run any polyfills +import { polyfillWebCrypto } from "expo-standard-web-crypto"; +polyfillWebCrypto(); + +// then launch the router +import "expo-router/entry"; diff --git a/src/notifications/context.tsx b/src/notifications/context.tsx new file mode 100644 index 0000000..f4c493c --- /dev/null +++ b/src/notifications/context.tsx @@ -0,0 +1,127 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { + UseMutationResult, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import * as Notifications from "expo-notifications"; +import React from "react"; + +import { handleError, wrapError } from "@/src/common/errors"; + +export interface NotificationsContextValue { + permissionStatus: Notifications.PermissionStatus | null; + canAskAgain: boolean | null; + warningDismissed: boolean | null; + useDismissNotificationsWarning: () => UseMutationResult< + void, + Error, + void, + unknown + >; +} + +export const NotificationsContext = + React.createContext(null); + +export function useNotificationsContext(): NotificationsContextValue { + const value = React.useContext(NotificationsContext); + if (!value) { + throw new Error( + "useNotificationsContext must be used within a NotificationsProvider", + ); + } + return value; +} + +export function NotificationsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [permissionStatus, setPermissionStatus] = + React.useState(null); + const [canAskAgain, setCanAskAgain] = React.useState(null); + const [warningDismissed, setWarningDismissed] = React.useState< + boolean | null + >(null); + + const queryClient = useQueryClient(); + + const warningDismissedStorageKey = "NotificationsWarningsDismissed"; + + async function syncPermissionState() { + const response = await Notifications.getPermissionsAsync(); + setPermissionStatus(response.status); + setCanAskAgain(response.canAskAgain); + + try { + const storedWarningDismissed = await AsyncStorage.getItem( + warningDismissedStorageKey, + ); + if (storedWarningDismissed === null) { + setWarningDismissed(false); + } + if (storedWarningDismissed === "dismissed") { + setWarningDismissed(true); + } + } catch (error) { + handleError( + wrapError( + error, + "Failed to sync no-notifications warning dismissal state", + ), + ); + } + + return response; + } + + async function dismissWarning() { + try { + await AsyncStorage.setItem(warningDismissedStorageKey, "dismissed"); + } catch (error) { + handleError( + wrapError( + error, + "Failed to store no-notifications warning dismissal", + ), + ); + } + setWarningDismissed(true); + } + + const useNotificationsPermission = () => + useQuery({ + queryKey: ["notifications-permission"], + queryFn: syncPermissionState, + // only refetch permission status if permission is not granted + refetchInterval: permissionStatus === "granted" ? false : 2000, + }); + + const useDismissNotificationsWarning = () => + useMutation({ + mutationFn: dismissWarning, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["notifications-permission"], + }); + }, + }); + + // continually sync notification permissions status + useNotificationsPermission(); + + const value = { + permissionStatus: permissionStatus, + canAskAgain: canAskAgain, + warningDismissed: warningDismissed, + useDismissNotificationsWarning: useDismissNotificationsWarning, + }; + return ( + + {children} + + ); +} diff --git a/src/psiphon/inproxy.ts b/src/psiphon/inproxy.ts new file mode 100644 index 0000000..7a41717 --- /dev/null +++ b/src/psiphon/inproxy.ts @@ -0,0 +1,72 @@ +import { edwardsToMontgomeryPub } from "@noble/curves/ed25519"; +import { base64nopad } from "@scure/base"; +import { z } from "zod"; + +import { Ed25519KeyPair } from "@/src/common/cryptography"; +import { Base64Unpadded64Bytes } from "@/src/common/validators"; + +export const InProxyActivityDataByPeriodSchema = z.object({ + bytesUp: z.array(z.number()).length(288), + bytesDown: z.array(z.number()).length(288), + connectingClients: z.array(z.number()).length(288), + connectedClients: z.array(z.number()).length(288), +}); + +export const InProxyActivityStatsSchema = z.object({ + elapsedTime: z.number(), + totalBytesUp: z.number(), + totalBytesDown: z.number(), + currentConnectingClients: z.number(), + currentConnectedClients: z.number(), + dataByPeriod: z.object({ + "1000ms": InProxyActivityDataByPeriodSchema, + }), +}); + +// These are the user-configurable parameters for the inproxy. +export const InProxyParametersSchema = z.object({ + privateKey: Base64Unpadded64Bytes, + maxClients: z.number().int().positive(), + limitUpstreamBytesPerSecond: z.number().int().positive(), + limitDownstreamBytesPerSecond: z.number().int().positive(), + // personalCompartmentIds: z.array(z.string()), // eventually... +}); + +export type InProxyParameters = z.infer; +export type InProxyActivityStats = z.infer; +export type InProxyActivityByPeriod = z.infer< + typeof InProxyActivityDataByPeriodSchema +>; + +/** This is used to derive the conduit key pair from the mnemonic. The chosen + * path is not that important, but each device should have it's own unique + * conduit key pair, so we use the device nonce as the last index. The root + * of the path is chosen to not conflict with any standard BIP44 paths. + * The maximum value of the device nonce is 2^31, as we use the ' notation + * for accessing the "hardened keys" in the BIP32 key tree. This maximum is + * enforced at runtime by zod. + */ +export function formatConduitBip32Path(deviceNonce: number): string { + z.number().min(0).max(0x80000000).parse(deviceNonce); + + return `m/400'/20'/${deviceNonce}'`; +} + +/** + * Get the base64 nopad encoding of the X25519 public key representation of the + * Ed25519 InProxy key pair, recorded as proxy_id by psiphond. + */ +export function getProxyId(conduitKeyPair: Ed25519KeyPair): string { + return base64nopad.encode(edwardsToMontgomeryPub(conduitKeyPair.publicKey)); +} + +/** + * + * Utility method for converting a base64nopad encoded Ed25519 public key to a + * base64nopad X25519 public key. + */ +export function ed25519StringToX25519String(ed25519String: string): string { + return base64nopad.encode( + edwardsToMontgomeryPub(base64nopad.decode(ed25519String)), + ); +} diff --git a/src/styles.ts b/src/styles.ts new file mode 100644 index 0000000..9ae8acf --- /dev/null +++ b/src/styles.ts @@ -0,0 +1,320 @@ +import { StyleSheet } from "react-native"; + +export const palette = { + black: "#000000", + red: "#d54028", + blue: "#3b7a96", + purple: "#5d4264", + white: "#ffffff", + grey: "#342F2F", +}; + +export const sharedStyles = StyleSheet.create({ + column: { + flexDirection: "column", + gap: 10, + }, + row: { + flexDirection: "row", + gap: 10, + }, + nogap: { + gap: 0, + }, + doubleGap: { + gap: 20, + }, + halfGap: { + gap: 5, + }, + fullWidth: { + width: "100%", + maxWidth: "100%", + }, + fullHeight: { + height: "100%", + maxHeight: "100%", + }, + padded: { + padding: 10, + }, + paddedVertical: { + paddingVertical: 10, + }, + paddedHorizontal: { + paddingHorizontal: 10, + }, + halfPadded: { + padding: 5, + }, + doublePadded: { + padding: 20, + }, + margin: { + margin: 10, + }, + doubleMargin: { + margin: 20, + }, + paddedLeft: { + paddingLeft: 10, + }, + paddedRight: { + paddingRight: 10, + }, + paddedTop: { + paddingTop: 10, + }, + paddedTop20: { + paddingTop: 20, + }, + paddedTop40: { + paddingTop: 40, + }, + height30: { + height: 30, + maxHeight: 30, + minHeight: 30, + }, + height40: { + height: 40, + maxHeight: 40, + minHeight: 40, + }, + height60: { + height: 60, + maxHeight: 60, + minHeight: 60, + }, + height80: { + height: 80, + maxHeight: 80, + minHeight: 80, + }, + height100: { + height: 100, + maxHeight: 100, + minHeight: 100, + }, + height120: { + height: 120, + maxHeight: 120, + minHeight: 120, + }, + height200: { + height: 200, + maxHeight: 200, + minHeight: 200, + }, + height300: { + height: 300, + maxHeight: 300, + minHeight: 300, + }, + width30: { + width: 30, + maxWidth: 30, + minWidth: 30, + }, + width60: { + width: 60, + maxWidth: 60, + minWidth: 60, + }, + width80: { + width: 80, + maxWidth: 80, + }, + width150: { + width: 150, + maxWidth: 150, + minWidth: 150, + }, + width350: { + width: 350, + maxWidth: 350, + minWidth: 350, + }, + flex: { + flex: 1, + }, + justifyCenter: { + justifyContent: "center", + }, + justifyFlexStart: { + justifyContent: "flex-start", + }, + justifyFlexEnd: { + justifyContent: "flex-end", + }, + justifySpaceBetween: { + justifyContent: "space-between", + }, + justifySpaceAround: { + justifyContent: "space-around", + }, + alignFlexStart: { + alignItems: "flex-start", + }, + alignFlexEnd: { + alignItems: "flex-end", + }, + alignCenter: { + alignItems: "center", + }, + rounded5: { + borderRadius: 5, + }, + rounded10: { + borderRadius: 10, + }, + rounded20: { + borderRadius: 20, + }, + rounded40: { + borderRadius: 40, + }, + roundedTop40: { + borderTopRightRadius: 35, + borderTopLeftRadius: 35, + }, + flexWrap: { + flexWrap: "wrap", + }, + bodyFont: { + fontSize: 16, + fontFamily: "SpaceMono", + }, + largeFont: { + fontSize: 24, + fontFamily: "SpaceMono", + }, + extraLargeFont: { + fontSize: 32, + fontFamily: "SpaceMono", + }, + boldFont: { + fontSize: 20, + fontFamily: "SpaceMono", + }, + whiteText: { + color: palette.white, + }, + blackText: { + color: palette.black, + }, + centeredText: { + textAlign: "center", + }, + whiteBorderLeft: { + borderLeftWidth: 1, + borderColor: palette.white, + }, + blackBg: { + backgroundColor: palette.black, + }, + whiteBg: { + backgroundColor: palette.white, + }, + transparentBg: { + backgroundColor: "transparent", + }, + whiteBorder: { + borderColor: palette.white, + borderWidth: 1, + }, + absoluteFill: { + position: "absolute", + height: "100%", + width: "100%", + }, + modalHalfBottom: { + flex: 1, + height: "50%", + width: "100%", + backgroundColor: palette.grey, + borderTopRightRadius: 40, + borderTopLeftRadius: 40, + position: "absolute", + padding: 20, + bottom: 0, + }, + modalCenter: { + flex: 1, + height: "50%", + width: "80%", + backgroundColor: palette.grey, + borderRadius: 18, + position: "absolute", + left: "10%", + top: "25%", + }, + modalCenterSmall: { + flex: 1, + height: "35%", + width: "80%", + backgroundColor: palette.grey, + borderRadius: 18, + position: "absolute", + left: "10%", + top: "25%", + }, + textInput: { + borderWidth: 1, + borderRadius: 20, + padding: 20, + backgroundColor: palette.grey, + color: palette.white, + width: "100%", + }, + circle12: { + width: 12, + height: 12, + borderRadius: 6, + }, + circle38: { + width: 38, + height: 38, + borderRadius: 19, + }, + circle50: { + width: 50, + height: 50, + borderRadius: 25, + }, + circle158: { + height: 158, + width: 158, + borderRadius: 79, + }, + circle296: { + height: 296, + width: 296, + borderRadius: 148, + }, + absolute: { + position: "absolute", + }, + right10: { + right: 10, + }, + absoluteTopLeft: { + position: "absolute", + left: 10, + top: 10, + }, + absoluteTopRight: { + position: "absolute", + top: 10, + right: 10, + }, + underlay: { + position: "absolute", + left: 0, + top: 0, + opacity: 0.5, + height: "100%", + width: "100%", + backgroundColor: "black", + }, +});