diff --git a/packages/webview-app/src/providers/SelfClientProvider.tsx b/packages/webview-app/src/providers/SelfClientProvider.tsx index 28eb977979..ed1be700f9 100644 --- a/packages/webview-app/src/providers/SelfClientProvider.tsx +++ b/packages/webview-app/src/providers/SelfClientProvider.tsx @@ -20,7 +20,6 @@ import { bridgeLifecycleAdapter, bridgeStorageAdapter, consoleAnalyticsAdapter, - createKeychainDocumentsAdapter, createSdkAdapters, } from '@selfxyz/webview-bridge/adapters'; @@ -74,15 +73,13 @@ export const SelfClientProvider: React.FC<{ children: React.ReactNode }> = ({ ch listeners, }); - const documents = createKeychainDocumentsAdapter(bridge); - return { client, lifecycle: bridgeLifecycleAdapter(bridge), haptic: bridgeHapticAdapter(bridge), biometrics: bridgeBiometricsAdapter(bridge), analytics: withCohortTags(consoleAnalyticsAdapter()), - documents, + documents: sdkAdapters.documents, }; }, [bridge, stableNavigate, stableGoBack]); diff --git a/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx b/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx index 8d9468d42d..55cc791ce6 100644 --- a/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx +++ b/packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx @@ -144,7 +144,8 @@ export const KeychainDebugScreen: React.FC = () => { }, [documents, addLog]); const handleClearAll = useCallback(async () => { - const ALL_KEYS = ['self_document_catalog', 'self_mnemonic', 'self_private_key']; + // Documents no longer live in secureStorage — clear them via "Delete catalog". + const ALL_KEYS = ['self_mnemonic', 'self_private_key']; try { for (const k of ALL_KEYS) { const existing = await storage.get(k); @@ -162,23 +163,17 @@ export const KeychainDebugScreen: React.FC = () => { }, [storage, addLog]); const handleDumpAll = useCallback(async () => { - const ALL_KEYS = ['self_document_catalog', 'self_mnemonic', 'self_private_key']; + const ALL_KEYS = ['self_mnemonic', 'self_private_key']; try { + const catalog = await documents.loadDocumentCatalog(); + addLog(`documents catalog -> ${catalog.documents.length} doc(s), selected: ${catalog.selectedDocumentId ?? 'none'}`); + for (const doc of catalog.documents) { + addLog(` [${doc.id}] type=${doc.documentType} mock=${doc.mock} registered=${doc.isRegistered}`); + } for (const k of ALL_KEYS) { const val = await storage.get(k); if (val === null) { addLog(`${k} -> null`); - } else if (k === 'self_document_catalog') { - try { - const catalog = JSON.parse(val); - const docs = catalog.documents ?? []; - addLog(`${k} -> ${docs.length} doc(s), selected: ${catalog.selectedDocumentId ?? 'none'}`); - for (const doc of docs) { - addLog(` [${doc.id}] type=${doc.documentType} mock=${doc.mock} registered=${doc.isRegistered}`); - } - } catch { - addLog(`${k} -> ${val.length} chars (parse error)`, true); - } } else if (k === 'self_mnemonic') { addLog(`${k} -> ${val.split(' ').length} words`); } else { @@ -188,7 +183,7 @@ export const KeychainDebugScreen: React.FC = () => { } catch (e) { addLog(`DUMP FAILED: ${e}`, true); } - }, [storage, addLog]); + }, [storage, documents, addLog]); const handleDeleteDoc = useCallback(async () => { try { diff --git a/packages/webview-app/src/screens/onboarding/eu-id/NfcInstructionsRoute.tsx b/packages/webview-app/src/screens/onboarding/eu-id/NfcInstructionsRoute.tsx index 39b260cede..810ebdeb6d 100644 --- a/packages/webview-app/src/screens/onboarding/eu-id/NfcInstructionsRoute.tsx +++ b/packages/webview-app/src/screens/onboarding/eu-id/NfcInstructionsRoute.tsx @@ -7,7 +7,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { EuIdNfcInstructionsScreen } from '@selfxyz/euclid'; -import { normalizeNfcPassport } from '@selfxyz/mobile-sdk-alpha/browser'; +import { normalizeNfcPassport, storePassportData } from '@selfxyz/mobile-sdk-alpha/browser'; import { bridgeNFCScannerAdapter } from '@selfxyz/webview-bridge/adapters'; import { useBridge } from '../../../providers/BridgeProvider'; @@ -30,7 +30,7 @@ export const EuIdNfcInstructionsRoute: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const bridge = useBridge(); - const { analytics, haptic, documents } = useSelfClient(); + const { analytics, haptic, client } = useSelfClient(); const state = (location.state as RouteState | null) ?? {}; const nfcScanner = useMemo(() => bridgeNFCScannerAdapter(bridge), [bridge]); const startedRef = useRef(false); @@ -78,27 +78,11 @@ export const EuIdNfcInstructionsRoute: React.FC = () => { try { const result = await nfcScanner.scan(scanParams); const documentData = normalizeNfcPassport(result); - const documentNumber = state.mrz?.passportNumber ?? state.canValue ?? 'unknown'; - const docId = `id_card-${documentNumber}`; - await documents.saveDocument(docId, documentData as never); - const catalog = await documents.loadDocumentCatalog(); - const entry = { - id: docId, - documentType: state.documentType ?? 'id_card', - documentCategory: 'id_card' as const, - data: '', - mock: false, - isRegistered: false, - }; - const existingIndex = catalog.documents.findIndex((d: { id: string }) => d.id === docId); - if (existingIndex >= 0) { - catalog.documents[existingIndex] = entry; - } else { - catalog.documents.push(entry); - } - catalog.selectedDocumentId = docId; - await documents.saveDocumentCatalog(catalog); + // Content-hash id + dedup + catalog upsert via the SDK store. Never + // key documents by document number / CAN: keychain service names leak + // into native logs (RNKeychainManager logs missing services verbatim). + await storePassportData(client, documentData); analytics.trackEvent('eu_id_nfc_scan_succeeded'); haptic.trigger('success'); @@ -126,7 +110,7 @@ export const EuIdNfcInstructionsRoute: React.FC = () => { }, [ analytics, busy, - documents, + client, haptic, navigate, nfcScanner, diff --git a/packages/webview-app/src/screens/onboarding/passport/NfcRoute.tsx b/packages/webview-app/src/screens/onboarding/passport/NfcRoute.tsx index fb02883014..cd43c8d916 100644 --- a/packages/webview-app/src/screens/onboarding/passport/NfcRoute.tsx +++ b/packages/webview-app/src/screens/onboarding/passport/NfcRoute.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { PassportNfcInstructionsScreen } from '@selfxyz/euclid'; -import { normalizeNfcPassport } from '@selfxyz/mobile-sdk-alpha/browser'; +import { normalizeNfcPassport, storePassportData } from '@selfxyz/mobile-sdk-alpha/browser'; import { bridgeNFCScannerAdapter, onNfcProgress } from '@selfxyz/webview-bridge/adapters'; import { useBridge } from '../../../providers/BridgeProvider'; @@ -58,7 +58,7 @@ export const PassportNfcRoute: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const bridge = useBridge(); - const { analytics, haptic, documents } = useSelfClient(); + const { analytics, haptic, client } = useSelfClient(); const state = (location.state as RouteState | null) ?? {}; const mrz = state.mrz; const nfcScanner = useMemo(() => bridgeNFCScannerAdapter(bridge), [bridge]); @@ -101,25 +101,10 @@ export const PassportNfcRoute: React.FC = () => { const passportData = normalizeNfcPassport(result); - const docId = `passport-${mrz.passportNumber}`; - await documents.saveDocument(docId, passportData as never); - const catalog = await documents.loadDocumentCatalog(); - const entry = { - id: docId, - documentType: state.documentType ?? 'passport', - documentCategory: 'passport' as const, - data: '', - mock: false, - isRegistered: false, - }; - const existingIndex = catalog.documents.findIndex((d: { id: string }) => d.id === docId); - if (existingIndex >= 0) { - catalog.documents[existingIndex] = entry; - } else { - catalog.documents.push(entry); - } - catalog.selectedDocumentId = docId; - await documents.saveDocumentCatalog(catalog); + // Content-hash id + dedup + catalog upsert via the SDK store. Never + // key documents by passport number: keychain service names leak into + // native logs (RNKeychainManager logs missing services verbatim). + await storePassportData(client, passportData); analytics.trackEvent('passport_nfc_scan_succeeded'); haptic.trigger('success'); @@ -158,7 +143,7 @@ export const PassportNfcRoute: React.FC = () => { } }, 0); }; - }, [analytics, bridge, documents, haptic, mrz, navigate, nfcScanner, state.countryCode, state.documentType]); + }, [analytics, bridge, client, haptic, mrz, navigate, nfcScanner, state.countryCode, state.documentType]); const handleBack = useCallback(() => { haptic.trigger('selection'); diff --git a/packages/webview-app/tests/flows/onboarding.passport.test.tsx b/packages/webview-app/tests/flows/onboarding.passport.test.tsx index 7e900124e9..b39e49461d 100644 --- a/packages/webview-app/tests/flows/onboarding.passport.test.tsx +++ b/packages/webview-app/tests/flows/onboarding.passport.test.tsx @@ -40,6 +40,23 @@ describe('passport onboarding flow', () => { }); await waitFor(() => expect(currentPath(result)).toBe('/capture/passport/nfc-success')); + + // The captured document must be persisted over the documents bridge + // domain (host store), not tunneled through secureStorage. + expect(result.documents.byId.size).toBe(1); + const catalog = result.documents.catalog.value as { + documents: Array<{ id: string; isRegistered: boolean }>; + selectedDocumentId?: string; + }; + expect(catalog.documents).toHaveLength(1); + expect(catalog.documents[0].isRegistered).toBe(false); + // Document ids are content hashes, never document numbers — keychain + // service names derived from the id leak into native logs. + const [docId] = result.documents.byId.keys(); + expect(catalog.documents[0].id).toBe(docId); + expect(catalog.selectedDocumentId).toBe(docId); + expect(docId).not.toContain('L898902C3'); // chip MRZ document number + expect(docId).not.toContain(MRZ_FIXTURE.documentNumber); }); it('NFC failure routes to the error screen and shows the support reference', async () => { diff --git a/packages/webview-app/tests/utils/renderWithBridge.tsx b/packages/webview-app/tests/utils/renderWithBridge.tsx index 6ef29cf382..99e60ce278 100644 --- a/packages/webview-app/tests/utils/renderWithBridge.tsx +++ b/packages/webview-app/tests/utils/renderWithBridge.tsx @@ -30,15 +30,23 @@ export interface RenderWithBridgeOptions { config?: HostConfig; // Seed values for the in-memory secure storage map (key -> string value). storage?: Record; + // Seed for the documents-domain store (catalog object + documents by id). + documents?: { catalog?: unknown; byId?: Record }; // Caller hook to register flow-specific handlers (camera/scanMRZ, nfc/scanPassport, // etc.) or to override any baseline handler. Runs after defaults are registered. setupHandlers?: (mock: MockNativeBridge) => void; } +export interface HarnessDocumentsStore { + catalog: { value: unknown }; + byId: Map; +} + export interface RenderWithBridgeResult extends RenderResult { mock: MockNativeBridge; bridge: WebViewBridge; storage: Map; + documents: HarnessDocumentsStore; } const LocationProbe: React.FC = () => { @@ -56,10 +64,14 @@ export function currentPath(result: RenderWithBridgeResult): string { * real routing and real bridge round-trips; only the native side is mocked. */ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBridgeResult { - const { initialEntries, config, storage: seed, setupHandlers } = options; + const { initialEntries, config, storage: seed, documents: documentsSeed, setupHandlers } = options; const mock = new MockNativeBridge(); const storage = new Map(Object.entries(seed ?? {})); + const documents: HarnessDocumentsStore = { + catalog: { value: documentsSeed?.catalog ?? null }, + byId: new Map(Object.entries(documentsSeed?.byId ?? {})), + }; // Awaited boot request: operating mode + reference id. mock.handle('lifecycle', 'getConfig', () => ({ mode: 'self-app', ...config })); @@ -70,8 +82,8 @@ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBr mock.handleWith('lifecycle', 'dismiss', {}); mock.handleWith('haptic', 'trigger', {}); - // Secure storage backed by an in-memory map. Covers the secret manager, - // document catalog (self_document_catalog) and document bodies (self_doc_*). + // Secure storage backed by an in-memory map. Covers the secret manager + // (mnemonic/private key); documents live on the documents domain below. mock.handle('secureStorage', 'get', params => { const key = params.key as string; return storage.has(key) ? storage.get(key) : null; @@ -85,6 +97,23 @@ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBr return {}; }); + // Documents domain, mirroring rn-sdk DocumentsHandler semantics (an unset + // catalog loads as null; the web-side typed adapter normalizes it). + mock.handle('documents', 'loadCatalog', () => documents.catalog.value); + mock.handle('documents', 'saveCatalog', params => { + documents.catalog.value = params.catalog; + return {}; + }); + mock.handle('documents', 'loadById', params => documents.byId.get(params.id as string) ?? null); + mock.handle('documents', 'save', params => { + documents.byId.set(params.id as string, params.data); + return {}; + }); + mock.handle('documents', 'delete', params => { + documents.byId.delete(params.id as string); + return {}; + }); + setupHandlers?.(mock); const bridge = new WebViewBridge({ transport: mock }); @@ -103,7 +132,7 @@ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBr , ); - return Object.assign(result, { mock, bridge, storage }); + return Object.assign(result, { mock, bridge, storage, documents }); } export type { BridgeDomain }; diff --git a/packages/webview-bridge/src/__tests__/adapters.test.ts b/packages/webview-bridge/src/__tests__/adapters.test.ts index 3b51cf50c3..1e881fac23 100644 --- a/packages/webview-bridge/src/__tests__/adapters.test.ts +++ b/packages/webview-bridge/src/__tests__/adapters.test.ts @@ -11,6 +11,7 @@ import { bridgeCameraAdapter, bridgeCryptoAdapter, bridgeDocumentsAdapter, + createBridgeDocumentsSdkAdapter, bridgeHapticAdapter, bridgeLifecycleAdapter, bridgeNFCScannerAdapter, @@ -278,6 +279,56 @@ describe('Adapter integration tests', () => { }); }); + describe('SDK Documents Adapter (typed)', () => { + it('normalizes a null catalog (unset host store) to an empty catalog', async () => { + mock.handleWith('documents', 'loadCatalog', null); + + const docs = createBridgeDocumentsSdkAdapter(bridge); + expect(await docs.loadDocumentCatalog()).toEqual({ documents: [] }); + }); + + it('normalizes a malformed catalog to an empty catalog', async () => { + mock.handleWith('documents', 'loadCatalog', { documents: 'not-an-array' }); + + const docs = createBridgeDocumentsSdkAdapter(bridge); + expect(await docs.loadDocumentCatalog()).toEqual({ documents: [] }); + }); + + it('passes a valid catalog through unchanged (app schema pin)', async () => { + // Shape of the Self app's real DocumentCatalog (common DocumentMetadata): + // this fixture surviving normalization unchanged is the B2 compatibility pin. + const catalog = { + documents: [ + { + id: 'a1b2c3', + documentType: 'passport', + documentCategory: 'passport', + data: 'P { + mock.handleWith('documents', 'loadById', null); + mock.handleWith('documents', 'saveCatalog', {}); + + const docs = createBridgeDocumentsSdkAdapter(bridge); + expect(await docs.loadDocumentById('missing')).toBeNull(); + + await docs.saveDocumentCatalog({ documents: [] }); + const saveMessage = mock.messagesFor('documents').find(m => m.method === 'saveCatalog'); + expect(saveMessage?.params).toEqual({ catalog: { documents: [] } }); + }); + }); + describe('Storage Adapter', () => { it('should get/set/remove values', async () => { mock.handleWith('secureStorage', 'get', { value: 'stored' }); diff --git a/packages/webview-bridge/src/adapters/documents.ts b/packages/webview-bridge/src/adapters/documents.ts index ee677a2301..6cb06f9107 100644 --- a/packages/webview-bridge/src/adapters/documents.ts +++ b/packages/webview-bridge/src/adapters/documents.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +import type { DocumentCatalog, DocumentsAdapter, IDDocument } from '@selfxyz/mobile-sdk-alpha/browser'; + import type { WebViewBridge } from '../bridge'; export interface BridgeDocumentsAdapter { @@ -35,3 +37,48 @@ export function bridgeDocumentsAdapter(bridge: WebViewBridge): BridgeDocumentsAd }, }; } + +const EMPTY_CATALOG: DocumentCatalog = { documents: [] }; + +// The documents host store legitimately returns null for a never-written +// catalog (DocumentsHandler contract), but SDK callers dereference +// catalog.documents unguarded — normalize anything malformed to an empty +// catalog at the boundary. +function normalizeCatalog(raw: unknown): DocumentCatalog { + if ( + raw && + typeof raw === 'object' && + Array.isArray((raw as { documents?: unknown }).documents) + ) { + return raw as DocumentCatalog; + } + return EMPTY_CATALOG; +} + +/** + * SDK-facing typed adapter over the raw `documents` bridge domain. This is + * what webview-app hands to createSelfClient; the host side is rn-sdk's + * DocumentsHandler (or the host's injected store, e.g. the Self app's + * keychain-backed document store in webview-in-app). + */ +export function createBridgeDocumentsSdkAdapter(bridge: WebViewBridge): DocumentsAdapter { + const raw = bridgeDocumentsAdapter(bridge); + return { + async loadDocumentCatalog(): Promise { + return normalizeCatalog(await raw.loadDocumentCatalog()); + }, + async saveDocumentCatalog(catalog: DocumentCatalog): Promise { + await raw.saveDocumentCatalog(catalog); + }, + async loadDocumentById(id: string): Promise { + const doc = await raw.loadDocumentById(id); + return doc && typeof doc === 'object' ? (doc as IDDocument) : null; + }, + async saveDocument(id: string, doc: IDDocument): Promise { + await raw.saveDocument(id, doc); + }, + async deleteDocument(id: string): Promise { + await raw.deleteDocument(id); + }, + }; +} diff --git a/packages/webview-bridge/src/adapters/index.ts b/packages/webview-bridge/src/adapters/index.ts index 699b58aa8f..11316d2604 100644 --- a/packages/webview-bridge/src/adapters/index.ts +++ b/packages/webview-bridge/src/adapters/index.ts @@ -39,7 +39,7 @@ export { bridgeBiometricsAdapter } from './biometrics'; export { bridgeCameraAdapter } from './camera'; export { bridgeCryptoAdapter } from './crypto'; -export { bridgeDocumentsAdapter } from './documents'; +export { bridgeDocumentsAdapter, createBridgeDocumentsSdkAdapter } from './documents'; export { bridgeHapticAdapter } from './haptic'; export { bridgeLifecycleAdapter } from './lifecycle'; @@ -50,7 +50,6 @@ export { bridgeStorageAdapter } from './storage'; export function consoleAnalyticsAdapter(options?: ConsoleAnalyticsOptions): BridgeAnalyticsAdapter { return createWebAnalyticsAdapter(options) as BridgeAnalyticsAdapter; } -export { createKeychainDocumentsAdapter } from './keychain-documents'; export { createSdkAdapters } from './sdk-adapter-map'; export function indexedDBDocumentsAdapter(): BridgeDocumentsAdapter { diff --git a/packages/webview-bridge/src/adapters/keychain-documents.ts b/packages/webview-bridge/src/adapters/keychain-documents.ts deleted file mode 100644 index 4795251a9d..0000000000 --- a/packages/webview-bridge/src/adapters/keychain-documents.ts +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc. -// SPDX-License-Identifier: BUSL-1.1 -// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. - -import type { DocumentCatalog, DocumentsAdapter, IDDocument } from '@selfxyz/mobile-sdk-alpha/browser'; - -import type { WebViewBridge } from '../bridge'; - -const CATALOG_KEY = 'self_document_catalog'; -const DOC_PREFIX = 'self_doc_'; -const EMPTY_CATALOG: DocumentCatalog = { documents: [] }; - -function safeParse(raw: string, fallback: T): T { - try { - return JSON.parse(raw); - } catch { - return fallback; - } -} - -export function createKeychainDocumentsAdapter(bridge: WebViewBridge): DocumentsAdapter { - async function storageGet(key: string): Promise { - const result = await bridge.request('secureStorage', 'get', { key }); - if (result == null) return null; - if (typeof result === 'string') return result; - return result.value ?? null; - } - - async function storageSet(key: string, value: string): Promise { - await bridge.request('secureStorage', 'set', { key, value }); - } - - async function storageRemove(key: string): Promise { - await bridge.request('secureStorage', 'remove', { key }); - } - - return { - async loadDocumentCatalog(): Promise { - const raw = await storageGet(CATALOG_KEY); - return raw ? safeParse(raw, EMPTY_CATALOG) : EMPTY_CATALOG; - }, - async saveDocumentCatalog(catalog: DocumentCatalog): Promise { - await storageSet(CATALOG_KEY, JSON.stringify(catalog)); - }, - async loadDocumentById(id: string): Promise { - const raw = await storageGet(`${DOC_PREFIX}${id}`); - return raw ? safeParse(raw, null) : null; - }, - async saveDocument(id: string, doc: IDDocument): Promise { - await storageSet(`${DOC_PREFIX}${id}`, JSON.stringify(doc)); - }, - async deleteDocument(id: string): Promise { - await storageRemove(`${DOC_PREFIX}${id}`); - }, - }; -} diff --git a/packages/webview-bridge/src/adapters/sdk-adapter-map.ts b/packages/webview-bridge/src/adapters/sdk-adapter-map.ts index 93bcb62659..d62b1bb194 100644 --- a/packages/webview-bridge/src/adapters/sdk-adapter-map.ts +++ b/packages/webview-bridge/src/adapters/sdk-adapter-map.ts @@ -19,7 +19,7 @@ import { import type { WebViewBridge } from '../bridge'; import { bridgeAuthAdapter } from './auth'; import { bridgeCryptoAdapter } from './crypto'; -import { createKeychainDocumentsAdapter } from './keychain-documents'; +import { createBridgeDocumentsSdkAdapter } from './documents'; export interface CreateSdkAdaptersOpts { bridge: WebViewBridge; @@ -61,7 +61,7 @@ export function createSdkAdapters(opts: CreateSdkAdaptersOpts): Adapters { crypto, network: createWebNetworkAdapter(), auth, - documents: createKeychainDocumentsAdapter(bridge), + documents: createBridgeDocumentsSdkAdapter(bridge), navigation, analytics: analytics ?? createWebAnalyticsAdapter(), };