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/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",
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({