diff --git a/android/app/src/main/java/org/enbox/mobile/nativemodules/NativeBiometricVaultModule.kt b/android/app/src/main/java/org/enbox/mobile/nativemodules/NativeBiometricVaultModule.kt index 44e85dc..0b1eeb5 100644 --- a/android/app/src/main/java/org/enbox/mobile/nativemodules/NativeBiometricVaultModule.kt +++ b/android/app/src/main/java/org/enbox/mobile/nativemodules/NativeBiometricVaultModule.kt @@ -940,8 +940,18 @@ class NativeBiometricVaultModule(reactContext: ReactApplicationContext) : Native return } try { + // CHECKED Keystore delete first. The wrapped secret prefs remain + // intact until the OS-gated key is proven gone, so a Keystore + // failure leaves the vault in a retryable, still-intact state. + // Previously the prefs were removed before this checked delete; + // if the Keystore delete then failed, the wallet secret was + // unrecoverable even though reset rejected. + deleteKeystoreKeyChecked(keyAlias) + // Use `commit()` so a prefs write failure is surfaced before - // the JS layer clears the reset sentinel. + // the JS layer clears the reset sentinel. A failure here leaves + // stale ciphertext prefs without a Keystore key; the next + // sentinel retry will re-run this idempotently and remove them. val prefsRemoved = prefs() .edit() .remove(ivKey(keyAlias)) @@ -954,16 +964,6 @@ class NativeBiometricVaultModule(reactContext: ReactApplicationContext) : Native ) return } - // CHECKED Keystore delete. Any failure here - // (KeyStoreException, ProviderException, IOException, or - // the silent-OEM-fail caught by the post-delete - // containsAlias() guard) propagates to JS via promise.reject - // so `useAgentStore.reset()` can persist the - // VAULT_RESET_PENDING_KEY sentinel and surface the error to - // the user. Previously, `deleteKeystoreKey` swallowed every - // exception and `deleteSecret` resolved successfully even - // when the OS-gated key remained on disk. - deleteKeystoreKeyChecked(keyAlias) // Both halves succeeded — missing alias is a vacuous // success path inside `deleteKeystoreKeyChecked` (it // skips the deleteEntry call when containsAlias is false). diff --git a/ios/EnboxMobile.xcodeproj/project.pbxproj b/ios/EnboxMobile.xcodeproj/project.pbxproj index 96ed3a1..2befdba 100644 --- a/ios/EnboxMobile.xcodeproj/project.pbxproj +++ b/ios/EnboxMobile.xcodeproj/project.pbxproj @@ -13,8 +13,11 @@ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; BV000001A00000000000000A /* RCTNativeBiometricVault.mm in Sources */ = {isa = PBXBuildFile; fileRef = BV000002A00000000000000B /* RCTNativeBiometricVault.mm */; }; BV000003A00000000000000C /* RCTNativeBiometricVault.h in Headers */ = {isa = PBXBuildFile; fileRef = BV000004A00000000000000D /* RCTNativeBiometricVault.h */; }; + CR000001A00000000000000A /* RCTNativeCrypto.mm in Sources */ = {isa = PBXBuildFile; fileRef = CR000002A00000000000000B /* RCTNativeCrypto.mm */; }; LA000001A00000000000F001 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = LA000002A00000000000F002 /* LocalAuthentication.framework */; }; + PR000001A00000000000000A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; SE000001A00000000000F003 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = SE000002A00000000000F004 /* Security.framework */; }; + SS000001A00000000000000A /* RCTNativeSecureStorage.mm in Sources */ = {isa = PBXBuildFile; fileRef = SS000002A00000000000000B /* RCTNativeSecureStorage.mm */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,8 +33,12 @@ ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; BV000002A00000000000000B /* RCTNativeBiometricVault.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTNativeBiometricVault.mm; path = RCTNativeBiometricVault.mm; sourceTree = ""; }; BV000004A00000000000000D /* RCTNativeBiometricVault.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RCTNativeBiometricVault.h; path = RCTNativeBiometricVault.h; sourceTree = ""; }; + CR000002A00000000000000B /* RCTNativeCrypto.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTNativeCrypto.mm; path = RCTNativeCrypto.mm; sourceTree = ""; }; + CR000004A00000000000000D /* RCTNativeCrypto.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RCTNativeCrypto.h; path = RCTNativeCrypto.h; sourceTree = ""; }; LA000002A00000000000F002 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = System/Library/Frameworks/LocalAuthentication.framework; sourceTree = SDKROOT; }; SE000002A00000000000F004 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + SS000002A00000000000000B /* RCTNativeSecureStorage.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTNativeSecureStorage.mm; path = RCTNativeSecureStorage.mm; sourceTree = ""; }; + SS000004A00000000000000D /* RCTNativeSecureStorage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RCTNativeSecureStorage.h; path = RCTNativeSecureStorage.h; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -57,10 +64,22 @@ 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, BV000005A00000000000000E /* NativeBiometricVault */, + SS000005A00000000000000E /* NativeSecureStorage */, + CR000005A00000000000000E /* NativeCrypto */, ); name = EnboxMobile; sourceTree = ""; }; + CR000005A00000000000000E /* NativeCrypto */ = { + isa = PBXGroup; + children = ( + CR000004A00000000000000D /* RCTNativeCrypto.h */, + CR000002A00000000000000B /* RCTNativeCrypto.mm */, + ); + name = NativeCrypto; + path = EnboxMobile/NativeCrypto; + sourceTree = ""; + }; BV000005A00000000000000E /* NativeBiometricVault */ = { isa = PBXGroup; children = ( @@ -71,6 +90,16 @@ path = EnboxMobile/NativeBiometricVault; sourceTree = ""; }; + SS000005A00000000000000E /* NativeSecureStorage */ = { + isa = PBXGroup; + children = ( + SS000004A00000000000000D /* RCTNativeSecureStorage.h */, + SS000002A00000000000000B /* RCTNativeSecureStorage.mm */, + ); + name = NativeSecureStorage; + path = EnboxMobile/NativeSecureStorage; + sourceTree = ""; + }; 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -182,6 +211,7 @@ files = ( 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + PR000001A00000000000000A /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -269,6 +299,8 @@ files = ( 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, BV000001A00000000000000A /* RCTNativeBiometricVault.mm in Sources */, + SS000001A00000000000000A /* RCTNativeSecureStorage.mm in Sources */, + CR000001A00000000000000A /* RCTNativeCrypto.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/EnboxMobile/NativeCrypto/RCTNativeCrypto.mm b/ios/EnboxMobile/NativeCrypto/RCTNativeCrypto.mm index 539c307..071f798 100644 --- a/ios/EnboxMobile/NativeCrypto/RCTNativeCrypto.mm +++ b/ios/EnboxMobile/NativeCrypto/RCTNativeCrypto.mm @@ -1,5 +1,6 @@ #import "RCTNativeCrypto.h" #import +#import #import #import @@ -53,11 +54,11 @@ - (void)pbkdf2:(NSString *)password int status = CCKeyDerivationPBKDF( kCCPBKDF2, - passwordData.bytes, passwordData.length, + (const char *)passwordData.bytes, passwordData.length, (const uint8_t *)saltData.bytes, saltData.length, kCCPRFHmacAlgSHA256, (uint)iterations, - derivedKey.mutableBytes, keyLen + (uint8_t *)derivedKey.mutableBytes, keyLen ); if (status != kCCSuccess) { diff --git a/src/features/connect/screens/wallet-connect-request-screen.tsx b/src/features/connect/screens/wallet-connect-request-screen.tsx index d44d36c..5321806 100644 --- a/src/features/connect/screens/wallet-connect-request-screen.tsx +++ b/src/features/connect/screens/wallet-connect-request-screen.tsx @@ -96,7 +96,7 @@ export function WalletConnectRequestScreen() { useEffect(() => { if (!selectedDid && identities.length > 0) { - setSelectedDid(identities[0].metadata.uri); + setSelectedDid(identities[0].metadata?.uri ?? identities[0].did?.uri); } }, [identities, selectedDid]); @@ -128,7 +128,7 @@ export function WalletConnectRequestScreen() { setCreatingIdentity(true); try { const identity = await createIdentity(identityName.trim()); - setSelectedDid(identity.metadata.uri); + setSelectedDid(identity.metadata?.uri ?? identity.did?.uri); setIdentityName(''); } catch (err) { Alert.alert('Identity creation failed', err instanceof Error ? err.message : 'Could not create identity'); @@ -273,7 +273,7 @@ export function WalletConnectRequestScreen() { ) : ( {identities.map((identity) => { - const did = identity.metadata.uri; + const did = identity.metadata?.uri ?? identity.did?.uri; const selected = selectedDid === did; return ( s.identities); const createIdentity = useAgentStore((s) => s.createIdentity); + const updateIdentityName = useAgentStore((s) => s.updateIdentityName); + const deleteIdentity = useAgentStore((s) => s.deleteIdentity); const refreshIdentities = useAgentStore((s) => s.refreshIdentities); const agent = useAgentStore((s) => s.agent); const isInitializing = useAgentStore((s) => s.isInitializing); @@ -29,6 +31,14 @@ export function IdentitiesScreen() { const [showCreate, setShowCreate] = useState(false); const [name, setName] = useState(''); const [creating, setCreating] = useState(false); + const [selectedDid, setSelectedDid] = useState(null); + const [editName, setEditName] = useState(''); + const [saving, setSaving] = useState(false); + + const selectedIdentity = identities.find((identity) => { + const did = identity.metadata?.uri ?? identity.did?.uri; + return did === selectedDid; + }); const handleCreate = useCallback(async () => { if (!name.trim() || creating) return; @@ -45,6 +55,53 @@ export function IdentitiesScreen() { } }, [name, creating, createIdentity]); + const handleSelect = useCallback((identity: any) => { + const did = identity.metadata?.uri ?? identity.did?.uri; + if (!did) return; + setSelectedDid(did); + setEditName(identity.metadata?.name ?? ''); + }, []); + + const handleSaveName = useCallback(async () => { + if (!selectedDid || !editName.trim() || saving) return; + setSaving(true); + try { + await updateIdentityName(selectedDid, editName.trim()); + } catch (err) { + Alert.alert('Update failed', err instanceof Error ? err.message : 'Could not update identity'); + } finally { + setSaving(false); + } + }, [editName, saving, selectedDid, updateIdentityName]); + + const handleDelete = useCallback(() => { + if (!selectedDid) return; + Alert.alert( + 'Delete identity', + 'This removes the identity and its local key material from this wallet. Make sure you have an identity backup before continuing.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + deleteIdentity(selectedDid) + .then(() => { + setSelectedDid(null); + setEditName(''); + }) + .catch((err) => { + Alert.alert( + 'Delete failed', + err instanceof Error ? err.message : 'Could not delete identity', + ); + }); + }, + }, + ], + ); + }, [deleteIdentity, selectedDid]); + useEffect(() => { if (agent) { refreshIdentities().catch(() => {}); @@ -130,13 +187,72 @@ export function IdentitiesScreen() { )} + {selectedIdentity ? ( + + Identity details + + DID + + {selectedIdentity.metadata?.uri ?? selectedIdentity.did?.uri} + + {selectedIdentity.metadata?.tenant ? ( + <> + Tenant + + {selectedIdentity.metadata.tenant} + + + ) : null} + {selectedIdentity.metadata?.connectedDid ? ( + <> + Connected DID + + {selectedIdentity.metadata.connectedDid} + + + ) : null} + + setSelectedDid(null)} + /> + + + + + ) : null} + {identities.length > 0 && ( <> item.metadata.uri} + keyExtractor={(item) => item.metadata?.uri ?? item.did?.uri} scrollEnabled={false} - renderItem={({ item }) => } + renderItem={({ item }) => ( + handleSelect(item)} /> + )} ItemSeparatorComponent={Separator} style={[styles.list, { borderColor: theme.colors.border }]} /> @@ -154,12 +270,20 @@ function Separator() { return ; } -function IdentityRow({ identity, theme }: { identity: any; theme: AppTheme }) { +function IdentityRow({ + identity, + theme, + onPress, +}: { + identity: any; + theme: AppTheme; + onPress: () => void; +}) { const didUri = identity.metadata?.uri ?? identity.did?.uri ?? 'Unknown DID'; const displayName = identity.metadata?.name ?? 'Unnamed'; return ( - + {displayName.charAt(0).toUpperCase()} @@ -187,6 +311,8 @@ const styles = StyleSheet.create({ emptyBody: { fontSize: 15, lineHeight: 22, textAlign: 'center' }, card: { borderRadius: 24, borderWidth: 1, padding: 20, gap: 12 }, cardTitle: { fontSize: 18, fontWeight: '700' }, + detailLabel: { fontSize: 12, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.5 }, + detailValue: { fontSize: 12, fontFamily: 'monospace', lineHeight: 18 }, input: { borderRadius: 16, borderWidth: 1, fontSize: 16, paddingHorizontal: 16, paddingVertical: 14 }, buttons: { flexDirection: 'row', gap: 12 }, list: { borderRadius: 16, borderWidth: 1, overflow: 'hidden' }, diff --git a/src/features/search/screens/__tests__/search-screen.test.tsx b/src/features/search/screens/__tests__/search-screen.test.tsx index 0b4a986..3f69b1e 100644 --- a/src/features/search/screens/__tests__/search-screen.test.tsx +++ b/src/features/search/screens/__tests__/search-screen.test.tsx @@ -1,13 +1,39 @@ /** * SearchScreen regression tests (VAL-UX-052). * - * The biometric-first refactor must not change the DID-resolution - * surface: when the user types a DID and presses Resolve, the screen - * still calls `agent.did.resolve(did)` and renders the resolved - * document. Biometric/PIN copy must not leak into Search. + * Search mirrors the web wallet's public-profile lookup surface: when + * the user types a DID and presses Resolve, the screen performs an + * anonymous DWN profile query and renders public profile data. + * Biometric/PIN copy must not leak into Search. */ - +const mockRecordsQuery = jest.fn(); + +jest.mock( + '@enbox/api', + () => ({ + __esModule: true, + Enbox: { + anonymous: jest.fn(() => ({ + dwn: { + records: { + query: mockRecordsQuery, + }, + }, + })), + }, + }), + { virtual: true }, +); + +jest.mock( + '@enbox/protocols', + () => ({ + __esModule: true, + ProfileDefinition: { protocol: 'https://identity.foundation/protocols/profile' }, + }), + { virtual: true }, +); jest.mock('@/lib/enbox/agent-store', () => { const { create } = require('zustand'); @@ -38,6 +64,7 @@ const agentStoreMock = require('@/lib/enbox/agent-store') as { describe('SearchScreen — VAL-UX-052 regression', () => { beforeEach(() => { + mockRecordsQuery.mockReset(); agentStoreMock.__mockResolve.mockReset(); // Ensure the agent stub is restored between tests. agentStoreMock.__setAgent({ did: { resolve: agentStoreMock.__mockResolve } }); @@ -51,15 +78,19 @@ describe('SearchScreen — VAL-UX-052 regression', () => { expect(screen.getByPlaceholderText(/did:/)).toBeTruthy(); }); - it('calls agent.did.resolve with the trimmed DID and renders the document on success', async () => { - const document = { - id: 'did:dht:abc', - service: [{ type: 'IdentityHub', serviceEndpoint: 'https://dwn.example' }], - verificationMethod: [{ id: 'did:dht:abc#0', type: 'Ed25519VerificationKey2020' }], - }; - agentStoreMock.__mockResolve.mockResolvedValue({ - didResolutionMetadata: {}, - didDocument: document, + it('queries the public profile with the trimmed DID and renders profile data on success', async () => { + mockRecordsQuery.mockResolvedValue({ + records: [ + { + data: { + json: jest.fn(async () => ({ + displayName: 'Alice', + tagline: 'Decentralized builder', + bio: 'Building with Enbox.', + })), + }, + }, + ], }); const screen = render(); @@ -74,21 +105,22 @@ describe('SearchScreen — VAL-UX-052 regression', () => { }); await waitFor(() => { - expect(agentStoreMock.__mockResolve).toHaveBeenCalledWith('did:dht:abc'); - }); - expect(screen.getByText('Resolved')).toBeTruthy(); + expect(mockRecordsQuery).toHaveBeenCalledWith({ + from: 'did:dht:abc', + filter: { + protocol: 'https://identity.foundation/protocols/profile', + protocolPath: 'profile', + }, + }); + }); + expect(screen.getByText('Public profile')).toBeTruthy(); expect(screen.getByText('did:dht:abc')).toBeTruthy(); - expect(screen.getByText('IdentityHub')).toBeTruthy(); + expect(screen.getByText(/Alice/)).toBeTruthy(); + expect(screen.getByText(/Decentralized builder/)).toBeTruthy(); }); - it('renders the inline error card when the resolver reports an error', async () => { - agentStoreMock.__mockResolve.mockResolvedValue({ - didResolutionMetadata: { - error: 'notFound', - errorMessage: 'DID not found on the DHT', - }, - didDocument: null, - }); + it('renders an unnamed profile card when no profile record exists', async () => { + mockRecordsQuery.mockResolvedValue({ records: [] }); const screen = render(); await act(async () => { @@ -99,13 +131,12 @@ describe('SearchScreen — VAL-UX-052 regression', () => { }); await waitFor(() => { - expect(screen.getByText('Resolution failed')).toBeTruthy(); + expect(screen.getByText('Unnamed identity')).toBeTruthy(); }); - expect(screen.getByText('DID not found on the DHT')).toBeTruthy(); }); - it('renders the Resolution failed card when resolve throws', async () => { - agentStoreMock.__mockResolve.mockRejectedValue(new Error('network down')); + it('renders the Resolution failed card when profile lookup throws', async () => { + mockRecordsQuery.mockRejectedValue(new Error('network down')); const screen = render(); await act(async () => { @@ -130,7 +161,7 @@ describe('SearchScreen — VAL-UX-052 regression', () => { await act(async () => { fireEvent.press(screen.getByText('Resolve')); }); - expect(agentStoreMock.__mockResolve).not.toHaveBeenCalled(); + expect(mockRecordsQuery).not.toHaveBeenCalled(); // Type a non-DID query — the CTA is disabled and pressing it does nothing. await act(async () => { @@ -139,23 +170,7 @@ describe('SearchScreen — VAL-UX-052 regression', () => { await act(async () => { fireEvent.press(screen.getByText('Resolve')); }); - expect(agentStoreMock.__mockResolve).not.toHaveBeenCalled(); - }); - - it('does not call resolve when the agent is absent (locked/uninitialized state)', async () => { - agentStoreMock.__setAgent(null); - - const screen = render(); - await act(async () => { - fireEvent.changeText( - screen.getByLabelText('Search DID'), - 'did:dht:abc', - ); - }); - await act(async () => { - fireEvent.press(screen.getByText('Resolve')); - }); - expect(agentStoreMock.__mockResolve).not.toHaveBeenCalled(); + expect(mockRecordsQuery).not.toHaveBeenCalled(); }); it('does not render any PIN-era copy (regression guard)', () => { diff --git a/src/features/search/screens/search-screen.tsx b/src/features/search/screens/search-screen.tsx index ccec47b..61f66e5 100644 --- a/src/features/search/screens/search-screen.tsx +++ b/src/features/search/screens/search-screen.tsx @@ -8,58 +8,79 @@ import { TextInput, View, } from 'react-native'; +import { Enbox } from '@enbox/api'; +import { ProfileDefinition } from '@enbox/protocols'; import { AppButton } from '@/components/ui/app-button'; import { Screen } from '@/components/ui/screen'; import { ScreenHeader } from '@/components/ui/screen-header'; -import { useAgentStore } from '@/lib/enbox/agent-store'; import { useAppTheme } from '@/theme'; interface ResolveResult { didUri: string; - document: any; + profile: { + displayName: string; + tagline?: string; + bio?: string; + } | null; error?: string; } +let anonymousEnbox: ReturnType | undefined; + +function getAnonymousEnbox() { + if (!anonymousEnbox) anonymousEnbox = Enbox.anonymous(); + return anonymousEnbox; +} + +async function fetchPublicProfile(did: string): Promise { + const { dwn } = getAnonymousEnbox(); + const { records } = await dwn.records.query({ + from: did, + filter: { + protocol: ProfileDefinition.protocol, + protocolPath: 'profile', + }, + }); + + if (records.length === 0) { + return { displayName: '' }; + } + + const data = await records[0].data.json() as Record; + return { + displayName: data.displayName ?? '', + tagline: data.tagline, + bio: data.bio, + }; +} + export function SearchScreen() { const theme = useAppTheme(); - const agent = useAgentStore((s) => s.agent); const [query, setQuery] = useState(''); const [resolving, setResolving] = useState(false); const [result, setResult] = useState(null); const handleResolve = useCallback(async () => { - if (!query.trim() || !agent || resolving) return; + if (!query.trim() || resolving) return; setResolving(true); setResult(null); try { - const resolution = await agent.did.resolve(query.trim()); - - if (resolution.didResolutionMetadata.error) { - setResult({ - didUri: query.trim(), - document: null, - error: resolution.didResolutionMetadata.errorMessage - ?? resolution.didResolutionMetadata.error, - }); - } else { - setResult({ - didUri: query.trim(), - document: resolution.didDocument, - }); - } + const did = query.trim(); + const profile = await fetchPublicProfile(did); + setResult({ didUri: did, profile }); } catch (err) { setResult({ didUri: query.trim(), - document: null, + profile: null, error: err instanceof Error ? err.message : 'Resolution failed', }); } finally { setResolving(false); } - }, [query, agent, resolving]); + }, [query, resolving]); return ( @@ -108,37 +129,29 @@ export function SearchScreen() { )} - {result?.document && ( - - Resolved + {result?.profile && ( + + Public profile {result.didUri} - {result.document.service?.length > 0 && ( - - Services - {result.document.service.map((svc: any, i: number) => ( - - {svc.type} - - {Array.isArray(svc.serviceEndpoint) ? svc.serviceEndpoint[0] : svc.serviceEndpoint} - - - ))} - - )} - - {result.document.verificationMethod?.length > 0 && ( - - - Verification methods ({result.document.verificationMethod.length}) - - {result.document.verificationMethod.map((vm: any, i: number) => ( - - {vm.id} ({vm.type}) - - ))} - - )} + + {result.profile.displayName || 'Unnamed identity'} + + {result.profile.tagline ? ( + + {result.profile.tagline} + + ) : null} + {result.profile.bio ? ( + + {result.profile.bio} + + ) : null} )} @@ -146,7 +159,7 @@ export function SearchScreen() { Enter a DID to search - Results will show the public DID document including services and verification methods. + Results will show public profile data published to the DID's DWN. )} @@ -164,6 +177,7 @@ const styles = StyleSheet.create({ card: { borderRadius: 24, borderWidth: 1, padding: 20, gap: 10 }, cardTitle: { fontSize: 18, fontWeight: '700' }, cardBody: { fontSize: 15, lineHeight: 22 }, + profileName: { fontSize: 20, fontWeight: '700' }, didUri: { fontSize: 13, fontFamily: 'monospace' }, section: { gap: 6, marginTop: 4 }, sectionTitle: { fontSize: 12, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase' }, diff --git a/src/features/settings/screens/settings-screen.tsx b/src/features/settings/screens/settings-screen.tsx index dbacd5a..99d6cdb 100644 --- a/src/features/settings/screens/settings-screen.tsx +++ b/src/features/settings/screens/settings-screen.tsx @@ -1,4 +1,14 @@ -import { Alert, Linking, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useState } from 'react'; +import { + Alert, + Linking, + Pressable, + Share, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; import { Screen } from '@/components/ui/screen'; import { ScreenHeader } from '@/components/ui/screen-header'; @@ -49,9 +59,56 @@ export function SettingsScreen({ onLock }: SettingsScreenProps) { const agent = useAgentStore((s) => s.agent); const identityCount = useAgentStore((s) => s.identities.length); const agentError = useAgentStore((s) => s.error); + const exportIdentities = useAgentStore((s) => s.exportIdentities); + const importIdentities = useAgentStore((s) => s.importIdentities); + + const [showImport, setShowImport] = useState(false); + const [importJson, setImportJson] = useState(''); + const [isExporting, setIsExporting] = useState(false); + const [isImporting, setIsImporting] = useState(false); const agentDid = agent?.agentDid?.uri; + async function handleExportBackup(): Promise { + if (isExporting) return; + setIsExporting(true); + try { + const json = await exportIdentities(); + await Share.share({ + title: 'Enbox identity backup', + message: json, + }); + } catch (err) { + Alert.alert( + 'Export failed', + err instanceof Error ? err.message : 'Could not export identities', + ); + } finally { + setIsExporting(false); + } + } + + async function handleImportBackup(): Promise { + if (!importJson.trim() || isImporting) return; + setIsImporting(true); + try { + const count = await importIdentities(importJson.trim()); + setImportJson(''); + setShowImport(false); + Alert.alert( + 'Import complete', + `Imported ${count} ${count === 1 ? 'identity' : 'identities'}.`, + ); + } catch (err) { + Alert.alert( + 'Import failed', + err instanceof Error ? err.message : 'Could not import identities', + ); + } finally { + setIsImporting(false); + } + } + async function performReset(): Promise { // Settings uses the same reset primitive as recovery restore: // native vault wipe, LevelDB wipe, in-memory teardown, and session @@ -171,8 +228,53 @@ export function SettingsScreen({ onLock }: SettingsScreenProps) { Data - {}} theme={theme} /> - {}} theme={theme} /> + { + handleExportBackup().catch(() => {}); + }} + theme={theme} + /> + setShowImport((value) => !value)} + theme={theme} + /> + {showImport ? ( + + + Paste an Enbox identity backup JSON export. Imported identities + keep their exact DID and key material. + + + { + handleImportBackup().catch(() => {}); + }} + theme={theme} + /> + + ) : null} @@ -257,6 +359,9 @@ const styles = StyleSheet.create({ infoRow: { paddingHorizontal: 16, paddingVertical: 10, gap: 2 }, infoLabel: { fontSize: 12, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.5 }, infoValue: { fontSize: 13, fontFamily: 'monospace' }, + importBox: { gap: 10, paddingHorizontal: 16, paddingVertical: 12 }, + importHelp: { fontSize: 13, lineHeight: 18 }, + importInput: { borderRadius: 14, borderWidth: 1, fontSize: 13, minHeight: 120, paddingHorizontal: 12, paddingVertical: 10, textAlignVertical: 'top' }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 14 }, rowLabel: { fontSize: 16 }, rowChevron: { fontSize: 22 }, diff --git a/src/lib/__tests__/polyfills.test.ts b/src/lib/__tests__/polyfills.test.ts index 5ffcfe7..551e7e7 100644 --- a/src/lib/__tests__/polyfills.test.ts +++ b/src/lib/__tests__/polyfills.test.ts @@ -85,6 +85,33 @@ describe('polyfills — AbortSignal.timeout', () => { expect(typeof (globalThis as any).TextEncoder).toBe('function'); }); + it('exposes CustomEvent with detail payload support for @enbox/api live queries', () => { + expect(typeof (globalThis as any).CustomEvent).toBe('function'); + + const event = new (globalThis as any).CustomEvent('change', { + detail: { ok: true }, + }); + expect(event.type).toBe('change'); + expect(event.detail).toEqual({ ok: true }); + }); + + it('installs CustomEvent when Hermes does not provide it', () => { + const originalCustomEvent = (globalThis as any).CustomEvent; + try { + (globalThis as any).CustomEvent = undefined; + jest.isolateModules(() => { + require('../polyfills'); + }); + + const event = new (globalThis as any).CustomEvent('change', { + detail: { ok: true }, + }); + expect(event.detail).toEqual({ ok: true }); + } finally { + (globalThis as any).CustomEvent = originalCustomEvent; + } + }); + it('does not wrap globalThis.crypto.subtle methods under Jest (NODE_ENV=test)', () => { // Sanity check: NODE_ENV should be 'test' when running under Jest. expect(process.env.NODE_ENV).toBe('test'); diff --git a/src/lib/enbox/__tests__/agent-store.reset-blockers.test.ts b/src/lib/enbox/__tests__/agent-store.reset-blockers.test.ts index a8e3f9b..01199da 100644 --- a/src/lib/enbox/__tests__/agent-store.reset-blockers.test.ts +++ b/src/lib/enbox/__tests__/agent-store.reset-blockers.test.ts @@ -444,16 +444,12 @@ describe('useAgentStore.reset() — no-vault fallback clears SecureStorage keys' /simulated native error/, ); - // SecureStorage flags STILL cleared (defense in depth — even - // though the native delete failed, removing the flags ensures the - // hydrate gate can route to onboarding instead of an unlock loop). + // SecureStorage flags are preserved when the native wipe fails. + // Clearing them first creates a mixed partial-reset state where the + // app forgets the wallet while the OS-gated secret may still exist. const deletedKeys = nativeSecure.deleteItem.mock.calls.map((c) => c[0]); - expect(deletedKeys).toEqual( - expect.arrayContaining([ - 'enbox:enbox.vault.initialized', - 'enbox:enbox.vault.biometric-state', - ]), - ); + expect(deletedKeys).not.toContain('enbox:enbox.vault.initialized'); + expect(deletedKeys).not.toContain('enbox:enbox.vault.biometric-state'); // The vault-reset-pending sentinel was persisted under the // canonical key. SecureStorageAdapter prefixes every key with diff --git a/src/lib/enbox/__tests__/biometric-vault.lock.test.ts b/src/lib/enbox/__tests__/biometric-vault.lock.test.ts index ecc0116..07d2743 100644 --- a/src/lib/enbox/__tests__/biometric-vault.lock.test.ts +++ b/src/lib/enbox/__tests__/biometric-vault.lock.test.ts @@ -213,14 +213,13 @@ describe('BiometricVault.lock() — primitive contract (auto-lock prerequisite)' }); // =================================================================== -// VAL-VAULT-028 — getMnemonic() re-derives the BIP-39 phrase from the -// vault's in-memory entropy so the pending-first-backup resume flow -// can re-show the 24 words WITHOUT triggering a second biometric -// prompt (the caller has already gone through `unlock()` / the new -// agent's `start()`). +// VAL-VAULT-028 — getMnemonic() re-derives the BIP-39 phrase only from +// the pending-first-backup resume path's temporary in-memory entropy so +// the 24 words can be re-shown WITHOUT triggering a second biometric +// prompt. Normal initialize/unlock flows must not retain root entropy. // =================================================================== -describe('BiometricVault.getMnemonic() — re-derive phrase from in-memory secret (VAL-VAULT-028)', () => { +describe('BiometricVault.getMnemonic() — temporary backup-secret retention (VAL-VAULT-028)', () => { // The 24-word all-`abandon` / `art` phrase is the BIP-39 phrase // that decodes to 32 zero bytes of entropy. Used here as a stable, // well-known fixture: initialize({ recoveryPhrase: FIXED_MNEMONIC }) @@ -231,20 +230,24 @@ describe('BiometricVault.getMnemonic() — re-derive phrase from in-memory secre 'abandon abandon abandon abandon abandon abandon ' + 'abandon abandon abandon abandon abandon art'; - it('round-trips the mnemonic passed to initialize() — unlocked vault returns it verbatim', async () => { + it('does not retain mnemonic entropy after initialize()', async () => { const vault = makeTestVault(); await vault.initialize({ recoveryPhrase: FIXED_MNEMONIC }); - const mnemonic = await vault.getMnemonic(); - expect(mnemonic).toBe(FIXED_MNEMONIC); + expect(vault.isLocked()).toBe(false); + await expect(vault.getMnemonic()).rejects.toMatchObject({ + code: 'VAULT_ERROR_LOCKED', + }); }); - it('does NOT trigger a native biometric prompt — entropy is already in memory', async () => { + it('does NOT trigger a second native biometric prompt after backup-retention unlock', async () => { const vault = makeTestVault(); await vault.initialize({ recoveryPhrase: FIXED_MNEMONIC }); + await vault.lock(); + await vault.unlock({ retainSecretForBackup: true }); // getMnemonic MUST NOT trigger a native biometric prompt — - // entropy is already in memory from initialize() / unlock(). + // entropy is already in memory from the explicit backup-retention unlock. // getSecret() is the native method that would prompt biometrics // on a real device, so its call count is the canonical signal. const getSecretCountBefore = native.getSecret.mock.calls.length; @@ -254,17 +257,16 @@ describe('BiometricVault.getMnemonic() — re-derive phrase from in-memory secre expect(native.getSecret.mock.calls.length).toBe(getSecretCountBefore); }); - it('returns the same mnemonic across initialize → lock → unlock → getMnemonic (round-trip)', async () => { + it('returns the same mnemonic across initialize → lock → backup-retention unlock → getMnemonic', async () => { const vault = makeTestVault(); await vault.initialize({ recoveryPhrase: FIXED_MNEMONIC }); - expect(await vault.getMnemonic()).toBe(FIXED_MNEMONIC); // Simulate the auto-lock → re-foreground sequence that underlies // the resumePendingBackup() path. await vault.lock(); expect(vault.isLocked()).toBe(true); - await vault.unlock({}); + await vault.unlock({ retainSecretForBackup: true }); expect(vault.isLocked()).toBe(false); // Same entropy → same mnemonic. This pins the deterministic @@ -273,6 +275,18 @@ describe('BiometricVault.getMnemonic() — re-derive phrase from in-memory secre expect(await vault.getMnemonic()).toBe(FIXED_MNEMONIC); }); + it('does not retain mnemonic entropy after a normal unlock', async () => { + const vault = makeTestVault(); + await vault.initialize({ recoveryPhrase: FIXED_MNEMONIC }); + await vault.lock(); + await vault.unlock({}); + + expect(vault.isLocked()).toBe(false); + await expect(vault.getMnemonic()).rejects.toMatchObject({ + code: 'VAULT_ERROR_LOCKED', + }); + }); + it('rejects with VAULT_ERROR_LOCKED when the vault is locked', async () => { const vault = makeTestVault(); await vault.initialize({ recoveryPhrase: FIXED_MNEMONIC }); diff --git a/src/lib/enbox/__tests__/biometric-vault.test.ts b/src/lib/enbox/__tests__/biometric-vault.test.ts index 6ccbdcf..698142d 100644 --- a/src/lib/enbox/__tests__/biometric-vault.test.ts +++ b/src/lib/enbox/__tests__/biometric-vault.test.ts @@ -1053,7 +1053,6 @@ describe('BiometricVault — prior-init routing and in-memory cleanup', () => { // and serving derived state. expect(vault.isLocked()).toBe(false); await expect(vault.getDid()).resolves.toBeDefined(); - await expect(vault.getMnemonic()).resolves.toBeDefined(); await expect( vault.encryptData({ plaintext: new Uint8Array([1, 2, 3]) }), ).resolves.toBeDefined(); @@ -1331,7 +1330,6 @@ describe('BiometricVault — getSecret NOT_FOUND race and derivation cleanup', ( // Pre-condition: vault is unlocked and serving a DID. expect(vault.isLocked()).toBe(false); await expect(vault.getDid()).resolves.toBeDefined(); - await expect(vault.getMnemonic()).resolves.toBeDefined(); // Now: lock and trigger an unlock attempt where DID derivation // throws AFTER getSecret succeeds. diff --git a/src/lib/enbox/agent-store.ts b/src/lib/enbox/agent-store.ts index 09fc6a7..4f0c5e6 100644 --- a/src/lib/enbox/agent-store.ts +++ b/src/lib/enbox/agent-store.ts @@ -27,7 +27,16 @@ import { BiometricVault, type BiometricState, } from './biometric-vault'; -import { createMobileIdentity } from './identity-service'; +import { + createMobileIdentity, + deleteMobileIdentity, + DEFAULT_DWN_ENDPOINTS, + ensurePostSession, + importMobileIdentity, + recoverWalletFromSync, + stopWalletSync, + updateMobileIdentityName, +} from './identity-service'; import { destroyAgentLevelDatabases } from './rn-level'; import { SecureStorageAdapter } from './storage-adapter'; import { @@ -357,6 +366,18 @@ export interface AgentStore { /** Create a new identity. */ createIdentity: (name: string) => Promise; + /** Export all identities as portable JSON. */ + exportIdentities: () => Promise; + + /** Import one or more portable identities from JSON. */ + importIdentities: (json: string) => Promise; + + /** Update an identity's local/profile display name. */ + updateIdentityName: (did: string, name: string) => Promise; + + /** Delete an identity from local wallet storage. */ + deleteIdentity: (did: string) => Promise; + /** Tear down agent (on lock or reset). */ teardown: () => void; @@ -539,7 +560,7 @@ export const useAgentStore = create((set, get) => ({ // defensively `lock()` it if `initialize({})` / `start({})` // unlocked the vault before a later step threw. The same // residency-window argument applies to first-launch as to unlock. - let vaultRef: { lock: () => Promise } | null = null; + let vaultRef: { lock?: () => Promise; unlock?: (params?: any) => Promise } | null = null; try { // Retry pending reset cleanups before creating the agent. Both // helpers are fail-CLOSED — if the retry rejects we throw before opening @@ -562,7 +583,9 @@ export const useAgentStore = create((set, get) => ({ // is widened to optional by `scripts/apply-patches.mjs`'s // `patchEnboxAgentPasswordOptional()` so the call site does NOT // need to carry a `password` property. - recoveryPhrase = await agent.initialize({}); + recoveryPhrase = await agent.initialize({ + dwnEndpoints: DEFAULT_DWN_ENDPOINTS, + }); debugLog('[agent-store] vault initialized.'); // Upstream `EnboxUserAgent.initialize()` does NOT assign // `agentDid` (only `start()` does). Without this assignment the @@ -597,6 +620,10 @@ export const useAgentStore = create((set, get) => ({ recoveryPhrase, biometricState: 'ready', }); + // Keep sync startup off the critical onboarding path; recovery phrase + // display must not wait on remote DWN availability. + // eslint-disable-next-line no-void + void ensurePostSession(agent).catch(() => {}); get().refreshIdentities().catch(() => {}); return recoveryPhrase; } catch (err) { @@ -617,7 +644,7 @@ export const useAgentStore = create((set, get) => ({ // GC. Calling `lock()` here scrubs them immediately. Best- // effort: a `lock()` rejection is logged but never re-thrown so // the caller still sees the original failure. - if (vaultRef !== null) { + if (typeof vaultRef?.lock === 'function') { // eslint-disable-next-line no-void void vaultRef.lock().catch((lockErr) => { console.warn( @@ -674,6 +701,8 @@ export const useAgentStore = create((set, get) => ({ biometricState: 'ready', }); + // eslint-disable-next-line no-void + void ensurePostSession(agent).catch(() => {}); get().refreshIdentities().catch(() => {}); } catch (err) { const code = (err as { code?: unknown })?.code; @@ -748,7 +777,21 @@ export const useAgentStore = create((set, get) => ({ // prompts biometrics once and populates the vault's in-memory // `_secretBytes` buffer. The subsequent `getMnemonic()` call // does NOT re-prompt — it reads the already-in-memory entropy. - await agent.start({}); + if (typeof vault.unlock === 'function') { + await vault.unlock({ retainSecretForBackup: true } as any); + } else { + await agent.start({}); + } + try { + const bearerDid = await vault.getDid(); + (agent as unknown as { agentDid?: { uri: string } }).agentDid = + bearerDid as unknown as { uri: string }; + } catch (err) { + console.warn( + '[agent-store] resumePendingBackup: could not assign agentDid', + err, + ); + } const recoveryPhrase = await vault.getMnemonic(); debugLog('[agent-store] resumePendingBackup: mnemonic re-derived.'); @@ -761,6 +804,9 @@ export const useAgentStore = create((set, get) => ({ recoveryPhrase, }); + // eslint-disable-next-line no-void + void ensurePostSession(agent).catch(() => {}); + get() .refreshIdentities() .catch(() => {}); @@ -784,7 +830,7 @@ export const useAgentStore = create((set, get) => ({ // the success-path `set(...)` was pre-empted), the unlocked // buffers (and the freshly re-derived mnemonic) live on the // vault instance until GC. Best-effort lock() scrubs them. - if (vaultRef !== null) { + if (typeof vaultRef?.lock === 'function') { // eslint-disable-next-line no-void void vaultRef.lock().catch((lockErr) => { console.warn( @@ -836,7 +882,7 @@ export const useAgentStore = create((set, get) => ({ // synchronously so a heap snapshot taken between the throw and // the next `restoreFromMnemonic()` retry cannot leak the // restored entropy. - let vaultRef: { lock: () => Promise } | null = null; + let vaultRef: { lock?: () => Promise } | null = null; try { // Retry any pending cleanup before creating the agent. Restore is // the most important place to enforce both: a user typing a @@ -850,14 +896,7 @@ export const useAgentStore = create((set, get) => ({ // `initialize({ recoveryPhrase })` path won't fast-fail with // `VAULT_ERROR_ALREADY_INITIALIZED`. Best-effort — a missing // alias resolves as success on both iOS and Android. - try { - await NativeBiometricVault.deleteSecret(WALLET_ROOT_KEY_ALIAS); - } catch (err) { - console.warn( - '[agent-store] restoreFromMnemonic: deleteSecret failed (ignored):', - err, - ); - } + await NativeBiometricVault.deleteSecret(WALLET_ROOT_KEY_ALIAS); // Create a fresh agent + vault. We do NOT reuse any existing // instance — the old state is tied to the now-invalid secret @@ -875,7 +914,10 @@ export const useAgentStore = create((set, get) => ({ // rejection is mapped to a canonical VAULT_ERROR_* and // surfaced via the screen. `AgentInitializeParams.password` is // widened to optional by the postinstall patch, so we omit it. - await agent.initialize({ recoveryPhrase: trimmed }); + await agent.initialize({ + recoveryPhrase: trimmed, + dwnEndpoints: DEFAULT_DWN_ENDPOINTS, + }); // Upstream `EnboxUserAgent.initialize()` does NOT assign // `agentDid` — only `start()` does (`this.agentDid = await @@ -900,6 +942,8 @@ export const useAgentStore = create((set, get) => ({ ); } + await recoverWalletFromSync(agent, DEFAULT_DWN_ENDPOINTS); + set({ agent, authManager, @@ -937,7 +981,7 @@ export const useAgentStore = create((set, get) => ({ // the next retry / app close is closed. A `lock()` rejection // is logged but never re-throws so the original restore error // remains the one the caller observes. - if (vaultRef !== null) { + if (typeof vaultRef?.lock === 'function') { // eslint-disable-next-line no-void void vaultRef.lock().catch((lockErr) => { console.warn( @@ -1019,6 +1063,52 @@ export const useAgentStore = create((set, get) => ({ return identity; }, + exportIdentities: async () => { + const { agent } = get(); + if (!agent) throw new Error('Agent not initialized'); + const identities = await agent.identity.list(); + const exported = []; + for (const identity of identities) { + exported.push(await agent.identity.export({ didUri: identity.did.uri })); + } + return JSON.stringify(exported, null, 2); + }, + + importIdentities: async (json) => { + const { agent } = get(); + if (!agent) throw new Error('Agent not initialized'); + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new Error('Backup JSON is not valid'); + } + const items = Array.isArray(parsed) ? parsed : [parsed]; + if (items.length === 0) throw new Error('Backup JSON contains no identities'); + + let imported = 0; + for (const item of items) { + await importMobileIdentity(agent, item); + imported += 1; + } + await get().refreshIdentities(); + return imported; + }, + + updateIdentityName: async (did, name) => { + const { agent } = get(); + if (!agent) throw new Error('Agent not initialized'); + await updateMobileIdentityName(agent, did, name); + await get().refreshIdentities(); + }, + + deleteIdentity: async (did) => { + const { agent } = get(); + if (!agent) throw new Error('Agent not initialized'); + await deleteMobileIdentity(agent, did); + await get().refreshIdentities(); + }, + teardown: () => { // Cancel the refreshIdentities() agentDid-race poller (if any) so // background / lock / reset paths never leak an interval. The stop @@ -1039,8 +1129,12 @@ export const useAgentStore = create((set, get) => ({ // is a no-op, and any unexpected throw is logged and swallowed so // teardown still completes (auto-lock on background MUST NOT partially // fail and strand the store in a half-torn-down state). - const { vault } = get(); - if (vault) { + const { agent, vault } = get(); + if (agent) { + // eslint-disable-next-line no-void + void stopWalletSync(agent as any).catch(() => {}); + } + if (vault && typeof vault.lock === 'function') { try { // `BiometricVault.lock()` returns a Promise but only because the // `IdentityVault` interface requires it — the implementation itself @@ -1076,7 +1170,7 @@ export const useAgentStore = create((set, get) => ({ }, reset: async () => { - const { vault } = get(); + const { agent, vault } = get(); // Persist retry sentinels before destructive work begins. The writes // stay sequential because SecureStorageAdapter tracks keys with a @@ -1162,24 +1256,26 @@ export const useAgentStore = create((set, get) => ({ err, ); } - const fallbackStorage = new SecureStorageAdapter(); - try { - await fallbackStorage.remove(INITIALIZED_STORAGE_KEY); - } catch (err) { - if (vaultResetError === null) vaultResetError = err; - console.warn( - '[agent-store] reset: no-vault fallback clear initialized failed:', - err, - ); - } - try { - await fallbackStorage.remove(BIOMETRIC_STATE_STORAGE_KEY); - } catch (err) { - if (vaultResetError === null) vaultResetError = err; - console.warn( - '[agent-store] reset: no-vault fallback clear biometric-state failed:', - err, - ); + if (vaultResetError === null) { + const fallbackStorage = new SecureStorageAdapter(); + try { + await fallbackStorage.remove(INITIALIZED_STORAGE_KEY); + } catch (err) { + if (vaultResetError === null) vaultResetError = err; + console.warn( + '[agent-store] reset: no-vault fallback clear initialized failed:', + err, + ); + } + try { + await fallbackStorage.remove(BIOMETRIC_STATE_STORAGE_KEY); + } catch (err) { + if (vaultResetError === null) vaultResetError = err; + console.warn( + '[agent-store] reset: no-vault fallback clear biometric-state failed:', + err, + ); + } } } if (vaultResetError === null) { @@ -1194,6 +1290,10 @@ export const useAgentStore = create((set, get) => ({ } } + if (vaultResetError === null && agent) { + await stopWalletSync(agent as any); + } + // Wipe ENBOX_AGENT LevelDB only after the vault wipe succeeds. let levelDbError: unknown = null; if (vaultResetError === null) { diff --git a/src/lib/enbox/biometric-vault.ts b/src/lib/enbox/biometric-vault.ts index 3894c23..038b1ff 100644 --- a/src/lib/enbox/biometric-vault.ts +++ b/src/lib/enbox/biometric-vault.ts @@ -12,8 +12,10 @@ * - Gate provisioning (`initialize()`) on `hasSecret()` so we never * overwrite a live vault. * - Prompt biometrics to retrieve the secret during `unlock()`. - * - Keep the derived seed / DID / CEK in memory only; `lock()` clears - * them, leaving the native secret intact. + * - Keep only the active DID / CEK in memory after a normal unlock; + * `lock()` clears them, leaving the native secret intact. The root + * entropy is retained only during the first-backup resume flow where + * the mnemonic must be re-derived for display. * - Translate native error codes into stable `VAULT_ERROR_*` codes so * the UI can route the user (invalidated -> recovery, cancel -> retry, * etc.). @@ -707,7 +709,7 @@ export class BiometricVault private readonly _unlockPrompt: typeof DEFAULT_UNLOCK_PROMPT; private readonly _provisionPrompt: typeof DEFAULT_PROVISION_PROMPT; - // In-memory secret bytes (undefined when locked). + // In-memory secret bytes (only populated during pending-backup resume). // Fields whose names combine sensitive tokens ("secret", "key") with a // raw-byte array type are routed through the neutral `BinaryBuffer` // alias from `./binary-types` to avoid Droid-Shield content-scanner @@ -742,7 +744,7 @@ export class BiometricVault } async isInitialized(): Promise { - if (this._secretBytes && this._bearerDid) { + if (this._bearerDid && this._contentEncryptionKey) { return true; } try { @@ -763,7 +765,7 @@ export class BiometricVault } isLocked(): boolean { - return !this._secretBytes || !this._bearerDid || !this._contentEncryptionKey; + return !this._bearerDid || !this._contentEncryptionKey; } // --------------------------------------------------------------------- @@ -906,13 +908,14 @@ export class BiometricVault // outer `finally` can scrub its buffers regardless of which // branch (success / derivation throw / rollback) we exit on. let rootHdKeyLocal: { privateKey?: Uint8Array; chainCode?: Uint8Array } | undefined; + let rootSeedLocal: Uint8Array | undefined; try { const mnemonic = entropyToMnemonic(entropy, wordlist); if (!validateMnemonic(mnemonic, wordlist)) { throw new VaultError('VAULT_ERROR', 'Derived mnemonic failed validation'); } - const rootSeed = await mnemonicToSeed(mnemonic); - rootHdKeyLocal = HDKey.fromMasterSeed(rootSeed); + rootSeedLocal = await mnemonicToSeed(mnemonic); + rootHdKeyLocal = HDKey.fromMasterSeed(rootSeedLocal); const bearerDid = await this._didFactory({ rootHdKey: rootHdKeyLocal, dwnEndpoints: params.dwnEndpoints, @@ -924,8 +927,8 @@ export class BiometricVault // was unread (every consumer used the local) and retained // a 32-byte private key + 32-byte chain code that // `_clearInMemoryState` only dropped — never zeroed. - this._secretBytes = entropy; - this._rootSeed = rootSeed; + this._secretBytes = undefined; + this._rootSeed = undefined; this._bearerDid = bearerDid; this._contentEncryptionKey = cek; this._biometricState = 'ready'; @@ -983,6 +986,8 @@ export class BiometricVault // here closes the residency window before GC is allowed // to reclaim the underlying buffers. zeroHdKeyBuffers(rootHdKeyLocal); + zeroBytes(entropy); + zeroBytes(rootSeedLocal); } } @@ -990,7 +995,9 @@ export class BiometricVault // Unlock // --------------------------------------------------------------------- - async unlock(_params: { password?: string } = {}): Promise { + async unlock( + _params: { password?: string; retainSecretForBackup?: boolean } = {}, + ): Promise { if (this._pendingUnlock) { return this._pendingUnlock; } @@ -1011,7 +1018,7 @@ export class BiometricVault // need to know the slot is free so we can continue. } } - return this._doUnlock(); + return this._doUnlock(Boolean(_params.retainSecretForBackup)); })(); this._pendingUnlock = task; try { @@ -1021,7 +1028,7 @@ export class BiometricVault } } - private async _doUnlock(): Promise { + private async _doUnlock(retainSecretForBackup = false): Promise { // Probe native presence. A rejected probe is indeterminate and must // not be collapsed into "no vault". let hasExisting: boolean; @@ -1148,6 +1155,8 @@ export class BiometricVault let secretBytes: Uint8Array | undefined; let rootSeed: Uint8Array | undefined; let cek: Uint8Array | undefined; + let publishedSecret = false; + let publishedCek = false; // Hold the local rootHdKey so the outer `finally` // can scrub its `chainCode` + `privateKey` buffers regardless // of which branch we exit on. See `_doInitialize` for the full @@ -1167,15 +1176,21 @@ export class BiometricVault const bearerDid = await this._didFactory({ rootHdKey: rootHdKeyLocal }); cek = await deriveContentEncryptionKey(rootHdKeyLocal); - // Atomic publish: assign all four fields in one synchronous + // Atomic publish: assign all unlock-visible fields in one synchronous // block AFTER every derivation step has succeeded. No partial // assignment is ever observable. // Do NOT store rootHdKey on `this`. See the // matching note in `_doInitialize`. - this._secretBytes = secretBytes; - this._rootSeed = rootSeed; + if (retainSecretForBackup) { + this._secretBytes = secretBytes; + publishedSecret = true; + } else { + this._secretBytes = undefined; + } + this._rootSeed = undefined; this._bearerDid = bearerDid; this._contentEncryptionKey = cek; + publishedCek = true; this._biometricState = 'ready'; } catch (err) { // Zero local allocations before clearing any stale prior-unlock fields. @@ -1190,6 +1205,13 @@ export class BiometricVault // derived material; the rootHdKey itself is no longer // referenced by any store-facing field. zeroHdKeyBuffers(rootHdKeyLocal); + if (!publishedSecret) { + zeroBytes(secretBytes); + } + zeroBytes(rootSeed); + if (!publishedCek) { + zeroBytes(cek); + } } } @@ -1218,8 +1240,10 @@ export class BiometricVault // missing-alias — but a rejection from a present-alias delete // indicates a real Keystore / Keychain failure that we MUST // surface. + let nativeDeleteSucceeded = false; try { await this._native.deleteSecret(WALLET_ROOT_KEY_ALIAS); + nativeDeleteSucceeded = true; } catch (err) { firstError = err; } @@ -1230,7 +1254,7 @@ export class BiometricVault // state. Both writes are independent — we attempt the second // even if the first throws so a single corrupt key does not // block the other. - if (this._secureStorage) { + if (this._secureStorage && nativeDeleteSucceeded) { try { await this._secureStorage.remove(INITIALIZED_STORAGE_KEY); } catch (err) { @@ -1268,8 +1292,8 @@ export class BiometricVault /** * Re-derive the 24-word BIP-39 mnemonic from the vault's root entropy. * - * Only callable while the vault is unlocked — callers MUST have gone - * through `initialize()` / `unlock()` first so `_secretBytes` is + * Only callable after the pending-backup resume path has unlocked with + * `retainSecretForBackup=true`, so `_secretBytes` is temporarily * populated. The returned string is the same mnemonic that * `initialize()` produced for the caller-provided / CSPRNG entropy, so * this method can be used to re-show the phrase during the pending- diff --git a/src/lib/enbox/identity-service.ts b/src/lib/enbox/identity-service.ts index 2cc5a63..c78f043 100644 --- a/src/lib/enbox/identity-service.ts +++ b/src/lib/enbox/identity-service.ts @@ -16,6 +16,9 @@ export const WEB_WALLET_URL = 'https://enbox-wallet.pages.dev'; const REGISTRATION_TOKENS_KEY = 'enbox.registration.tokens'; const ENABLE_IDENTITY_PROVISIONING_LOGS = process.env.ENBOX_DEBUG_AGENT === '1'; +const SOCIAL_GRAPH_PROTOCOL_URI = 'https://identity.foundation/protocols/social-graph'; +const PROFILE_PROTOCOL_URI = 'https://identity.foundation/protocols/profile'; +const CONNECT_PROTOCOL_URI = 'https://identity.foundation/protocols/connect'; type RegistrationTokenData = { registrationToken: string; @@ -84,16 +87,29 @@ type ProviderAuthInfo = NonNullable; type IdentityAgent = { agentDid?: DidUri; + did?: { + delete?: EnboxUserAgent['did']['delete']; + }; identity: { create(params: MobileIdentityCreateOptions): Promise; list(): Promise; + get?: EnboxUserAgent['identity']['get']; + import?: EnboxUserAgent['identity']['import']; + export?: EnboxUserAgent['identity']['export']; + delete?: EnboxUserAgent['identity']['delete']; + setMetadataName?: EnboxUserAgent['identity']['setMetadataName']; }; processDwnRequest?: EnboxUserAgent['processDwnRequest']; rpc?: { getServerInfo?: (url: string) => Promise; sendDwnRequest?: EnboxUserAgent['rpc']['sendDwnRequest']; }; - sync?: Partial>; + sync?: Partial< + Pick< + EnboxUserAgent['sync'], + 'registerIdentity' | 'unregisterIdentity' | 'startSync' | 'stopSync' | 'sync' + > + >; }; function debugWarn(message: string, error?: unknown): void { @@ -106,7 +122,18 @@ function loadEnboxApi(): typeof import('@enbox/api') { } function loadProtocols(): typeof import('@enbox/protocols') { - return require('@enbox/protocols') as typeof import('@enbox/protocols'); + try { + return require('@enbox/protocols') as typeof import('@enbox/protocols'); + } catch (err) { + debugWarn('Falling back to inline protocol metadata:', err); + return { + SocialGraphDefinition: { protocol: SOCIAL_GRAPH_PROTOCOL_URI }, + ProfileDefinition: { protocol: PROFILE_PROTOCOL_URI }, + ConnectDefinition: { protocol: CONNECT_PROTOCOL_URI }, + ProfileProtocol: { protocol: PROFILE_PROTOCOL_URI }, + ConnectProtocol: { protocol: CONNECT_PROTOCOL_URI }, + } as unknown as typeof import('@enbox/protocols'); + } } function loadDwnRegistrar(): typeof import('@enbox/dwn-clients').DwnRegistrar { @@ -151,6 +178,172 @@ function loadRequiredProtocols(): readonly DwnProtocolDefinition[] { ] as const; } +function requiredProtocolUris(): string[] { + return loadRequiredProtocols().map((definition) => definition.protocol); +} + +async function registerSyncIdentity( + agent: IdentityAgent, + did: string, + protocols?: string[], +): Promise { + if (!agent.sync?.registerIdentity) return false; + try { + await agent.sync.registerIdentity({ + did, + ...(protocols ? { options: { protocols } } : {}), + }); + return true; + } catch { + // Already registered or unavailable offline. + return false; + } +} + +export async function startWalletSync(agent: IdentityAgent): Promise { + if (!agent.sync?.startSync) return; + try { + await agent.sync.startSync({ mode: 'live', interval: '5m' }); + } catch (err) { + debugWarn('Failed to start wallet sync:', err); + } +} + +export async function stopWalletSync(agent: IdentityAgent): Promise { + if (!agent.sync?.stopSync) return; + try { + await agent.sync.stopSync(2000); + } catch (err) { + debugWarn('Failed to stop wallet sync:', err); + } +} + +export async function ensurePostSession( + agent: IdentityAgent, + endpoints: string[] = DEFAULT_DWN_ENDPOINTS, +): Promise { + try { + await ensureRegistration(agent, normalizeEndpoints(endpoints)); + } catch (err) { + debugWarn('Post-session DWN registration failed:', err); + } + + if (agent.agentDid?.uri) { + await registerSyncIdentity(agent, agent.agentDid.uri); + } + + if (agent.sync?.registerIdentity) { + const protocolUris = requiredProtocolUris(); + try { + const identities = await agent.identity.list(); + for (const identity of identities) { + const did = identity.metadata?.connectedDid ?? identity.did?.uri ?? identity.metadata?.uri; + if (did) { + await registerSyncIdentity(agent, did, protocolUris); + } + } + } catch (err) { + debugWarn('Post-session identity sync registration failed:', err); + } + } + + await startWalletSync(agent); +} + +export async function recoverWalletFromSync( + agent: IdentityAgent, + endpoints: string[] = DEFAULT_DWN_ENDPOINTS, +): Promise { + const canRegisterSync = typeof agent.sync?.registerIdentity === 'function'; + const canPullSync = typeof agent.sync?.sync === 'function'; + const canProvisionDwn = supportsDwnProvisioning(agent); + + if (!canRegisterSync && !canPullSync && !canProvisionDwn) { + await startWalletSync(agent); + return; + } + + const dwnEndpoints = normalizeEndpoints(endpoints); + const protocolDefinitions = loadRequiredProtocols(); + const protocolUris = protocolDefinitions.map((definition) => definition.protocol); + + try { + await ensureRegistration(agent, dwnEndpoints); + } catch (err) { + debugWarn('Restore DWN registration failed before sync pull:', err); + } + + await stopWalletSync(agent); + + if (agent.agentDid?.uri) { + await registerSyncIdentity(agent, agent.agentDid.uri); + } + + if (agent.sync?.sync) { + try { + await agent.sync.sync('pull'); + } catch (err) { + debugWarn('Restore identity-metadata pull failed:', err); + } + } + + let identities: IdentityRecord[] = []; + try { + identities = await agent.identity.list(); + } catch (err) { + debugWarn('Restore identity list after metadata pull failed:', err); + } + + for (const identity of identities) { + const did = identity.metadata?.connectedDid ?? identity.did?.uri ?? identity.metadata?.uri; + if (did) { + await registerSyncIdentity(agent, did, protocolUris); + } + } + + try { + await ensureRegistration(agent, dwnEndpoints); + } catch (err) { + debugWarn('Restore DWN registration failed after identity pull:', err); + } + + if (agent.sync?.sync) { + try { + await agent.sync.sync('pull'); + } catch (err) { + debugWarn('Restore profile/data pull failed:', err); + } + } + + try { + identities = await agent.identity.list(); + } catch (err) { + debugWarn('Restore identity list after data pull failed:', err); + } + + if (canProvisionDwn) { + for (const identity of identities) { + const did = identity.did?.uri ?? identity.metadata?.uri; + if (!did) continue; + try { + await installIdentityProtocols(agent, did, protocolDefinitions); + } catch (err) { + debugWarn(`Restore protocol install for ${did} failed:`, err); + } + } + } + + if (agent.sync?.sync) { + try { + await agent.sync.sync('push'); + } catch (err) { + debugWarn('Restore protocol push failed:', err); + } + } + + await startWalletSync(agent); +} + async function readRegistrationTokens( storage = new SecureStorageAdapter(), ): Promise> { @@ -453,21 +646,14 @@ export async function createMobileIdentity( shouldProvisionDwn || agent.sync?.registerIdentity ? loadRequiredProtocols() : []; + const protocolUris = requiredProtocolUris(); - if (agent.sync?.registerIdentity) { - try { - await agent.sync.registerIdentity({ - did, - options: { - protocols: protocolDefinitions.map((definition) => definition.protocol), - }, - }); - } catch { - // Already registered or unavailable offline. Local provisioning below - // remains the source of truth for the created identity. - } + if (agent.agentDid?.uri) { + await registerSyncIdentity(agent, agent.agentDid.uri); } + await registerSyncIdentity(agent, did, protocolUris); + if (shouldProvisionDwn) { await ensureRegistration(agent, dwnEndpoints); await installIdentityProtocols(agent, did, protocolDefinitions); @@ -475,5 +661,98 @@ export async function createMobileIdentity( await createWalletRecord(agent, did); } + await startWalletSync(agent); + + return identity as BearerIdentity; +} + +export async function importMobileIdentity( + agent: IdentityAgent, + portableIdentity: any, +): Promise { + if (!agent.identity.import) { + throw new Error('Identity import is not available'); + } + + const did = + portableIdentity?.portableDid?.uri ?? + portableIdentity?.did?.uri ?? + portableIdentity?.metadata?.uri; + if (typeof did !== 'string' || did.length === 0) { + throw new Error('Imported identity is missing a DID URI'); + } + + if (agent.identity.get) { + const existing = await agent.identity.get({ didUri: did }); + if (existing) throw new Error(`Identity already exists: ${did}`); + } + + const identity = await agent.identity.import({ portableIdentity }); + const importedDid = getIdentityDid(identity); + const protocolDefinitions = loadRequiredProtocols(); + const protocolUris = protocolDefinitions.map((definition) => definition.protocol); + + if (agent.agentDid?.uri) { + await registerSyncIdentity(agent, agent.agentDid.uri); + } + await registerSyncIdentity(agent, importedDid, protocolUris); + if (supportsDwnProvisioning(agent)) { + await ensureRegistration(agent, DEFAULT_DWN_ENDPOINTS); + await installIdentityProtocols(agent, importedDid, protocolDefinitions); + await createWalletRecord(agent, importedDid); + } + await startWalletSync(agent); + return identity as BearerIdentity; } + +export async function updateMobileIdentityName( + agent: IdentityAgent, + did: string, + name: string, +): Promise { + const nextName = name.trim(); + if (!nextName) throw new Error('Identity name is required'); + if (!agent.identity.setMetadataName) { + throw new Error('Identity metadata updates are not available'); + } + + await agent.identity.setMetadataName({ didUri: did, name: nextName }); + + try { + await writeInitialProfile(agent, did, nextName); + } catch (err) { + debugWarn(`Profile update for ${did} failed:`, err); + } + + await startWalletSync(agent); +} + +export async function deleteMobileIdentity( + agent: IdentityAgent, + did: string, +): Promise { + if (!agent.identity.delete) { + throw new Error('Identity delete is not available'); + } + + if (agent.sync?.unregisterIdentity) { + try { + await agent.sync.unregisterIdentity(did); + } catch (err) { + debugWarn(`Sync unregister for ${did} failed:`, err); + } + } + + await agent.identity.delete({ didUri: did }); + + if (agent.did?.delete && agent.agentDid?.uri) { + try { + await agent.did.delete({ didUri: did, tenant: agent.agentDid.uri }); + } catch (err) { + debugWarn(`DID delete for ${did} failed:`, err); + } + } + + await startWalletSync(agent); +} diff --git a/src/lib/polyfills.ts b/src/lib/polyfills.ts index 0cab945..3a6dd83 100644 --- a/src/lib/polyfills.ts +++ b/src/lib/polyfills.ts @@ -41,6 +41,74 @@ if ( }; } +// EventTarget / CustomEvent — Hermes does not expose the full browser event +// surface, while @enbox/api defines LiveQuery classes at module load that +// extend EventTarget and CustomEvent. Keep this minimal but standards-shaped +// enough for add/remove/dispatch and `.detail` consumers. +if (typeof globalThis.Event === 'undefined') { + (globalThis as any).Event = class Event { + public readonly type: string; + public readonly bubbles: boolean; + public readonly cancelable: boolean; + public defaultPrevented = false; + + constructor(type: string, init: EventInit = {}) { + this.type = type; + this.bubbles = Boolean(init.bubbles); + this.cancelable = Boolean(init.cancelable); + } + + preventDefault(): void { + if (this.cancelable) this.defaultPrevented = true; + } + }; +} + +if (typeof globalThis.EventTarget === 'undefined') { + (globalThis as any).EventTarget = class EventTarget { + private readonly listeners = new Map>(); + + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return; + let listenersForType = this.listeners.get(type); + if (!listenersForType) { + listenersForType = new Set(); + this.listeners.set(type, listenersForType); + } + listenersForType.add(listener); + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return; + this.listeners.get(type)?.delete(listener); + } + + dispatchEvent(event: Event): boolean { + const listenersForType = this.listeners.get(event.type); + if (!listenersForType) return !event.defaultPrevented; + for (const listener of [...listenersForType]) { + if (typeof listener === 'function') { + listener.call(this, event); + } else { + listener.handleEvent(event); + } + } + return !event.defaultPrevented; + } + }; +} + +if (typeof globalThis.CustomEvent === 'undefined') { + (globalThis as any).CustomEvent = class CustomEvent extends Event { + public readonly detail: T | null; + + constructor(type: string, init: CustomEventInit = {}) { + super(type, init); + this.detail = init.detail ?? null; + } + }; +} + // crypto.subtle + crypto.getRandomValues import { install as installCrypto } from 'react-native-quick-crypto'; installCrypto();