From 739fcb9b5f5c24c0a1c22fb8a559253d8c0f3c28 Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Thu, 18 Sep 2025 02:57:22 -0400 Subject: [PATCH] working private key --- .../src/components/key-manager/KeyList.tsx | 347 ++++++++++++++++-- apps/mobile/src/lib/query-fns.ts | 22 +- .../rust/uniffi-russh/src/lib.rs | 25 +- 3 files changed, 349 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/components/key-manager/KeyList.tsx b/apps/mobile/src/components/key-manager/KeyList.tsx index 0f20b6c..4348f25 100644 --- a/apps/mobile/src/components/key-manager/KeyList.tsx +++ b/apps/mobile/src/components/key-manager/KeyList.tsx @@ -1,7 +1,10 @@ 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'; import { secretsManager } from '@/lib/secrets-manager'; +import { useTheme } from '@/lib/theme'; export type KeyListMode = 'manage' | 'select'; @@ -10,6 +13,7 @@ export function KeyList(props: { onSelect?: (id: string) => void | Promise; }) { const listKeysQuery = useQuery(secretsManager.keys.query.list); + const theme = useTheme(); const generateMutation = useMutation({ mutationFn: async () => { @@ -28,15 +32,16 @@ export function KeyList(props: { }); return ( - + + listKeysQuery.refetch()} /> + - + {generateMutation.isPending ? 'Generating…' - : 'Generate New RSA 4096 Key'} + : 'Generate New Key (ed25519)'} {listKeysQuery.isLoading ? ( - Loading keys… + Loading keys… ) : listKeysQuery.isError ? ( - Error loading keys + Error loading keys ) : listKeysQuery.data?.length ? ( - + {listKeysQuery.data.map((k) => ( ) : ( - No keys yet + No keys yet )} ); } +function ImportKeyCard({ onImported }: { onImported: () => void }) { + const theme = useTheme(); + const [mode, setMode] = React.useState<'paste' | 'file'>('paste'); + const [label, setLabel] = React.useState('Imported Key'); + const [asDefault, setAsDefault] = React.useState(false); + const [content, setContent] = React.useState(''); + const [fileName, setFileName] = React.useState(null); + + const importMutation = useMutation({ + 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', + isDefault: asDefault, + }, + value: trimmed, + }); + }, + onSuccess: () => { + setContent(''); + setFileName(null); + onImported(); + }, + }); + + const pickFile = React.useCallback(async () => { + const res = await DocumentPicker.getDocumentAsync({ + multiple: false, + copyToCacheDirectory: true, + type: ['text/*', 'application/*'], + }); + // Newer expo-document-picker: { canceled: boolean, assets?: [{ uri, name, ... }] } + const canceled = 'canceled' in res ? res.canceled : false; + if (canceled) return; + const asset = res.assets?.[0]; + const file = asset?.file; + if (!file) return; + setFileName(asset.name ?? null); + const data = await file.text(); + setContent(data); + if (asset.name && (!label || label === 'Imported Key')) { + setLabel(String(asset.name).replace(/\.[^.]+$/, '')); + } + }, [label]); + + return ( + + + Import Private Key + + + + {(['paste', 'file'] as const).map((m) => ( + setMode(m)} + style={{ + flex: 1, + paddingVertical: 10, + alignItems: 'center', + backgroundColor: + mode === m + ? theme.colors.surface + : theme.colors.inputBackground, + }} + > + + {m === 'paste' ? 'Paste' : 'File'} + + + ))} + + + {mode === 'paste' ? ( + + ) : ( + + + + {fileName ? 'Choose Different File' : 'Choose File'} + + + {fileName ? ( + + Selected: {fileName} + + ) : null} + {content ? ( + + ) : null} + + )} + + + + Label + + + + + setAsDefault((v) => !v)} + style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }} + > + + + Set as default + + + + importMutation.mutate()} + style={{ + backgroundColor: theme.colors.primary, + borderRadius: 12, + paddingVertical: 12, + alignItems: 'center', + opacity: importMutation.isPending ? 0.6 : 1, + }} + > + + {importMutation.isPending ? 'Importing…' : 'Import Key'} + + + + {importMutation.isError ? ( + + {(importMutation.error as Error).message || 'Import failed'} + + ) : null} + + ); +} + function KeyRow(props: { entryId: string; mode: KeyListMode; onSelected?: (id: string) => void | Promise; }) { + const theme = useTheme(); const entryQuery = useQuery(secretsManager.keys.query.get(props.entryId)); const entry = entryQuery.data; const [label, setLabel] = React.useState( @@ -141,30 +397,35 @@ function KeyRow(props: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', - backgroundColor: '#0E172B', + backgroundColor: theme.colors.inputBackground, borderWidth: 1, - borderColor: '#2A3655', + borderColor: theme.colors.border, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 12, - marginBottom: 10, }} > - + {entry.manifestEntry.metadata.label ?? entry.manifestEntry.id} {entry.manifestEntry.metadata.isDefault ? ' • Default' : ''} - + ID: {entry.manifestEntry.id} {props.mode === 'manage' ? ( @@ -185,14 +446,20 @@ function KeyRow(props: { setDefaultMutation.mutate(); }} style={{ - backgroundColor: '#2563EB', + backgroundColor: theme.colors.primary, borderRadius: 10, paddingVertical: 12, paddingHorizontal: 10, alignItems: 'center', }} > - + Select @@ -201,9 +468,9 @@ function KeyRow(props: { - + {renameMutation.isPending ? 'Saving…' : 'Save'} @@ -224,9 +497,9 @@ function KeyRow(props: { {!entry.manifestEntry.metadata.isDefault ? ( - + Set Default ) : null} - + Delete diff --git a/apps/mobile/src/lib/query-fns.ts b/apps/mobile/src/lib/query-fns.ts index 3378e7d..966a168 100644 --- a/apps/mobile/src/lib/query-fns.ts +++ b/apps/mobile/src/lib/query-fns.ts @@ -18,17 +18,25 @@ export const useSshConnMutation = () => { mutationFn: async (connectionDetails: InputConnectionDetails) => { try { console.log('Connecting to SSH server...'); + // Resolve security into the RN bridge shape + const security = + connectionDetails.security.type === '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), + }; + const sshConnection = await RnRussh.connect({ host: connectionDetails.host, port: connectionDetails.port, username: connectionDetails.username, - security: - connectionDetails.security.type === 'password' - ? { - type: 'password', - password: connectionDetails.security.password, - } - : { type: 'key', privateKey: 'TODO' }, + security, onStatusChange: (status) => { console.log('SSH connection status', status); }, diff --git a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs index 710f06c..f17afe0 100644 --- a/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs +++ b/packages/react-native-uniffi-russh/rust/uniffi-russh/src/lib.rs @@ -16,7 +16,8 @@ use tokio::sync::{broadcast, Mutex as AsyncMutex}; use russh::{self, client, ChannelMsg, Disconnect}; use russh::client::{Config as ClientConfig, Handle as ClientHandle}; -use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey}; +use russh_keys::{Algorithm as KeyAlgorithm, EcdsaCurve, PrivateKey as RusshKeysPrivateKey}; +use russh::keys::{PrivateKey as RusshPrivateKey, PrivateKeyWithHashAlg}; use russh_keys::ssh_key::{self, LineEnding}; use bytes::Bytes; @@ -698,10 +699,20 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE // Auth let auth = match &details.security { Security::Password { password } => { - handle.authenticate_password(details.username.clone(), password.clone()).await? + handle + .authenticate_password(details.username.clone(), password.clone()) + .await? } - Security::Key { .. } => { - return Err(SshError::UnsupportedKeyType); + // Treat key_id as the OpenSSH PEM-encoded private key content + Security::Key { key_id } => { + // Parse OpenSSH private key text into a russh::keys::PrivateKey + let parsed: RusshPrivateKey = RusshPrivateKey::from_openssh(key_id.as_str()) + .map_err(|e| SshError::RusshKeys(e.to_string()))?; + // Wrap; omit hash preference (server selects or default applies) + let pk_with_hash = PrivateKeyWithHashAlg::new(Arc::new(parsed), None); + handle + .authenticate_publickey(details.username.clone(), pk_with_hash) + .await? } }; match auth { @@ -729,12 +740,12 @@ pub async fn connect(options: ConnectOptions) -> Result, SshE pub async fn generate_key_pair(key_type: KeyType) -> Result { let mut rng = OsRng; let key = match key_type { - KeyType::Rsa => PrivateKey::random(&mut rng, KeyAlgorithm::Rsa { hash: None })?, - KeyType::Ecdsa => PrivateKey::random( + KeyType::Rsa => RusshKeysPrivateKey::random(&mut rng, KeyAlgorithm::Rsa { hash: None })?, + KeyType::Ecdsa => RusshKeysPrivateKey::random( &mut rng, KeyAlgorithm::Ecdsa { curve: EcdsaCurve::NistP256 }, )?, - KeyType::Ed25519 => PrivateKey::random(&mut rng, KeyAlgorithm::Ed25519)?, + KeyType::Ed25519 => RusshKeysPrivateKey::random(&mut rng, KeyAlgorithm::Ed25519)?, KeyType::Ed448 => return Err(SshError::UnsupportedKeyType), }; let pem = key.to_openssh(LineEnding::LF)?; // Zeroizing