mirror of
https://github.com/EthanShoeDev/fressh.git
synced 2026-01-11 14:22:51 +00:00
secret storage more changes
This commit is contained in:
@@ -3,8 +3,10 @@ import { Picker } from '@react-native-picker/picker';
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useAppForm, withFieldGroup } from '../components/form-components';
|
||||
import { KeyManagerModal } from '../components/key-manager-modal';
|
||||
import {
|
||||
type ConnectionDetails,
|
||||
connectionDetailsSchema,
|
||||
@@ -48,15 +50,14 @@ export default function Index() {
|
||||
value.security.password,
|
||||
);
|
||||
}
|
||||
const privateKey =
|
||||
await secretsManager.keys.utils.betterKeyStorage.getEntry(
|
||||
value.security.keyId,
|
||||
);
|
||||
const privateKey = await secretsManager.keys.utils.getPrivateKey(
|
||||
value.security.keyId,
|
||||
);
|
||||
return await SSHClient.connectWithKey(
|
||||
value.host,
|
||||
value.port,
|
||||
value.username,
|
||||
privateKey,
|
||||
privateKey.value,
|
||||
);
|
||||
})();
|
||||
|
||||
@@ -218,47 +219,54 @@ const KeyPairSection = withFieldGroup({
|
||||
props: {},
|
||||
render: function Render({ group }) {
|
||||
const listPrivateKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||
const [showManager, setShowManager] = React.useState(false);
|
||||
|
||||
return (
|
||||
<group.AppField name="keyId">
|
||||
{(field) =>
|
||||
listPrivateKeysQuery.isLoading ? (
|
||||
<Text style={styles.mutedText}>Loading keys...</Text>
|
||||
) : listPrivateKeysQuery.isError ? (
|
||||
<Text style={styles.errorText}>
|
||||
Error: {listPrivateKeysQuery.error.message}
|
||||
</Text>
|
||||
) : (
|
||||
{(field) => {
|
||||
if (listPrivateKeysQuery.isLoading) {
|
||||
return <Text style={styles.mutedText}>Loading keys...</Text>;
|
||||
}
|
||||
if (listPrivateKeysQuery.isError) {
|
||||
return (
|
||||
<Text style={styles.errorText}>
|
||||
Error: {listPrivateKeysQuery.error.message}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<field.PickerField label="Key">
|
||||
{listPrivateKeysQuery.data?.map((key) => (
|
||||
<Picker.Item key={key.id} label={key.id} value={key.id} />
|
||||
))}
|
||||
{listPrivateKeysQuery.data?.map((key) => {
|
||||
const label = `${key.metadata?.label ?? key.id}${
|
||||
key.metadata?.isDefault ? ' • Default' : ''
|
||||
}`;
|
||||
return (
|
||||
<Picker.Item key={key.id} label={label} value={key.id} />
|
||||
);
|
||||
})}
|
||||
</field.PickerField>
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={async () => {
|
||||
const newKeyPair =
|
||||
await secretsManager.keys.utils.generateKeyPair({
|
||||
type: 'rsa',
|
||||
keySize: 4096,
|
||||
});
|
||||
await secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: 'default',
|
||||
privateKey: newKeyPair.privateKey,
|
||||
priority: 0,
|
||||
});
|
||||
field.handleChange('default');
|
||||
console.log('New key pair generated and saved');
|
||||
}}
|
||||
onPress={() => setShowManager(true)}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>
|
||||
Generate New Key Pair
|
||||
</Text>
|
||||
<Text style={styles.secondaryButtonText}>Manage Keys</Text>
|
||||
</Pressable>
|
||||
<KeyManagerModal
|
||||
visible={showManager}
|
||||
onClose={() => {
|
||||
setShowManager(false);
|
||||
if (!field.state.value && listPrivateKeysQuery.data) {
|
||||
const def = listPrivateKeysQuery.data.find(
|
||||
(k) => k.metadata?.isDefault,
|
||||
);
|
||||
if (def) field.handleChange(def.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
</group.AppField>
|
||||
);
|
||||
},
|
||||
@@ -298,7 +306,7 @@ function ConnectionRow(props: {
|
||||
onSelect: (connection: ConnectionDetails) => void;
|
||||
}) {
|
||||
const detailsQuery = useQuery(secretsManager.connections.query.get(props.id));
|
||||
const details = detailsQuery.data;
|
||||
const details = detailsQuery.data?.value;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
|
||||
325
apps/mobile/src/components/key-manager-modal.tsx
Normal file
325
apps/mobile/src/components/key-manager-modal.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { secretsManager } from '../lib/secrets-manager';
|
||||
|
||||
export function KeyManagerModal(props: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const listKeysQuery = useQuery(secretsManager.keys.query.list);
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const id = `key_${Date.now()}`;
|
||||
const pair = await secretsManager.keys.utils.generateKeyPair({
|
||||
type: 'rsa',
|
||||
keySize: 4096,
|
||||
});
|
||||
await secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: id,
|
||||
metadata: { priority: 0, label: 'New Key', isDefault: false },
|
||||
value: pair.privateKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function handleDelete(keyId: string) {
|
||||
await secretsManager.keys.utils.deletePrivateKey(keyId);
|
||||
}
|
||||
|
||||
async function handleSetDefault(keyId: string) {
|
||||
const entries = await secretsManager.keys.utils.listEntriesWithValues();
|
||||
await Promise.all(
|
||||
entries.map((e) =>
|
||||
secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: e.id,
|
||||
value: e.value,
|
||||
metadata: {
|
||||
priority: e.metadata.priority,
|
||||
label: e.metadata.label,
|
||||
isDefault: e.id === keyId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
await generateMutation.mutateAsync();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={props.visible} transparent animationType="slide">
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Manage Keys</Text>
|
||||
<Pressable style={styles.closeBtn} onPress={props.onClose}>
|
||||
<Text style={styles.closeText}>Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
generateMutation.isPending && { opacity: 0.7 },
|
||||
]}
|
||||
disabled={generateMutation.isPending}
|
||||
onPress={handleGenerate}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{generateMutation.isPending
|
||||
? 'Generating…'
|
||||
: 'Generate New RSA 4096 Key'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{listKeysQuery.isLoading ? (
|
||||
<View style={styles.centerRow}>
|
||||
<ActivityIndicator color="#9AA0A6" />
|
||||
<Text style={styles.muted}> Loading keys…</Text>
|
||||
</View>
|
||||
) : listKeysQuery.isError ? (
|
||||
<Text style={styles.error}>Error loading keys</Text>
|
||||
) : listKeysQuery.data?.length ? (
|
||||
<View>
|
||||
{listKeysQuery.data.map((k) => (
|
||||
<KeyRow
|
||||
key={k.id}
|
||||
entry={k}
|
||||
onDelete={() => handleDelete(k.id)}
|
||||
onSetDefault={() => handleSetDefault(k.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.muted}>No keys yet</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyRow(props: {
|
||||
entry: Awaited<
|
||||
ReturnType<typeof secretsManager.keys.utils.listEntriesWithValues>
|
||||
>[number];
|
||||
onDelete: () => void;
|
||||
onSetDefault: () => void;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [label, setLabel] = React.useState(props.entry.metadata?.label ?? '');
|
||||
const isDefault = props.entry.metadata?.isDefault;
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (newLabel: string) => {
|
||||
await secretsManager.keys.utils.upsertPrivateKey({
|
||||
keyId: props.entry.id,
|
||||
value: props.entry.value,
|
||||
metadata: {
|
||||
priority: props.entry.metadata.priority,
|
||||
label: newLabel,
|
||||
isDefault: props.entry.metadata.isDefault,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: () => setIsEditing(false),
|
||||
});
|
||||
|
||||
async function saveLabel() {
|
||||
await renameMutation.mutateAsync(label);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1, marginRight: 8 }}>
|
||||
<Text style={styles.rowTitle}>
|
||||
{(props.entry.metadata?.label ?? props.entry.id) +
|
||||
(isDefault ? ' • Default' : '')}
|
||||
</Text>
|
||||
<Text style={styles.rowSub}>ID: {props.entry.id}</Text>
|
||||
{isEditing ? (
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Display name"
|
||||
placeholderTextColor="#9AA0A6"
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowActions}>
|
||||
{!isDefault ? (
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={props.onSetDefault}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Set Default</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.secondaryButton,
|
||||
renameMutation.isPending && { opacity: 0.6 },
|
||||
]}
|
||||
onPress={saveLabel}
|
||||
disabled={renameMutation.isPending}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>
|
||||
{renameMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => setIsEditing(true)}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Rename</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable style={styles.dangerButton} onPress={props.onDelete}>
|
||||
<Text style={styles.dangerButtonText}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: '#0B1324',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 16,
|
||||
borderColor: '#1E293B',
|
||||
borderWidth: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
color: '#E5E7EB',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeBtn: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
},
|
||||
closeText: {
|
||||
color: '#C6CBD3',
|
||||
fontWeight: '600',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
backgroundColor: '#0E172B',
|
||||
color: '#E5E7EB',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#2563EB',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
},
|
||||
centerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
muted: {
|
||||
color: '#9AA0A6',
|
||||
},
|
||||
error: {
|
||||
color: '#FCA5A5',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#0E172B',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 10,
|
||||
},
|
||||
rowTitle: {
|
||||
color: '#E5E7EB',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
rowSub: {
|
||||
color: '#9AA0A6',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
rowActions: {
|
||||
gap: 6,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A3655',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: '#C6CBD3',
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#7F1D1D',
|
||||
borderRadius: 10,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
dangerButtonText: {
|
||||
color: '#FCA5A5',
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default KeyManagerModal;
|
||||
@@ -1,8 +1,9 @@
|
||||
import SSHClient from '@dylankenneally/react-native-ssh-sftp';
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import uuid from 'react-native-uuid';
|
||||
import * as z from 'zod';
|
||||
import { queryClient } from './utils';
|
||||
import { queryClient, type StrictOmit } from './utils';
|
||||
|
||||
function splitIntoChunks(data: string, chunkSize: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
@@ -22,7 +23,7 @@ function splitIntoChunks(data: string, chunkSize: number): string[] {
|
||||
function makeBetterSecureStore<
|
||||
T extends object = object,
|
||||
Value = string,
|
||||
>(params: {
|
||||
>(storeParams: {
|
||||
storagePrefix: string;
|
||||
parseValue: (value: string) => Value;
|
||||
extraManifestFieldsSchema?: z.ZodType<T>;
|
||||
@@ -32,11 +33,11 @@ function makeBetterSecureStore<
|
||||
const rootManifestVersion = 1;
|
||||
const manifestChunkVersion = 1;
|
||||
|
||||
const rootManifestKey = `${params.storagePrefix}rootManifest`;
|
||||
const rootManifestKey = [storeParams.storagePrefix, 'rootManifest'].join('-');
|
||||
const manifestChunkKey = (manifestChunkId: string) =>
|
||||
`${params.storagePrefix}manifestChunk_${manifestChunkId}`;
|
||||
[storeParams.storagePrefix, 'manifestChunk', manifestChunkId].join('-');
|
||||
const entryKey = (entryId: string, chunkIdx: number) =>
|
||||
`${params.storagePrefix}entry_${entryId}_chunk_${chunkIdx}`;
|
||||
[storeParams.storagePrefix, 'entry', entryId, 'chunk', chunkIdx].join('-');
|
||||
|
||||
const rootManifestSchema = z.looseObject({
|
||||
manifestVersion: z.number().default(rootManifestVersion),
|
||||
@@ -47,7 +48,7 @@ function makeBetterSecureStore<
|
||||
const entrySchema = z.object({
|
||||
id: z.string(),
|
||||
chunkCount: z.number().default(1),
|
||||
metadata: params.extraManifestFieldsSchema ?? z.object({}),
|
||||
metadata: storeParams.extraManifestFieldsSchema ?? z.object({}),
|
||||
});
|
||||
// type Entry = {
|
||||
// id: string;
|
||||
@@ -66,8 +67,10 @@ function makeBetterSecureStore<
|
||||
const rawRootManifestString =
|
||||
await SecureStore.getItemAsync(rootManifestKey);
|
||||
|
||||
console.log('DEBUG rawRootManifestString', rawRootManifestString);
|
||||
|
||||
console.log(
|
||||
`Root manifest for ${params.storagePrefix} is ${rawRootManifestString?.length} bytes`,
|
||||
`Root manifest for ${rootManifestKey} is ${rawRootManifestString?.length ?? 0} bytes`,
|
||||
);
|
||||
const unsafedRootManifest = rawRootManifestString
|
||||
? JSON.parse(rawRootManifestString)
|
||||
@@ -78,13 +81,14 @@ function makeBetterSecureStore<
|
||||
const rootManifest = rootManifestSchema.parse(unsafedRootManifest);
|
||||
const manifestChunks = await Promise.all(
|
||||
rootManifest.manifestChunksIds.map(async (manifestChunkId) => {
|
||||
const manifestChunkKeyString = manifestChunkKey(manifestChunkId);
|
||||
const rawManifestChunkString = await SecureStore.getItemAsync(
|
||||
manifestChunkKey(manifestChunkId),
|
||||
manifestChunkKeyString,
|
||||
);
|
||||
if (!rawManifestChunkString)
|
||||
throw new Error('Manifest chunk not found');
|
||||
console.log(
|
||||
`Manifest chunk for ${params.storagePrefix} ${manifestChunkId} is ${rawManifestChunkString?.length} bytes`,
|
||||
`Manifest chunk for ${manifestChunkKeyString} is ${rawManifestChunkString?.length} bytes`,
|
||||
);
|
||||
const unsafedManifestChunk = JSON.parse(rawManifestChunkString);
|
||||
return {
|
||||
@@ -100,20 +104,22 @@ function makeBetterSecureStore<
|
||||
};
|
||||
}
|
||||
|
||||
async function _getEntryValueFromManifestEntry(manifestEntry: Entry) {
|
||||
async function _getEntryValueFromManifestEntry(
|
||||
manifestEntry: Entry,
|
||||
): Promise<Value> {
|
||||
const rawEntryChunks = await Promise.all(
|
||||
Array.from({ length: manifestEntry.chunkCount }, async (_, chunkIdx) => {
|
||||
const rawEntryChunk = await SecureStore.getItemAsync(
|
||||
entryKey(manifestEntry.id, chunkIdx),
|
||||
);
|
||||
const entryKeyString = entryKey(manifestEntry.id, chunkIdx);
|
||||
const rawEntryChunk = await SecureStore.getItemAsync(entryKeyString);
|
||||
console.log(
|
||||
`Entry chunk for ${params.storagePrefix} ${manifestEntry.id} ${chunkIdx} is ${rawEntryChunk?.length} bytes`,
|
||||
`Entry chunk for ${entryKeyString} is ${rawEntryChunk?.length} bytes`,
|
||||
);
|
||||
if (!rawEntryChunk) throw new Error('Entry chunk not found');
|
||||
return rawEntryChunk;
|
||||
}),
|
||||
);
|
||||
const entry = rawEntryChunks.join('');
|
||||
return params.parseValue(entry);
|
||||
return storeParams.parseValue(entry);
|
||||
}
|
||||
|
||||
async function getEntry(id: string) {
|
||||
@@ -125,7 +131,10 @@ function makeBetterSecureStore<
|
||||
);
|
||||
if (!manifestEntry) throw new Error('Entry not found');
|
||||
|
||||
return _getEntryValueFromManifestEntry(manifestEntry);
|
||||
return {
|
||||
value: await _getEntryValueFromManifestEntry(manifestEntry),
|
||||
manifestEntry,
|
||||
};
|
||||
}
|
||||
|
||||
async function listEntries() {
|
||||
@@ -136,20 +145,22 @@ function makeBetterSecureStore<
|
||||
return manifestEntries;
|
||||
}
|
||||
|
||||
async function listEntriesWithValues() {
|
||||
async function listEntriesWithValues(): Promise<
|
||||
(Entry & { value: Value })[]
|
||||
> {
|
||||
const manifestEntries = await listEntries();
|
||||
return await Promise.all(
|
||||
manifestEntries.map(async (entry) => {
|
||||
return {
|
||||
...entry,
|
||||
value: await _getEntryValueFromManifestEntry(entry),
|
||||
};
|
||||
} as Entry & { value: Value };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteEntry(id: string) {
|
||||
const manifest = await getManifest();
|
||||
let manifest = await getManifest();
|
||||
const manifestChunkContainingEntry = manifest.manifestChunks.find(
|
||||
(mChunk) => mChunk.manifestChunk.entries.some((entry) => entry.id === id),
|
||||
);
|
||||
@@ -161,27 +172,54 @@ function makeBetterSecureStore<
|
||||
);
|
||||
if (!manifestEntry) throw new Error('Entry not found');
|
||||
|
||||
const deleteEntryChunksPromise = Array.from(
|
||||
{ length: manifestEntry.chunkCount },
|
||||
async (_, chunkIdx) => {
|
||||
await SecureStore.deleteItemAsync(entryKey(id, chunkIdx));
|
||||
},
|
||||
);
|
||||
|
||||
const updateManifestChunkPromise = SecureStore.setItemAsync(
|
||||
manifestChunkKey(manifestChunkContainingEntry.manifestChunkId),
|
||||
JSON.stringify({
|
||||
...manifestChunkContainingEntry,
|
||||
entries: manifestChunkContainingEntry.manifestChunk.entries.filter(
|
||||
(entry) => entry.id !== id,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...deleteEntryChunksPromise,
|
||||
updateManifestChunkPromise,
|
||||
...Array.from(
|
||||
{ length: manifestEntry.chunkCount },
|
||||
async (_, chunkIdx) => {
|
||||
await SecureStore.deleteItemAsync(entryKey(id, chunkIdx));
|
||||
},
|
||||
),
|
||||
SecureStore.setItemAsync(
|
||||
manifestChunkKey(manifestChunkContainingEntry.manifestChunkId),
|
||||
JSON.stringify({
|
||||
...manifestChunkContainingEntry.manifestChunk,
|
||||
entries: manifestChunkContainingEntry.manifestChunk.entries.filter(
|
||||
(entry) => entry.id !== id,
|
||||
),
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
manifest = await getManifest();
|
||||
|
||||
// check for empty manifest chunks
|
||||
const emptyManifestChunks = manifest.manifestChunks.filter(
|
||||
(mChunk) => mChunk.manifestChunk.entries.length === 0,
|
||||
);
|
||||
if (emptyManifestChunks.length > 0) {
|
||||
console.log(
|
||||
'DEBUG: removing empty manifest chunks',
|
||||
emptyManifestChunks.length,
|
||||
);
|
||||
manifest.rootManifest.manifestChunksIds =
|
||||
manifest.rootManifest.manifestChunksIds.filter(
|
||||
(mChunkId) =>
|
||||
!emptyManifestChunks.some(
|
||||
(mChunk) => mChunk.manifestChunkId === mChunkId,
|
||||
),
|
||||
);
|
||||
await Promise.all([
|
||||
...emptyManifestChunks.map(async (mChunk) => {
|
||||
await SecureStore.deleteItemAsync(
|
||||
manifestChunkKey(mChunk.manifestChunkId),
|
||||
);
|
||||
}),
|
||||
SecureStore.setItemAsync(
|
||||
rootManifestKey,
|
||||
JSON.stringify(manifest.rootManifest),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertEntry(params: {
|
||||
@@ -197,8 +235,8 @@ function makeBetterSecureStore<
|
||||
const newManifestEntry = entrySchema.parse({
|
||||
id: params.id,
|
||||
chunkCount: valueChunks.length,
|
||||
...params.metadata,
|
||||
});
|
||||
metadata: params.metadata,
|
||||
} satisfies Entry);
|
||||
const newManifestEntrySize = JSON.stringify(newManifestEntry).length;
|
||||
if (newManifestEntrySize > sizeLimit / 2)
|
||||
throw new Error('Manifest entry size is too large');
|
||||
@@ -207,6 +245,10 @@ function makeBetterSecureStore<
|
||||
const existingManifestChunkWithRoom = manifest.manifestChunks.find(
|
||||
(mChunk) => sizeLimit > mChunk.manifestChunkSize + newManifestEntrySize,
|
||||
);
|
||||
console.log(
|
||||
'DEBUG existingManifestChunkWithRoom',
|
||||
existingManifestChunkWithRoom,
|
||||
);
|
||||
const manifestChunkWithRoom =
|
||||
existingManifestChunkWithRoom ??
|
||||
(await (async () => {
|
||||
@@ -215,28 +257,43 @@ function makeBetterSecureStore<
|
||||
entries: [],
|
||||
manifestChunkVersion: manifestChunkVersion,
|
||||
},
|
||||
manifestChunkId: crypto.randomUUID(),
|
||||
manifestChunkId: String(uuid.v4()),
|
||||
manifestChunkSize: 0,
|
||||
} satisfies NonNullable<(typeof manifest.manifestChunks)[number]>;
|
||||
console.log(
|
||||
`Adding new manifest chunk ${newManifestChunk.manifestChunkId}`,
|
||||
);
|
||||
manifest.rootManifest.manifestChunksIds.push(
|
||||
newManifestChunk.manifestChunkId,
|
||||
);
|
||||
await SecureStore.setItemAsync(
|
||||
rootManifestKey,
|
||||
JSON.stringify(manifest.rootManifest),
|
||||
);
|
||||
console.log('DEBUG: newRootManifest', manifest.rootManifest);
|
||||
return newManifestChunk;
|
||||
})());
|
||||
|
||||
manifestChunkWithRoom.manifestChunk.entries.push(newManifestEntry);
|
||||
const manifestChunkKeyString = manifestChunkKey(
|
||||
manifestChunkWithRoom.manifestChunkId,
|
||||
);
|
||||
await Promise.all([
|
||||
SecureStore.setItemAsync(
|
||||
manifestChunkKey(manifestChunkWithRoom.manifestChunkId),
|
||||
manifestChunkKeyString,
|
||||
JSON.stringify(manifestChunkWithRoom.manifestChunk),
|
||||
),
|
||||
...valueChunks.map((vChunk, chunkIdx) =>
|
||||
SecureStore.setItemAsync(
|
||||
entryKey(newManifestEntry.id, chunkIdx),
|
||||
vChunk,
|
||||
),
|
||||
),
|
||||
).then(() => {
|
||||
console.log(
|
||||
`Set manifest chunk for ${manifestChunkKeyString} to ${JSON.stringify(manifestChunkWithRoom.manifestChunk).length} bytes`,
|
||||
);
|
||||
}),
|
||||
...valueChunks.map(async (vChunk, chunkIdx) => {
|
||||
const entryKeyString = entryKey(newManifestEntry.id, chunkIdx);
|
||||
await SecureStore.setItemAsync(entryKeyString, vChunk);
|
||||
console.log(
|
||||
`Set entry chunk for ${entryKeyString} ${chunkIdx} to ${vChunk.length} bytes`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -250,28 +307,44 @@ function makeBetterSecureStore<
|
||||
};
|
||||
}
|
||||
|
||||
const betterKeyStorage = makeBetterSecureStore({
|
||||
storagePrefix: 'privateKey_',
|
||||
extraManifestFieldsSchema: z.object({
|
||||
priority: z.number(),
|
||||
createdAtMs: z.int(),
|
||||
}),
|
||||
const keyMetadataSchema = z.object({
|
||||
priority: z.number(),
|
||||
createdAtMs: z.int(),
|
||||
// Optional display name for the key
|
||||
label: z.string().optional(),
|
||||
// Optional default flag
|
||||
isDefault: z.boolean().optional(),
|
||||
});
|
||||
export type KeyMetadata = z.infer<typeof keyMetadataSchema>;
|
||||
|
||||
const betterKeyStorage = makeBetterSecureStore<KeyMetadata, string>({
|
||||
storagePrefix: 'privateKey',
|
||||
extraManifestFieldsSchema: keyMetadataSchema,
|
||||
parseValue: (value) => value,
|
||||
});
|
||||
|
||||
async function upsertPrivateKey(params: {
|
||||
keyId: string;
|
||||
privateKey: string;
|
||||
priority: number;
|
||||
metadata: StrictOmit<KeyMetadata, 'createdAtMs'>;
|
||||
value: string;
|
||||
}) {
|
||||
console.log(`Upserting private key ${params.keyId}`);
|
||||
// Preserve createdAtMs if the entry already exists
|
||||
const existing = await betterKeyStorage
|
||||
.getEntry(params.keyId)
|
||||
.catch(() => undefined);
|
||||
const createdAtMs =
|
||||
existing?.manifestEntry.metadata.createdAtMs ?? Date.now();
|
||||
|
||||
await betterKeyStorage.upsertEntry({
|
||||
id: params.keyId,
|
||||
metadata: {
|
||||
priority: params.priority,
|
||||
createdAtMs: Date.now(),
|
||||
...params.metadata,
|
||||
createdAtMs,
|
||||
},
|
||||
value: params.privateKey,
|
||||
value: params.value,
|
||||
});
|
||||
console.log('DEBUG: invalidating key query');
|
||||
await queryClient.invalidateQueries({ queryKey: [keyQueryKey] });
|
||||
}
|
||||
|
||||
@@ -284,7 +357,11 @@ const keyQueryKey = 'keys';
|
||||
|
||||
const listKeysQueryOptions = queryOptions({
|
||||
queryKey: [keyQueryKey],
|
||||
queryFn: async () => await betterKeyStorage.listEntries(),
|
||||
queryFn: async () => {
|
||||
const results = await betterKeyStorage.listEntriesWithValues();
|
||||
console.log(`Listed ${results.length} private keys`);
|
||||
return results;
|
||||
},
|
||||
});
|
||||
|
||||
const getKeyQueryOptions = (keyId: string) =>
|
||||
@@ -310,7 +387,7 @@ export const connectionDetailsSchema = z.object({
|
||||
});
|
||||
|
||||
const betterConnectionStorage = makeBetterSecureStore({
|
||||
storagePrefix: 'connection_',
|
||||
storagePrefix: 'connection',
|
||||
extraManifestFieldsSchema: z.object({
|
||||
priority: z.number(),
|
||||
createdAtMs: z.int(),
|
||||
@@ -335,6 +412,7 @@ async function upsertConnection(params: {
|
||||
},
|
||||
value: JSON.stringify(params.details),
|
||||
});
|
||||
console.log('DEBUG: invalidating connection query');
|
||||
await queryClient.invalidateQueries({ queryKey: [connectionQueryKey] });
|
||||
return params.details;
|
||||
}
|
||||
@@ -377,10 +455,12 @@ async function generateKeyPair(params: {
|
||||
export const secretsManager = {
|
||||
keys: {
|
||||
utils: {
|
||||
betterKeyStorage,
|
||||
upsertPrivateKey,
|
||||
deletePrivateKey,
|
||||
generateKeyPair,
|
||||
listEntriesWithValues: betterKeyStorage.listEntriesWithValues,
|
||||
getPrivateKey: (keyId: string) => betterKeyStorage.getEntry(keyId),
|
||||
// Intentionally no specialized setters; use upsertPrivateKey instead.
|
||||
},
|
||||
query: {
|
||||
list: listKeysQueryOptions,
|
||||
@@ -389,7 +469,6 @@ export const secretsManager = {
|
||||
},
|
||||
connections: {
|
||||
utils: {
|
||||
betterConnectionStorage,
|
||||
upsertConnection,
|
||||
deleteConnection,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
export type StrictOmit<T, K extends keyof T> = Omit<T, K>;
|
||||
|
||||
Reference in New Issue
Block a user