Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions __tests__/CollectionScreens-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -46,7 +53,7 @@ describe('Collection Screens', () => {
it('renders PasskeysScreen correctly', () => {
const { getByText } = render(<PasskeysScreen />);
expect(getByText('Test Passkey')).toBeTruthy();
expect(getByText('ID: cred123')).toBeTruthy();
expect(getByText('test@example.com')).toBeTruthy();
});

it('renders IdentitiesScreen correctly', () => {
Expand Down
26 changes: 24 additions & 2 deletions app.config.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
},
],
],
Expand All @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 19 additions & 32 deletions app/passkeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> }) {
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' },
Expand Down Expand Up @@ -49,25 +60,11 @@ export default function PasskeysScreen() {
<MaterialIcons name="fingerprint" size={24} color="#10B981" />
</View>
<View style={styles.details}>
<Text style={styles.passkeyName} numberOfLines={1} ellipsizeMode="middle">
{passkey.name}
<Text style={styles.website} numberOfLines={1} ellipsizeMode="tail">
{getPasskeyWebsite(passkey)}
</Text>
{passkey.userHandle && (
<Text style={styles.detailText} numberOfLines={1} ellipsizeMode="middle">
User: {passkey.userHandle}
</Text>
)}
{passkey.origin && (
<Text style={styles.detailText} numberOfLines={1} ellipsizeMode="middle">
Origin: {passkey.origin}
</Text>
)}
<Text style={styles.credentialId} numberOfLines={1} ellipsizeMode="middle">
ID: {passkey.id}
</Text>
<Text style={styles.date}>
Created:{' '}
{passkey.createdAt ? new Date(passkey.createdAt).toLocaleDateString() : 'N/A'}
<Text style={styles.username} numberOfLines={1} ellipsizeMode="middle">
{getPasskeyUsername(passkey)}
</Text>
</View>
<TouchableOpacity
Expand Down Expand Up @@ -128,25 +125,15 @@ const styles = StyleSheet.create({
details: {
flex: 1,
},
passkeyName: {
website: {
fontSize: 16,
fontWeight: '700',
color: '#0F172A',
marginBottom: 2,
},
detailText: {
fontSize: 13,
color: '#475569',
marginBottom: 2,
marginBottom: 4,
},
credentialId: {
fontSize: 13,
username: {
fontSize: 14,
color: '#64748B',
marginBottom: 2,
},
date: {
fontSize: 12,
color: '#94A3B8',
},
deleteButton: {
padding: 8,
Expand Down
2 changes: 1 addition & 1 deletion dialogs/DidDocumentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function DidDocumentModal({
{didDocument ? (
<View>
<JSONTree
data={didDocument}
data={didDocument as unknown as Record<string, unknown>}
theme={{
scheme: 'google',
author: 'seth wright (http://sethawright.com)',
Expand Down
20 changes: 20 additions & 0 deletions extensions/passkeys-keystore/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -40,6 +41,25 @@ export const WithPasskeysKeystore: Extension<PasskeysKeystoreExtension> = (
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');
Expand Down
10 changes: 8 additions & 2 deletions hooks/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

export function useConnection(origin: string, requestId: string): UseConnectionResult {
const router = useRouter();
const { accounts, keys, key, passkey, sessions } = useProvider();

Check warning on line 43 in hooks/useConnection.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

eslint(no-unused-vars)

Variable 'sessions' is declared but never used. Unused variables should start with a '_'.

const [isConnected, setIsConnected] = useState(false);
const [address, setAddress] = useState<string | null>(null);
Expand Down Expand Up @@ -281,7 +281,7 @@
origin,
type: 'algorand',
address: encodeAddress(foundKey?.publicKey),
signature: toBase64URL(await key.store.sign(foundKey.id, challenge)),

Check warning on line 284 in hooks/useConnection.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useEffect has missing dependencies: 'key.store', 'isConnected', and 'passkey.store'
device: 'Demo Web Wallet',
};

Expand Down Expand Up @@ -381,7 +381,10 @@
if (matchedKey) {
try {
const masterKey = await getMasterKey();
const keyData = await fetchSecret<KeyData>({ keyId: matchedKey.id, masterKey });
const keyData = await fetchSecret<KeyData>({
keyId: matchedKey.id,
options: { masterKey },
});
if (keyData) {
keyData.metadata = { ...keyData.metadata, registered: true };
await commit({ store: keyStore as any, keyData });
Expand Down Expand Up @@ -500,7 +503,10 @@
if (matchedKey) {
try {
const masterKey = await getMasterKey();
const keyData = await fetchSecret<KeyData>({ keyId: matchedKey.id, masterKey });
const keyData = await fetchSecret<KeyData>({
keyId: matchedKey.id,
options: { masterKey },
});
if (keyData) {
keyData.metadata = { ...keyData.metadata, registered: true };
await commit({ store: keyStore as any, keyData });
Expand Down Expand Up @@ -640,7 +646,7 @@
clientRef.current = null;
}
};
}, [origin, requestId, router, accounts.length > 0, keys.length > 0]);

Check warning on line 649 in hooks/useConnection.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useEffect has a complex expression in the dependency array.

Check warning on line 649 in hooks/useConnection.ts

View workflow job for this annotation

GitHub Actions / lint-and-test

eslint-plugin-react-hooks(exhaustive-deps)

React Hook useEffect has a complex expression in the dependency array.

return {
session,
Expand Down
Loading
Loading