Skip to content
Merged
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
38 changes: 37 additions & 1 deletion __mocks__/react-native-keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface Credential {

const store = new Map<string, Credential>();
let nextError: Error | null = null;
let hangNext = false;

function serviceOf(options?: Options): string {
return options?.service ?? 'default';
Expand All @@ -43,6 +44,29 @@ export const ACCESSIBLE = {
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 'AccessibleWhenPasscodeSetThisDeviceOnly',
} as const;

/** String values mirror the library's `BIOMETRY_TYPE` enum (enums.ts). */
export const BIOMETRY_TYPE = {
TOUCH_ID: 'TouchID',
FACE_ID: 'FaceID',
FINGERPRINT: 'Fingerprint',
FACE: 'Face',
IRIS: 'Iris',
} as const;

type BiometryType = (typeof BIOMETRY_TYPE)[keyof typeof BIOMETRY_TYPE];

let supportedBiometryType: BiometryType | null = null;

export async function getSupportedBiometryType(): Promise<BiometryType | null> {
takeError();
return supportedBiometryType;
}

/** Test helper: set what `getSupportedBiometryType` resolves to (default null). */
export function __setSupportedBiometryType(type: BiometryType | null): void {
supportedBiometryType = type;
}

export async function setGenericPassword(
username: string,
password: string,
Expand All @@ -57,6 +81,11 @@ export async function getGenericPassword(
options?: Options,
): Promise<(Credential & { service: string }) | false> {
takeError();
if (hangNext) {
hangNext = false;
// The OS auth sheet is up and the user hasn't answered yet.
return new Promise<never>(() => undefined);
}
const credential = store.get(serviceOf(options));
return credential ? { ...credential, service: serviceOf(options) } : false;
}
Expand All @@ -71,13 +100,20 @@ export async function hasGenericPassword(options?: Options): Promise<boolean> {
return store.has(serviceOf(options));
}

/** Test helper: clear all stored credentials and any queued error. */
/** Test helper: clear all stored credentials, queued error/hang, and biometry type. */
export function __resetKeychain(): void {
store.clear();
nextError = null;
hangNext = false;
supportedBiometryType = null;
}

/** Test helper: make the next keychain op reject with `error`. */
export function __failNextKeychainOp(error: Error): void {
nextError = error;
}

/** Test helper: make the next `getGenericPassword` never settle (OS sheet up). */
export function __hangNextKeychainOp(): void {
hangNext = true;
}
12 changes: 8 additions & 4 deletions src/components/PinKeypad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ interface PinKeypadProps {
readonly onComplete: (pin: string) => void;
/** When true, clears the entered digits (e.g. after a mismatch). */
readonly error?: boolean;
/** Ignores every key press and dims the pad (A3 lockout/verifying). */
readonly disabled?: boolean;
}

const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', '⌫'] as const;

export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps) {
export function PinKeypad({ length, onComplete, error = false, disabled = false }: PinKeypadProps) {
const [digits, setDigits] = useState('');
// Mirrors `digits` synchronously. `press` reads/writes this instead of the
// `digits` state closure so a same-tick double-tap (multi-touch) sees the
Expand All @@ -48,7 +50,9 @@ export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps)
}, [error]);

function press(key: string) {
if (key === '') {
// Guarded here as well as via the Pressable's `disabled` so a press that
// was already dispatched when the pad flipped to disabled is still ignored.
if (disabled || key === '') {
return;
}
if (key === '⌫') {
Expand All @@ -70,7 +74,7 @@ export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps)
}

return (
<View className="items-center gap-8" testID="pin-keypad">
<View className={disabled ? 'items-center gap-8 opacity-40' : 'items-center gap-8'} testID="pin-keypad">
<Animated.View style={shakeStyle} className="flex-row gap-3">
{Array.from({ length }, (_, i) => (
<View
Expand All @@ -89,7 +93,7 @@ export function PinKeypad({ length, onComplete, error = false }: PinKeypadProps)
<Pressable
key={i}
testID={key === '' ? undefined : `pin-key-${key === '⌫' ? 'backspace' : key}`}
disabled={key === ''}
disabled={disabled || key === ''}
onPress={() => press(key)}
className="aspect-square w-[62px] items-center justify-center rounded-full border border-borderDefault"
>
Expand Down
33 changes: 33 additions & 0 deletions src/components/__tests__/PinKeypad.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,39 @@ describe('PinKeypad', () => {
expect(litDots(tree!, 4)).toBe(0);
});

it('disabled ignores digit and backspace presses (A3 lockout/verifying)', () => {
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={2} onComplete={onComplete} disabled />);
});

pressKey(tree!, '1');
pressKey(tree!, '2');
pressKey(tree!, '⌫');

expect(onComplete).not.toHaveBeenCalled();
expect(litDots(tree!, 2)).toBe(0);
});

it('re-enabling after disabled restores input from a clean slate', () => {
const onComplete = jest.fn();
let tree: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(<PinKeypad length={2} onComplete={onComplete} disabled />);
});
pressKey(tree!, '1'); // swallowed while disabled

act(() => {
tree!.update(<PinKeypad length={2} onComplete={onComplete} disabled={false} />);
});
pressKey(tree!, '1');
pressKey(tree!, '2');

expect(onComplete).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledWith('12');
});

it('the error prop triggers the shared shake (not just a dot reset — Stage-2 A2b retrofit)', () => {
const triggerShake = jest.fn();
useShakeMock.mockReturnValue({
Expand Down
40 changes: 40 additions & 0 deletions src/lib/__tests__/deviceWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,46 @@ describe('unlock', () => {

await expect(unlock()).rejects.toThrow(/unexpected shape/i);
});

it('dedupes concurrent calls via single-flight (one keychain retrieve for both callers)', async () => {
const created = await createTestWallet();
const retrieveSpy = jest.spyOn(Keychain, 'getGenericPassword');

// Double-trigger race from the A3 screen: biometric-circle tap while the
// PIN path is already unlocking. One OS prompt, one import — shared result.
const [a, b] = await Promise.all([unlock(), unlock()]);

expect(a.address).toBe(created.address);
expect(b.address).toBe(created.address);
expect(retrieveSpy).toHaveBeenCalledTimes(1);
});

it('performs a fresh retrieve once the previous unlock settles (stateless — no session caching)', async () => {
await createTestWallet();
const retrieveSpy = jest.spyOn(Keychain, 'getGenericPassword');

await unlock();
await unlock();

expect(retrieveSpy).toHaveBeenCalledTimes(2);
});

it('does not cache the NoWalletError precondition as an in-flight unlock', async () => {
await expect(unlock()).rejects.toBeInstanceOf(NoWalletError);

// A wallet committed right after must unlock — a cached rejection here
// would mean the sync precondition leaked into the single-flight slot.
const created = await createTestWallet();
await expect(unlock()).resolves.toMatchObject({ address: created.address });
});

it('clears the in-flight slot after a failed unlock so a retry can succeed', async () => {
const created = await createTestWallet();
keychainMock.__failNextKeychainOp(new Error('biometric canceled'));

await expect(unlock()).rejects.toThrow('biometric canceled');
await expect(unlock()).resolves.toMatchObject({ address: created.address });
});
});

describe('reconcile', () => {
Expand Down
31 changes: 27 additions & 4 deletions src/lib/deviceWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,21 +138,41 @@ async function commit(mnemonic: string): Promise<UnlockedSession> {
};
}

let inFlightUnlock: Promise<UnlockedSession> | null = null;

/**
* Unlocks the stored wallet: retrieves the keystore secret (biometric prompt),
* reads the blob, and re-imports the wallet. A wrong/rotated secret or a corrupt
* blob surfaces as an `FfiError::Wallet` from `importKeystore`, which propagates.
*
* Concurrent calls dedupe via single-flight — the A3 screen can race a
* biometric-circle tap against an in-progress PIN-path unlock, and that must
* not stack two OS prompts / imports; every caller wants the same outcome, so
* they share one result. The slot clears when the attempt settles (fresh
* retrieve per unlock — the FFI is stateless, nothing is cached), and the
* synchronous NoWalletError precondition stays OUT of the slot (same pattern
* as `commitWallet`) so its rejection can never be handed to a later caller.
*
* @throws {NoWalletError} if no wallet is stored.
*/
export async function unlock(): Promise<UnlockedSession> {
export function unlock(): Promise<UnlockedSession> {
const record = readWalletRecord();
if (record === null) {
throw new NoWalletError();
return Promise.reject(new NoWalletError());
}
if (inFlightUnlock !== null) {
return inFlightUnlock;
}
inFlightUnlock = doUnlock(record.blob).finally(() => {
inFlightUnlock = null;
});
return inFlightUnlock;
}

async function doUnlock(blob: ArrayBuffer): Promise<UnlockedSession> {
const secretHex = await retrieveKeystoreSecret();
const password = asciiToArrayBuffer(secretHex);
const wallet: FfiWalletLike = await FfiWallet.importKeystore(record.blob, password);
const wallet: FfiWalletLike = await FfiWallet.importKeystore(blob, password);
return {
address: wallet.address(),
signMessage: (message: ArrayBuffer): Promise<ArrayBuffer> =>
Expand Down Expand Up @@ -183,7 +203,10 @@ export async function wipeWallet(): Promise<void> {
* Reconciles a partial-write state on startup so phase never lies:
* - blob without secret → clear the blob (unloadable → clean re-onboarding);
* - secret without blob → wipe the orphan secret.
* Idempotent; safe to call on every launch before routing.
* Idempotent; safe to call on every launch before routing. Besides cold start
* it is re-run (via `hydrate`) by the A3 key-invalidated recovery restart —
* everything is already wiped by then, so it reduces to a probe that also
* retries a failed secret wipe.
*/
export async function reconcile(): Promise<void> {
const blobPresent = hasWalletRecord();
Expand Down
6 changes: 6 additions & 0 deletions src/lib/keystoreSecret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ function mapKeychainError(error: unknown): KeystoreSecretException {
* Persists a keystore secret (64 lowercase-hex) to the Keychain, replacing any
* existing entry for this service. The caller (deviceWallet) generates the secret
* so it can encrypt the keyring with the same value before storing it here.
*
* May trigger the OS auth prompt too: the library's auth-required key is
* timeout-based (5s validity after the last authentication,
* `CipherStorageKeystoreAesGcm.kt:167-179`), and BOTH encrypt and decrypt
* need authorization outside that window — "prompt only on retrieve" was
* never the real contract (carry-over #20, resolved at the A3 grill).
*/
export async function saveKeystoreSecret(secretHex: string): Promise<void> {
const stored = await Keychain.setGenericPassword(ACCOUNT, secretHex, SET_OPTIONS).catch(
Expand Down
10 changes: 2 additions & 8 deletions src/navigation/RootNavigator.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assertNever } from '../lib/assertNever';
import { PlaceholderScreen } from '../screens/PlaceholderScreen';
import { SplashScreen } from '../screens/SplashScreen';
import { UnlockPin } from '../screens/locked/UnlockPin';
import { useWalletStore } from '../stores/walletStore';
import { OnboardingNavigator } from './OnboardingNavigator';

Expand All @@ -13,21 +14,14 @@ import { OnboardingNavigator } from './OnboardingNavigator';
*/
export function RootNavigator() {
const phase = useWalletStore((state) => state.phase);
const address = useWalletStore((state) => state.address);

switch (phase) {
case 'loading':
return <SplashScreen />;
case 'no_wallet':
return <OnboardingNavigator />;
case 'locked':
return (
<PlaceholderScreen
title="Wallet locked"
subtitle="Unlock with PIN or biometrics (A3)."
detail={address ?? undefined}
/>
);
return <UnlockPin />;
case 'unlocked':
return <PlaceholderScreen title="Home" subtitle="Wallet tabs (A2+)." />;
default:
Expand Down
8 changes: 5 additions & 3 deletions src/navigation/__tests__/RootNavigator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ describe('RootNavigator', () => {
expect(renderAt('no_wallet')).toContain('RUST');
});

it('renders the locked destination carrying the stored address', () => {
it('renders the UnlockPin screen for the locked phase (A3 — no placeholder, no address)', () => {
const view = renderAt('locked', '0xABCDEF');
expect(view).toContain('Wallet locked');
expect(view).toContain('0xABCDEF');
expect(view).toContain('LOCKED');
expect(view).toContain('Enter your PIN');
// The flipbook frame shows no address on the lock screen.
expect(view).not.toContain('0xABCDEF');
});

it('renders the home destination when unlocked', () => {
Expand Down
Loading