working private key

This commit is contained in:
EthanShoeDev
2025-09-18 02:57:22 -04:00
parent d664dc26c0
commit 739fcb9b5f
3 changed files with 349 additions and 45 deletions

View File

@@ -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<void>;
}) {
const listKeysQuery = useQuery(secretsManager.keys.query.list);
const theme = useTheme();
const generateMutation = useMutation({
mutationFn: async () => {
@@ -28,15 +32,16 @@ export function KeyList(props: {
});
return (
<ScrollView contentContainerStyle={{ padding: 16 }}>
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
<ImportKeyCard onImported={() => listKeysQuery.refetch()} />
<Pressable
style={[
{
backgroundColor: '#2563EB',
borderRadius: 10,
paddingVertical: 12,
backgroundColor: theme.colors.primary,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
marginBottom: 12,
},
generateMutation.isPending && { opacity: 0.7 },
]}
@@ -45,19 +50,26 @@ export function KeyList(props: {
generateMutation.mutate();
}}
>
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 14 }}>
<Text
style={{
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
fontSize: 14,
letterSpacing: 0.3,
}}
>
{generateMutation.isPending
? 'Generating…'
: 'Generate New RSA 4096 Key'}
: 'Generate New Key (ed25519)'}
</Text>
</Pressable>
{listKeysQuery.isLoading ? (
<Text style={{ color: '#9AA0A6' }}>Loading keys</Text>
<Text style={{ color: theme.colors.muted }}>Loading keys</Text>
) : listKeysQuery.isError ? (
<Text style={{ color: '#FCA5A5' }}>Error loading keys</Text>
<Text style={{ color: theme.colors.danger }}>Error loading keys</Text>
) : listKeysQuery.data?.length ? (
<View>
<View style={{ gap: 12 }}>
{listKeysQuery.data.map((k) => (
<KeyRow
key={k.id}
@@ -68,17 +80,261 @@ export function KeyList(props: {
))}
</View>
) : (
<Text style={{ color: '#9AA0A6' }}>No keys yet</Text>
<Text style={{ color: theme.colors.muted }}>No keys yet</Text>
)}
</ScrollView>
);
}
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<string | null>(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 (
<View
style={{
backgroundColor: theme.colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: theme.colors.border,
padding: 12,
gap: 12,
}}
>
<Text
style={{
color: theme.colors.textPrimary,
fontWeight: '700',
fontSize: 16,
}}
>
Import Private Key
</Text>
<View
style={{
flexDirection: 'row',
backgroundColor: theme.colors.inputBackground,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.colors.border,
overflow: 'hidden',
}}
>
{(['paste', 'file'] as const).map((m) => (
<Pressable
key={m}
onPress={() => setMode(m)}
style={{
flex: 1,
paddingVertical: 10,
alignItems: 'center',
backgroundColor:
mode === m
? theme.colors.surface
: theme.colors.inputBackground,
}}
>
<Text
style={{
color:
mode === m ? theme.colors.textPrimary : theme.colors.muted,
fontWeight: '600',
}}
>
{m === 'paste' ? 'Paste' : 'File'}
</Text>
</Pressable>
))}
</View>
{mode === 'paste' ? (
<TextInput
multiline
placeholder="Paste your private key here"
placeholderTextColor={theme.colors.muted}
value={content}
onChangeText={setContent}
style={{
minHeight: 120,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.textPrimary,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
padding: 12,
fontFamily: 'Menlo, ui-monospace, monospace',
}}
/>
) : (
<View style={{ gap: 8 }}>
<Pressable
onPress={pickFile}
style={{
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
}}
>
<Text
style={{ color: theme.colors.textSecondary, fontWeight: '600' }}
>
{fileName ? 'Choose Different File' : 'Choose File'}
</Text>
</Pressable>
{fileName ? (
<Text style={{ color: theme.colors.muted }}>
Selected: {fileName}
</Text>
) : null}
{content ? (
<TextInput
editable={false}
multiline
value={content.slice(0, 500)}
style={{
minHeight: 80,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.textSecondary,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
padding: 10,
fontFamily: 'Menlo, ui-monospace, monospace',
}}
/>
) : null}
</View>
)}
<View style={{ gap: 8 }}>
<Text style={{ color: theme.colors.textSecondary, fontSize: 12 }}>
Label
</Text>
<TextInput
placeholder="Display name"
placeholderTextColor={theme.colors.muted}
value={label}
onChangeText={setLabel}
style={{
backgroundColor: theme.colors.inputBackground,
color: theme.colors.textPrimary,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
}}
/>
</View>
<Pressable
onPress={() => setAsDefault((v) => !v)}
style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 2,
borderColor: theme.colors.border,
backgroundColor: asDefault
? theme.colors.primary
: theme.colors.transparent,
}}
/>
<Text style={{ color: theme.colors.textSecondary }}>
Set as default
</Text>
</Pressable>
<Pressable
disabled={importMutation.isPending}
onPress={() => importMutation.mutate()}
style={{
backgroundColor: theme.colors.primary,
borderRadius: 12,
paddingVertical: 12,
alignItems: 'center',
opacity: importMutation.isPending ? 0.6 : 1,
}}
>
<Text
style={{
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
}}
>
{importMutation.isPending ? 'Importing…' : 'Import Key'}
</Text>
</Pressable>
{importMutation.isError ? (
<Text style={{ color: theme.colors.danger }}>
{(importMutation.error as Error).message || 'Import failed'}
</Text>
) : null}
</View>
);
}
function KeyRow(props: {
entryId: string;
mode: KeyListMode;
onSelected?: (id: string) => void | Promise<void>;
}) {
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,
}}
>
<View style={{ flex: 1, marginRight: 8 }}>
<Text style={{ color: '#E5E7EB', fontSize: 15, fontWeight: '600' }}>
<Text
style={{
color: theme.colors.textPrimary,
fontSize: 15,
fontWeight: '600',
}}
>
{entry.manifestEntry.metadata.label ?? entry.manifestEntry.id}
{entry.manifestEntry.metadata.isDefault ? ' • Default' : ''}
</Text>
<Text style={{ color: '#9AA0A6', fontSize: 12, marginTop: 2 }}>
<Text style={{ color: theme.colors.muted, fontSize: 12, marginTop: 2 }}>
ID: {entry.manifestEntry.id}
</Text>
{props.mode === 'manage' ? (
<TextInput
style={{
borderWidth: 1,
borderColor: '#2A3655',
backgroundColor: '#0E172B',
color: '#E5E7EB',
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.textPrimary,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
@@ -172,7 +433,7 @@ function KeyRow(props: {
marginTop: 8,
}}
placeholder="Display name"
placeholderTextColor="#9AA0A6"
placeholderTextColor={theme.colors.muted}
value={label}
onChangeText={setLabel}
/>
@@ -185,14 +446,20 @@ function KeyRow(props: {
setDefaultMutation.mutate();
}}
style={{
backgroundColor: '#2563EB',
backgroundColor: theme.colors.primary,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 10,
alignItems: 'center',
}}
>
<Text style={{ color: '#FFFFFF', fontWeight: '700', fontSize: 12 }}>
<Text
style={{
color: theme.colors.buttonTextOnPrimary,
fontWeight: '700',
fontSize: 12,
}}
>
Select
</Text>
</Pressable>
@@ -201,9 +468,9 @@ function KeyRow(props: {
<Pressable
style={[
{
backgroundColor: 'transparent',
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: '#2A3655',
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 10,
@@ -216,7 +483,13 @@ function KeyRow(props: {
}}
disabled={renameMutation.isPending}
>
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
fontSize: 12,
}}
>
{renameMutation.isPending ? 'Saving…' : 'Save'}
</Text>
</Pressable>
@@ -224,9 +497,9 @@ function KeyRow(props: {
{!entry.manifestEntry.metadata.isDefault ? (
<Pressable
style={{
backgroundColor: 'transparent',
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: '#2A3655',
borderColor: theme.colors.border,
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 10,
@@ -236,16 +509,22 @@ function KeyRow(props: {
setDefaultMutation.mutate();
}}
>
<Text style={{ color: '#C6CBD3', fontWeight: '600', fontSize: 12 }}>
<Text
style={{
color: theme.colors.textSecondary,
fontWeight: '600',
fontSize: 12,
}}
>
Set Default
</Text>
</Pressable>
) : null}
<Pressable
style={{
backgroundColor: 'transparent',
backgroundColor: theme.colors.transparent,
borderWidth: 1,
borderColor: '#7F1D1D',
borderColor: theme.colors.danger,
borderRadius: 10,
paddingVertical: 8,
paddingHorizontal: 10,
@@ -255,7 +534,13 @@ function KeyRow(props: {
deleteMutation.mutate();
}}
>
<Text style={{ color: '#FCA5A5', fontWeight: '700', fontSize: 12 }}>
<Text
style={{
color: theme.colors.danger,
fontWeight: '700',
fontSize: 12,
}}
>
Delete
</Text>
</Pressable>

View File

@@ -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);
},

View File

@@ -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<Arc<SSHConnection>, 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<Arc<SSHConnection>, SshE
pub async fn generate_key_pair(key_type: KeyType) -> Result<String, SshError> {
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<String>