From 0e75e5498b61b17f1d02821c984b29257b0579e9 Mon Sep 17 00:00:00 2001 From: Kyle Breeding Date: Thu, 14 May 2026 16:24:47 -0700 Subject: [PATCH 1/2] feat: integrate iOS passkey autofill --- README.md | 3 + __tests__/CollectionScreens-test.tsx | 11 +- app.config.js | 26 +++- app/_layout.tsx | 19 +++ app/import.tsx | 2 +- app/passkeys.tsx | 51 +++---- dialogs/DidDocumentModal.tsx | 2 +- extensions/passkeys-keystore/extension.ts | 20 +++ hooks/useConnection.ts | 10 +- lib/bootstrap.ts | 154 +++++++++++++++++++--- lib/credentialProvider.ts | 4 +- stores/logs.ts | 2 +- 12 files changed, 244 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index ff649ca..0ed48c0 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ To further integrate with identity primitives, the following extensions are sugg ## Getting Started +> [!NOTE] +> The iOS passkey AutoFill integration in this branch depends on the next published version of `@algorandfoundation/react-native-passkey-autofill` that includes iOS Credential Provider support. Keep any Rocca PR as a draft until that package is published, then update the dependency version before marking the PR ready. + 1. Install dependencies ```bash diff --git a/__tests__/CollectionScreens-test.tsx b/__tests__/CollectionScreens-test.tsx index 45d72f9..22d2911 100644 --- a/__tests__/CollectionScreens-test.tsx +++ b/__tests__/CollectionScreens-test.tsx @@ -21,7 +21,14 @@ jest.mock('@/hooks/useProvider', () => ({ useProvider: () => ({ identities: [{ did: 'did:key:123' }], accounts: [{ address: 'ADDR123', balance: BigInt(100) }], - passkeys: [{ id: 'cred123', name: 'Test Passkey', createdAt: new Date().getTime() }], + passkeys: [ + { + id: 'cred123', + name: 'Test Passkey', + createdAt: new Date().getTime(), + metadata: { userName: 'test@example.com' }, + }, + ], sessions: [{ id: 'sess123', origin: 'example.com' }], passkey: { store: { @@ -46,7 +53,7 @@ describe('Collection Screens', () => { it('renders PasskeysScreen correctly', () => { const { getByText } = render(); expect(getByText('Test Passkey')).toBeTruthy(); - expect(getByText('ID: cred123')).toBeTruthy(); + expect(getByText('test@example.com')).toBeTruthy(); }); it('renders IdentitiesScreen correctly', () => { diff --git a/app.config.js b/app.config.js index efe1f71..ae4c2d0 100644 --- a/app.config.js +++ b/app.config.js @@ -1,6 +1,16 @@ const { version } = require('./package.json'); const ENV = process.env.APP_ENV || 'debug'; +const PASSKEY_AUTOFILL_SITE = process.env.PASSKEY_AUTOFILL_SITE || 'https://fido.shore-tech.net'; +const PASSKEY_AUTOFILL_LABEL = process.env.PASSKEY_AUTOFILL_LABEL || 'Rocca Wallet'; + +const getAssociatedDomain = (site) => { + try { + return new URL(site).host; + } catch { + return site.replace(/^https?:\/\//, '').split('/')[0]; + } +}; const getBundleIdentifier = () => { switch (ENV) { @@ -42,6 +52,13 @@ module.exports = { ios: { supportsTablet: true, bundleIdentifier: getBundleIdentifier(), + associatedDomains: [`webcredentials:${getAssociatedDomain(PASSKEY_AUTOFILL_SITE)}`], + infoPlist: { + NSFaceIDUsageDescription: 'Rocca uses Face ID to unlock your wallet keys.', + }, + entitlements: { + 'com.apple.developer.authentication-services.autofill-credential-provider': true, + }, }, icon: './assets/icon.png', splash: { @@ -89,8 +106,8 @@ module.exports = { [ '@algorandfoundation/react-native-passkey-autofill', { - site: 'https://fido.shore-tech.net', - label: 'Rocca Wallet', + site: PASSKEY_AUTOFILL_SITE, + label: PASSKEY_AUTOFILL_LABEL, }, ], ], @@ -111,6 +128,11 @@ module.exports = { showIdentities: true, showConnections: true, }, + passkeyAutofill: { + site: PASSKEY_AUTOFILL_SITE, + label: PASSKEY_AUTOFILL_LABEL, + associatedDomain: getAssociatedDomain(PASSKEY_AUTOFILL_SITE), + }, router: {}, eas: { projectId: 'f1e6cb1b-642d-49fa-b276-53b4403f62d6', diff --git a/app/_layout.tsx b/app/_layout.tsx index ab8b367..7e70013 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,5 +1,6 @@ import { useEventListener } from 'expo'; import { Stack } from 'expo-router'; +import { AppState } from 'react-native'; import { install } from 'react-native-quick-crypto'; import { keyStore } from '@/stores/keystore'; import { keyStoreHooks } from '@/stores/before-after'; @@ -64,6 +65,24 @@ export default function RootLayout() { bootstrap(biometricOptions).catch((e) => console.error('Bootstrap promise error:', e)); }, []); + React.useEffect(() => { + let wasBackgrounded = false; + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'background' || state === 'inactive') { + wasBackgrounded = true; + return; + } + if (state === 'active' && wasBackgrounded) { + wasBackgrounded = false; + bootstrap(biometricOptions, false).catch((e) => + console.error('Failed to reload keys after app became active:', e), + ); + } + }); + + return () => subscription.remove(); + }, []); + useEventListener(ReactNativePasskeyAutofill, 'onPasskeyAdded', (event) => { console.log('Passkey added via autofill:', event); if (event.success) { diff --git a/app/import.tsx b/app/import.tsx index 24afe3f..5a169ca 100644 --- a/app/import.tsx +++ b/app/import.tsx @@ -169,7 +169,7 @@ export default function ImportWalletScreen() { await new Promise((resolve) => setTimeout(resolve, 500)); // Bootstrap to ensure native side is updated with new master key and keys - await bootstrap(false); + await bootstrap(undefined, false); const { identities } = identitiesStore.state; const { accounts } = accountsStore.state; diff --git a/app/passkeys.tsx b/app/passkeys.tsx index fcf0cc0..9352236 100644 --- a/app/passkeys.tsx +++ b/app/passkeys.tsx @@ -5,9 +5,20 @@ import { Stack, useRouter } from 'expo-router'; import { MaterialIcons } from '@expo/vector-icons'; import { useProvider } from '@/hooks/useProvider'; +function getPasskeyWebsite(passkey: { origin?: string; name: string }) { + return passkey.origin || passkey.name; +} + +function getPasskeyUsername(passkey: { metadata?: Record }) { + return typeof passkey.metadata?.userName === 'string' && passkey.metadata.userName.length > 0 + ? passkey.metadata.userName + : 'Unknown user'; +} + export default function PasskeysScreen() { const router = useRouter(); const { passkeys, passkey: passkeyApi } = useProvider(); + const handleDelete = (id: string, name: string) => { Alert.alert('Delete Passkey', `Are you sure you want to delete "${name}"?`, [ { text: 'Cancel', style: 'cancel' }, @@ -49,25 +60,11 @@ export default function PasskeysScreen() { - - {passkey.name} + + {getPasskeyWebsite(passkey)} - {passkey.userHandle && ( - - User: {passkey.userHandle} - - )} - {passkey.origin && ( - - Origin: {passkey.origin} - - )} - - ID: {passkey.id} - - - Created:{' '} - {passkey.createdAt ? new Date(passkey.createdAt).toLocaleDateString() : 'N/A'} + + {getPasskeyUsername(passkey)} } theme={{ scheme: 'google', author: 'seth wright (http://sethawright.com)', diff --git a/extensions/passkeys-keystore/extension.ts b/extensions/passkeys-keystore/extension.ts index 2aa3881..1a93c36 100644 --- a/extensions/passkeys-keystore/extension.ts +++ b/extensions/passkeys-keystore/extension.ts @@ -10,6 +10,7 @@ import type { Store } from '@tanstack/store'; import { toUrlSafe } from '@/utils/base64'; import type { PasskeysKeystoreExtension, PasskeysKeystoreExtensionOptions } from './types'; import type { LogStoreExtension } from '@algorandfoundation/log-store'; +import ReactNativePasskeyAutofill from '@algorandfoundation/react-native-passkey-autofill'; /** * Extension that bridges the passkey store and keystore. @@ -40,6 +41,25 @@ export const WithPasskeysKeystore: Extension = ( provider.passkey.store.hooks.before('remove', async ({ id }) => { log?.info(`before remove hook: looking up key for passkey id=${id}`, {}, 'PasskeysKeystore'); const foundKey = (keyStore.state.keys as Key[]).find((k) => toUrlSafe(k.id) === id); + + const nativeCredentialIds = new Set([id]); + if (foundKey?.id) { + nativeCredentialIds.add(foundKey.id); + } + + for (const credentialId of nativeCredentialIds) { + try { + log?.info(`deleting native passkey credential ${credentialId}`, {}, 'PasskeysKeystore'); + await ReactNativePasskeyAutofill.deleteCredential(credentialId); + } catch (error) { + log?.error( + `Failed to delete native passkey credential ${credentialId}: ${error}`, + {}, + 'PasskeysKeystore', + ); + } + } + if (foundKey) { try { log?.info(`removing key ${foundKey.id} from keystore`, {}, 'PasskeysKeystore'); diff --git a/hooks/useConnection.ts b/hooks/useConnection.ts index 7b354df..745c6c9 100644 --- a/hooks/useConnection.ts +++ b/hooks/useConnection.ts @@ -381,7 +381,10 @@ export function useConnection(origin: string, requestId: string): UseConnectionR if (matchedKey) { try { const masterKey = await getMasterKey(); - const keyData = await fetchSecret({ keyId: matchedKey.id, masterKey }); + const keyData = await fetchSecret({ + keyId: matchedKey.id, + options: { masterKey }, + }); if (keyData) { keyData.metadata = { ...keyData.metadata, registered: true }; await commit({ store: keyStore as any, keyData }); @@ -500,7 +503,10 @@ export function useConnection(origin: string, requestId: string): UseConnectionR if (matchedKey) { try { const masterKey = await getMasterKey(); - const keyData = await fetchSecret({ keyId: matchedKey.id, masterKey }); + const keyData = await fetchSecret({ + keyId: matchedKey.id, + options: { masterKey }, + }); if (keyData) { keyData.metadata = { ...keyData.metadata, registered: true }; await commit({ store: keyStore as any, keyData }); diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 4b74ddc..4f883fd 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -15,26 +15,95 @@ import { import { Store } from '@tanstack/store'; import ReactNativePasskeyAutofill from '@algorandfoundation/react-native-passkey-autofill'; import { keyStore } from '@/stores/keystore'; +import { passkeysStore } from '@/stores/passkeys'; import { CredentialProviderService } from '@/lib/credentialProvider'; import { addLog } from '@algorandfoundation/log-store'; -import * as Keychain from 'react-native-keychain'; -import { randomBytes } from 'react-native-quick-crypto'; import { generateId } from '@algorandfoundation/wallet-provider'; import { logsStore } from '@/stores/logs'; +import { toUrlSafe } from '@/utils/base64'; -/** - * Bootstraps the app's keystore and native passkey autofill service. - * This should be called on app start, and after any operation that changes the wallet's keys (e.g., import, create). - * - * @param options - * @param showAlert - Whether to show an alert if the autofill service is not enabled. - */ -export async function bootstrap(options?: AuthenticationOptions, showAlert = true) { +type NativeStoredCredential = { + credentialId: string; + relyingPartyIdentifier: string; + userName: string; + userHandle: string; + publicKey?: string; + createdAt?: number; +}; + +function base64ToBytes(value: string): Uint8Array { + return new Uint8Array(Buffer.from(value, 'base64')); +} + +async function syncNativeStoredPasskeys(logMsg: (message: string, level?: string) => void) { + const credentials = (await ReactNativePasskeyAutofill.getStoredCredentials().catch( + (e: unknown) => { + logMsg(`ReactNativePasskeyAutofill.getStoredCredentials error: ${e}`, 'error'); + return []; + }, + )) as NativeStoredCredential[]; + + logMsg(`Native passkey credentials visible to app: ${credentials.length}`); + await ReactNativePasskeyAutofill.refreshCredentialIdentities?.().catch((e: unknown) => { + logMsg(`ReactNativePasskeyAutofill.refreshCredentialIdentities error: ${e}`, 'error'); + }); + const diagnostics = await ReactNativePasskeyAutofill.getDiagnostics().catch((): string[] => []); + diagnostics.slice(-8).forEach((line: string) => { + logMsg(`PasskeyAutofill diagnostic: ${line}`); + }); + + if (credentials.length === 0) { + return; + } + + passkeysStore.setState((state) => { + const nativePasskeys = credentials.map((credential) => { + const id = toUrlSafe(credential.credentialId); + const createdAt = + credential.createdAt && credential.createdAt < 10_000_000_000 + ? credential.createdAt * 1000 + : credential.createdAt; + + return { + id, + name: credential.relyingPartyIdentifier, + userHandle: credential.userHandle, + origin: credential.relyingPartyIdentifier, + publicKey: credential.publicKey ? base64ToBytes(credential.publicKey) : new Uint8Array(), + algorithm: 'P256', + createdAt, + metadata: { + keyId: credential.credentialId, + nativeCredential: true, + registered: true, + userName: credential.userName, + }, + }; + }); + + const nativeIds = new Set(nativePasskeys.map((passkey) => passkey.id)); + const retained = state.passkeys.filter((passkey) => !nativeIds.has(passkey.id)); + return { + ...state, + passkeys: [...nativePasskeys, ...retained], + }; + }); +} + +let activeBootstrap: Promise | null = null; + +async function runBootstrap(options?: AuthenticationOptions, showAlert = true) { const logMsg = (message: string, level = 'info') => { addLog({ store: logsStore, - log: { id: generateId(), level, context: 'Bootstrap', timestamp: new Date(), message }, + log: { + id: generateId(), + level, + context: 'Bootstrap', + timestamp: new Date(), + message, + }, }); if (level === 'error') { console.error(`[Bootstrap ERROR] ${message}`); @@ -44,7 +113,10 @@ export async function bootstrap(options?: AuthenticationOptions, showAlert = tru }; try { - setStatus({ store: keyStore as unknown as Store, status: 'loading' }); + setStatus({ + store: keyStore as unknown as Store, + status: 'loading', + }); const keyIds = storage.getAllKeys(); logMsg(`Found ${keyIds.length} keys in storage`); @@ -76,8 +148,13 @@ export async function bootstrap(options?: AuthenticationOptions, showAlert = tru logMsg(`ReactNativePasskeyAutofill.configureIntentActions error: ${e}`, 'error'); }); + await syncNativeStoredPasskeys(logMsg); + logMsg('No keys found, setting keystore status to idle'); - setStatus({ store: keyStore as unknown as Store, status: 'idle' }); + setStatus({ + store: keyStore as unknown as Store, + status: 'idle', + }); return; } @@ -99,7 +176,7 @@ export async function bootstrap(options?: AuthenticationOptions, showAlert = tru const keys = secrets .filter((k) => k !== null) - .map(({ privateKey, seed, ...rest }: any) => rest) as Key[]; + .map(({ privateKey: _privateKey, seed: _seed, ...rest }: any) => rest) as Key[]; logMsg(`Found ${keys.length} keys in storage`); keys.forEach((k) => { @@ -134,6 +211,9 @@ export async function bootstrap(options?: AuthenticationOptions, showAlert = tru keys, }); + const hdRootKeySecret = secrets.find( + (s) => s !== null && (s.type === 'hd-root-key' || s.type === 'xhd-root-key'), + ); const hdRootKey = keys.find((k) => k.type === 'hd-root-key') || keys.find((k) => k.type === 'xhd-root-key') || @@ -146,6 +226,17 @@ export async function bootstrap(options?: AuthenticationOptions, showAlert = tru }); } + if (hdRootKeySecret?.privateKey) { + logMsg('Setting derived main key material in native side'); + await ReactNativePasskeyAutofill.setDerivedMainKey( + Buffer.from(hdRootKeySecret.privateKey).toString('hex'), + ).catch((e) => { + logMsg(`ReactNativePasskeyAutofill.setDerivedMainKey error: ${e}`, 'error'); + }); + } else { + logMsg('No HD root key material available for native passkey registration', 'error'); + } + const isEnabled = await CredentialProviderService.isEnabledCredentialProviderService().catch( (e) => { logMsg(`CredentialProviderService.isEnabledCredentialProviderService error: ${e}`, 'error'); @@ -180,15 +271,44 @@ export async function bootstrap(options?: AuthenticationOptions, showAlert = tru logMsg(`ReactNativePasskeyAutofill.configureIntentActions error: ${e}`, 'error'); }); + await syncNativeStoredPasskeys(logMsg); + if (keys.length > 0) { logMsg('Setting keystore status to ready'); - setStatus({ store: keyStore as unknown as Store, status: 'ready' }); + setStatus({ + store: keyStore as unknown as Store, + status: 'ready', + }); } else { logMsg('No keys found, setting keystore status to idle'); - setStatus({ store: keyStore as unknown as Store, status: 'idle' }); + setStatus({ + store: keyStore as unknown as Store, + status: 'idle', + }); } } catch (e) { logMsg(`Bootstrap failed: ${e}`, 'error'); - setStatus({ store: keyStore as unknown as Store, status: 'error' }); + setStatus({ + store: keyStore as unknown as Store, + status: 'error', + }); } } + +/** + * Bootstraps the app's keystore and native passkey autofill service. + * This should be called on app start, and after any operation that changes the wallet's keys (e.g., import, create). + * + * @param options + * @param showAlert - Whether to show an alert if the autofill service is not enabled. + */ +export async function bootstrap(options?: AuthenticationOptions, showAlert = true) { + if (activeBootstrap) { + return activeBootstrap; + } + + activeBootstrap = runBootstrap(options, showAlert).finally(() => { + activeBootstrap = null; + }); + return activeBootstrap; +} diff --git a/lib/credentialProvider.ts b/lib/credentialProvider.ts index 09ebcb6..3a87592 100644 --- a/lib/credentialProvider.ts +++ b/lib/credentialProvider.ts @@ -9,8 +9,8 @@ export interface CredentialProvider { export const CredentialProviderService: CredentialProvider = { isEnabledCredentialProviderService: async () => { - if (Platform.OS !== 'android') return true; - if (!CredentialProviderModule) return true; + if (Platform.OS !== 'android') return false; + if (!CredentialProviderModule) return false; return await CredentialProviderModule.isEnabledCredentialProviderService(); }, showCredentialProviderSettings: async () => { diff --git a/stores/logs.ts b/stores/logs.ts index bd5fbce..6d483b6 100644 --- a/stores/logs.ts +++ b/stores/logs.ts @@ -1,4 +1,4 @@ -import { Store } from '@tanstack/react-store'; +import { Store } from '@tanstack/store'; import { LogStoreState } from '@algorandfoundation/log-store'; export const logsStore = new Store({ From a0fd35e7a7777ba674c5dabf6540f73d51e78afa Mon Sep 17 00:00:00 2001 From: Kyle Breeding Date: Tue, 19 May 2026 06:55:35 -0700 Subject: [PATCH 2/2] chore: react-native-passkey-autofill dep update --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2fa6664..a59ee34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@algorandfoundation/liquid-client": "1.0.0-canary.3", "@algorandfoundation/log-store": "^1.0.0-canary.3", "@algorandfoundation/react-native-keystore": "^1.0.0-canary.11", - "@algorandfoundation/react-native-passkey-autofill": "^1.0.0-canary.15", + "@algorandfoundation/react-native-passkey-autofill": "1.0.0-canary.16", "@config-plugins/react-native-webrtc": "^13.0.0", "@expo/vector-icons": "^15.0.3", "@noble/hashes": "^1.8.0", @@ -365,12 +365,12 @@ } }, "node_modules/@algorandfoundation/react-native-passkey-autofill": { - "version": "1.0.0-canary.15", - "resolved": "https://registry.npmjs.org/@algorandfoundation/react-native-passkey-autofill/-/react-native-passkey-autofill-1.0.0-canary.15.tgz", - "integrity": "sha512-7HThbTuQOBv2AMYmyLu7PKKbAlaHECvbFSCJaE4JsgYbmoN53EjpN3W39AhQcNANIVJPrWHDEiJdnyWWBigWUQ==", + "version": "1.0.0-canary.16", + "resolved": "https://registry.npmjs.org/@algorandfoundation/react-native-passkey-autofill/-/react-native-passkey-autofill-1.0.0-canary.16.tgz", + "integrity": "sha512-4FCY8KUrlsPuX0ENK3ZAnxMHPCAbqCXaD1OQJiq2Z3cGN57SDDl7+XpoEsy4dMtJSPl2TqRlhJmfgb/1JnfLSg==", "license": "Apache-2.0", "dependencies": { - "@algorandfoundation/react-native-keystore": "^1.0.0-canary.9" + "@algorandfoundation/react-native-keystore": "1.0.0-canary.11" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index aef731d..073e711 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@algorandfoundation/liquid-client": "1.0.0-canary.3", "@algorandfoundation/log-store": "^1.0.0-canary.3", "@algorandfoundation/react-native-keystore": "^1.0.0-canary.11", - "@algorandfoundation/react-native-passkey-autofill": "^1.0.0-canary.15", + "@algorandfoundation/react-native-passkey-autofill": "1.0.0-canary.16", "@config-plugins/react-native-webrtc": "^13.0.0", "@expo/vector-icons": "^15.0.3", "@noble/hashes": "^1.8.0",