diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 516c991..235f6e5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -21,7 +21,9 @@ "expo:dep:check": "expo install --fix", "expo:doctor": "pnpm dlx expo-doctor@latest", "test:e2e": "maestro test test/e2e/", - "adb:logs": "while ! adb logcat --pid=$(adb shell pidof -s dev.fressh.app); do sleep 1; done" + "adb:logs": "while ! adb logcat --pid=$(adb shell pidof -s dev.fressh.app); do sleep 1; done", + "android": "expo run:android", + "ios": "expo run:ios" }, "dependencies": { "@expo/vector-icons": "^15.0.2", diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 9424ebd..569e6c4 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -683,13 +683,10 @@ function ConnectionRow(props: { } // Recreate under new id then delete old await secretsManager.connections.utils.upsertConnection({ - id: newId, details, priority: 0, + label: newId, }); - await secretsManager.connections.utils.deleteConnection( - props.id, - ); await listQuery.refetch(); setRenameOpen(false); }} diff --git a/apps/mobile/src/app/(tabs)/shell/index.tsx b/apps/mobile/src/app/(tabs)/shell/index.tsx index 4cd8bad..9de6c9f 100644 --- a/apps/mobile/src/app/(tabs)/shell/index.tsx +++ b/apps/mobile/src/app/(tabs)/shell/index.tsx @@ -16,6 +16,7 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useShallow } from 'zustand/react/shallow'; import { preferences } from '@/lib/preferences'; import {} from '@/lib/query-fns'; import { useSshStore } from '@/lib/ssh-store'; @@ -31,7 +32,9 @@ export default function TabsShellList() { } function ShellContent() { - const connections = useSshStore((s) => Object.values(s.connections)); + const connections = useSshStore( + useShallow((s) => Object.values(s.connections)), + ); return ( @@ -92,7 +95,7 @@ function FlatView({ }: { setActionTarget: (target: ActionTarget) => void; }) { - const shells = useSshStore((s) => Object.values(s.shells)); + const shells = useSshStore(useShallow((s) => Object.values(s.shells))); return ( @@ -125,8 +128,10 @@ function GroupedView({ }) { const theme = useTheme(); const [expanded, setExpanded] = React.useState>({}); - const connections = useSshStore((s) => Object.values(s.connections)); - const shells = useSshStore((s) => Object.values(s.shells)); + const connections = useSshStore( + useShallow((s) => Object.values(s.connections)), + ); + const shells = useSshStore(useShallow((s) => Object.values(s.shells))); return ( data={connections} diff --git a/apps/mobile/src/components/key-manager/KeyList.tsx b/apps/mobile/src/components/key-manager/KeyList.tsx index 4348f25..6ead100 100644 --- a/apps/mobile/src/components/key-manager/KeyList.tsx +++ b/apps/mobile/src/components/key-manager/KeyList.tsx @@ -1,5 +1,5 @@ +import { RnRussh } from '@fressh/react-native-uniffi-russh'; import { useMutation, useQuery } from '@tanstack/react-query'; -import * as Crypto from 'expo-crypto'; import * as DocumentPicker from 'expo-document-picker'; import React from 'react'; import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; @@ -17,13 +17,8 @@ export function KeyList(props: { const generateMutation = useMutation({ mutationFn: async () => { - const id = `key_${Date.now()}`; - const pair = await secretsManager.keys.utils.generateKeyPair({ - type: 'rsa', - keySize: 4096, - }); + const pair = await RnRussh.generateKeyPair('ed25519'); await secretsManager.keys.utils.upsertPrivateKey({ - keyId: id, metadata: { priority: 0, label: 'New Key', isDefault: false }, value: pair, }); @@ -98,9 +93,7 @@ function ImportKeyCard({ onImported }: { onImported: () => void }) { mutationFn: async () => { const trimmed = content.trim(); if (!trimmed) throw new Error('No key content provided'); - const keyId = `key_${Crypto.randomUUID()}`; await secretsManager.keys.utils.upsertPrivateKey({ - keyId, metadata: { priority: 0, label: label || 'Imported Key', diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts index 5b576da..cc9b9eb 100644 --- a/apps/mobile/src/lib/query-fns.ts +++ b/apps/mobile/src/lib/query-fns.ts @@ -19,15 +19,15 @@ export const useSshConnMutation = (opts?: { const security = connectionDetails.security.type === 'password' ? { - type: 'password' as const, - password: connectionDetails.security.password, - } + type: 'password' as const, + password: connectionDetails.security.password, + } : { - type: 'key' as const, - privateKey: await secretsManager.keys.utils - .getPrivateKey(connectionDetails.security.keyId) - .then((e) => e.value), - }; + type: 'key' as const, + privateKey: await secretsManager.keys.utils + .getPrivateKey(connectionDetails.security.keyId) + .then((e) => e.value), + }; const sshConnection = await connect({ host: connectionDetails.host, @@ -42,7 +42,7 @@ export const useSshConnMutation = (opts?: { }); await secretsManager.connections.utils.upsertConnection({ - id: sshConnection.connectionId, + label: `${connectionDetails.username}@${connectionDetails.host}:${connectionDetails.port}`, details: connectionDetails, priority: 0, }); diff --git a/apps/mobile/src/lib/secrets-manager.ts b/apps/mobile/src/lib/secrets-manager.ts index bf7091d..1187c1e 100644 --- a/apps/mobile/src/lib/secrets-manager.ts +++ b/apps/mobile/src/lib/secrets-manager.ts @@ -82,9 +82,9 @@ function makeBetterSecureStore< const unsafedRootManifest: unknown = rawRootManifestString ? JSON.parse(rawRootManifestString) : { - manifestVersion: rootManifestVersion, - manifestChunksIds: [], - }; + manifestVersion: rootManifestVersion, + manifestChunksIds: [], + }; const rootManifest = rootManifestSchema.parse(unsafedRootManifest); const manifestChunks = await Promise.all( rootManifest.manifestChunksIds.map(async (manifestChunkId) => { @@ -290,6 +290,7 @@ function makeBetterSecureStore< }), ...valueChunks.map(async (vChunk, chunkIdx) => { const entryKeyString = entryKey(newManifestEntry.id, chunkIdx); + console.log('DEBUG: setting entry chunk', entryKeyString); await SecureStore.setItemAsync(entryKeyString, vChunk); log( `Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`, @@ -325,20 +326,23 @@ const betterKeyStorage = makeBetterSecureStore({ }); async function upsertPrivateKey(params: { - keyId: string; + keyId?: string; metadata: StrictOmit; value: string; }) { - log(`Upserting private key ${params.keyId}`); + const validKey = RnRussh.validatePrivateKey(params.value); + if (!validKey) throw new Error('Invalid private key'); + const keyId = params.keyId ?? `key_${Crypto.randomUUID()}`; + log(`${params.keyId ? 'Upserting' : 'Creating'} private key ${keyId}`); // Preserve createdAtMs if the entry already exists const existing = await betterKeyStorage - .getEntry(params.keyId) + .getEntry(keyId) .catch(() => undefined); const createdAtMs = existing?.manifestEntry.metadata.createdAtMs ?? Date.now(); await betterKeyStorage.upsertEntry({ - id: params.keyId, + id: keyId, metadata: { ...params.metadata, createdAtMs, @@ -393,6 +397,7 @@ const betterConnectionStorage = makeBetterSecureStore({ priority: z.number(), createdAtMs: z.int(), modifiedAtMs: z.int(), + label: z.string().optional(), }), parseValue: (value) => connectionDetailsSchema.parse(JSON.parse(value)), }); @@ -400,16 +405,18 @@ const betterConnectionStorage = makeBetterSecureStore({ export type InputConnectionDetails = z.infer; async function upsertConnection(params: { - id: string; details: InputConnectionDetails; priority: number; + label?: string; }) { + const id = `${params.details.username}-${params.details.host}-${params.details.port}`.replaceAll('.', '_'); await betterConnectionStorage.upsertEntry({ - id: params.id, + id, metadata: { priority: params.priority, - createdAtMs: Date.now(), modifiedAtMs: Date.now(), + createdAtMs: Date.now(), + label: params.label, }, value: JSON.stringify(params.details), }); @@ -436,32 +443,13 @@ const getConnectionQueryOptions = (id: string) => queryFn: () => betterConnectionStorage.getEntry(id), }); -// https://github.com/dylankenneally/react-native-ssh-sftp/blob/ea55436d8d40378a8f9dabb95b463739ffb219fa/android/src/main/java/me/keeex/rnssh/RNSshClientModule.java#L101-L119 -export type SshPrivateKeyType = 'dsa' | 'rsa' | 'ecdsa' | 'ed25519' | 'ed448'; -async function generateKeyPair(params: { - type: SshPrivateKeyType; - passphrase?: string; - keySize?: number; - comment?: string; -}) { - log('DEBUG: generating key pair', params); - const keyPair = await RnRussh.generateKeyPair( - 'ed25519', - // params.keySize, - // params.comment ?? '', - ); - return keyPair; -} - export const secretsManager = { keys: { utils: { upsertPrivateKey, deletePrivateKey, - generateKeyPair, listEntriesWithValues: betterKeyStorage.listEntriesWithValues, getPrivateKey: (keyId: string) => betterKeyStorage.getEntry(keyId), - // Intentionally no specialized setters; use upsertPrivateKey instead. }, query: { list: listKeysQueryOptions, diff --git a/packages/react-native-uniffi-russh/src/api.ts b/packages/react-native-uniffi-russh/src/api.ts index 89b9ff4..edf274b 100644 --- a/packages/react-native-uniffi-russh/src/api.ts +++ b/packages/react-native-uniffi-russh/src/api.ts @@ -33,8 +33,8 @@ export type ConnectionDetails = { port: number; username: string; security: - | { type: 'password'; password: string } - | { type: 'key'; privateKey: string }; + | { type: 'password'; password: string } + | { type: 'key'; privateKey: string }; }; /** @@ -147,7 +147,13 @@ export type SshShell = { type RusshApi = { uniffiInitAsync: () => Promise; connect: (opts: ConnectOptions) => Promise; - generateKeyPair: (type: 'rsa' | 'ecdsa' | 'ed25519') => Promise; + generateKeyPair: (type: 'rsa' | 'ecdsa' | 'ed25519', + // TODO: Add these + // passphrase?: string; + // keySize?: number; + // comment?: string; + ) => Promise; + validatePrivateKey: (key: string) => boolean; }; // #endregion @@ -312,8 +318,8 @@ function wrapConnection( term: terminalTypeLiteralToEnum[params.term], onClosedCallback: params.onClosed ? { - onChange: (channelId) => params.onClosed!(channelId), - } + onChange: (channelId) => params.onClosed!(channelId), + } : undefined, terminalMode: params.terminalMode, terminalPixelSize: params.terminalPixelSize, @@ -332,11 +338,11 @@ async function connect(options: ConnectOptions): Promise { const security = options.security.type === 'password' ? new GeneratedRussh.Security.Password({ - password: options.security.password, - }) + password: options.security.password, + }) : new GeneratedRussh.Security.Key({ - privateKeyContent: options.security.privateKey, - }); + privateKeyContent: options.security.privateKey, + }); const sshConnection = await GeneratedRussh.connect( { connectionDetails: { @@ -347,16 +353,16 @@ async function connect(options: ConnectOptions): Promise { }, onConnectionProgressCallback: options.onConnectionProgress ? { - onChange: (statusEnum) => - options.onConnectionProgress!( - sshConnProgressEnumToLiteral[statusEnum] - ), - } + onChange: (statusEnum) => + options.onConnectionProgress!( + sshConnProgressEnumToLiteral[statusEnum] + ), + } : undefined, onDisconnectedCallback: options.onDisconnected ? { - onChange: (connectionId) => options.onDisconnected!(connectionId), - } + onChange: (connectionId) => options.onDisconnected!(connectionId), + } : undefined, }, options.abortSignal ? { signal: options.abortSignal } : undefined @@ -373,10 +379,20 @@ async function generateKeyPair(type: 'rsa' | 'ecdsa' | 'ed25519') { return GeneratedRussh.generateKeyPair(map[type]); } +function validatePrivateKey(key: string) { + try { + GeneratedRussh.validatePrivateKey(key); + return true; + } catch { + return false; + } +} + // #endregion export const RnRussh = { uniffiInitAsync: GeneratedRussh.uniffiInitAsync, connect, generateKeyPair, + validatePrivateKey, } satisfies RusshApi; diff --git a/packages/react-native-xtermjs-webview/vite.config.ts b/packages/react-native-xtermjs-webview/vite.config.ts index d33308d..0731de0 100644 --- a/packages/react-native-xtermjs-webview/vite.config.ts +++ b/packages/react-native-xtermjs-webview/vite.config.ts @@ -8,7 +8,9 @@ const logExternal: boolean = false; export default defineConfig({ plugins: [ - react(), + react({ + + }), dts({ tsconfigPath: './tsconfig.app.json', // This makes dist/ look nice but breaks Cmd + Click