From 9b80df9ca0608d4e7e886248548d43e5ea6dd869 Mon Sep 17 00:00:00 2001 From: Pitrick3141 Date: Fri, 14 Aug 2026 14:43:46 +0800 Subject: [PATCH] ui(wallet): Update wallet layout --- apps/reference-rp/src/backend/lifecycle.ts | 24 +++- apps/reference-rp/src/backend/repository.ts | 38 ++++--- .../reference-rp/worker/reference-rp-state.ts | 91 +++++++++------- .../worker/reference-rp.worker.test.ts | 11 ++ apps/reference-rp/worker/vitest.config.ts | 15 ++- apps/wallet/src/App.tsx | 103 +++++++++++++++--- packages/verifier/src/index.ts | 1 + packages/verifier/src/orchestrate.ts | 31 ++---- packages/verifier/src/ownership-v2.test.ts | 47 ++++++-- packages/verifier/src/types.ts | 6 + tests/browser-e2e/security.spec.ts | 15 ++- .../test/identity-state.worker.test.ts | 87 ++++----------- 12 files changed, 292 insertions(+), 177 deletions(-) diff --git a/apps/reference-rp/src/backend/lifecycle.ts b/apps/reference-rp/src/backend/lifecycle.ts index d7d0ecc..cac4513 100644 --- a/apps/reference-rp/src/backend/lifecycle.ts +++ b/apps/reference-rp/src/backend/lifecycle.ts @@ -187,7 +187,10 @@ export class AuthoritativeRegistryLifecycleProvider */ export class LocalLifecycleAuthority implements LifecycleProvider, DeviceLifecycleProvider { readonly #states = new Map(); - readonly #deviceStates = new Map(); + readonly #deviceStates = new Map< + string, + Exclude + >(); public constructor(private readonly now: () => number = () => Math.floor(Date.now() / 1000)) {} @@ -220,20 +223,29 @@ export class LocalLifecycleAuthority implements LifecycleProvider, DeviceLifecyc } public getAuthoritativeDeviceStatus( - _subject: NexusSubject, + subject: NexusSubject, deviceId: NexusDeviceIdV2, authorizationId: NexusDeviceAuthorizationIdV2, ): Promise { - return Promise.resolve( - this.#deviceStates.get(deviceId) ?? { + const device = + this.#deviceStates.get(deviceId) ?? + ({ state: 'active', identityState: 'active', identitySequence: 0, deviceId, authorizationId, deviceLedgerSequence: 0, - }, - ); + } satisfies Exclude); + const identity = this.#states.get(subject); + if (identity?.state !== 'revoked') return Promise.resolve(device); + return Promise.resolve({ + ...device, + state: 'revoked', + identityState: 'revoked', + identitySequence: identity.sequence, + ...(identity.revokedAt === undefined ? {} : { revokedAt: identity.revokedAt }), + }); } public setDevice( diff --git a/apps/reference-rp/src/backend/repository.ts b/apps/reference-rp/src/backend/repository.ts index ba07e54..2273705 100644 --- a/apps/reference-rp/src/backend/repository.ts +++ b/apps/reference-rp/src/backend/repository.ts @@ -280,6 +280,7 @@ export class ReferenceRpRepository { authorizationId: NexusDeviceAuthorizationIdV2; } | undefined; + let identitySequence: number; if (input.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) { const expectedV2: VerificationExpectationV2 = { ...expected, @@ -289,10 +290,10 @@ export class ReferenceRpRepository { input.proof, expectedV2, this.challenges, - this.lifecycle, this.#requireDeviceLifecycle(), ); subject = verified.subject; + identitySequence = verified.identitySequence; deviceBinding = { proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, deviceId: verified.deviceId, @@ -306,6 +307,11 @@ export class ReferenceRpRepository { this.lifecycle, ); subject = verified.subject; + const lifecycle = await this.lifecycle.getAuthoritativeStatus(subject); + if (lifecycle.state !== 'active') { + throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401); + } + identitySequence = lifecycle.sequence; } const proofHash = canonicalSha256(input.proof); const token = secureToken(32); @@ -317,11 +323,6 @@ export class ReferenceRpRepository { ...(deviceBinding ?? {}), }; this.#sessions.set(stored.tokenHash, stored); - const lifecycle = await this.lifecycle.getAuthoritativeStatus(subject); - if (lifecycle.state !== 'active') { - this.#sessions.delete(stored.tokenHash); - throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401); - } const receipt = this.#recordReceipt({ operation: 'session.start', resource: SESSION_RESOURCE, @@ -338,7 +339,7 @@ export class ReferenceRpRepository { subject, friendlyName: this.#profiles.get(subject)?.friendlyName ?? null, state: 'active', - sequence: lifecycle.sequence, + sequence: identitySequence, expiresAt: stored.expiresAt, checkedAt: this.#now(), ...(stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 @@ -362,15 +363,11 @@ export class ReferenceRpRepository { if (stored === undefined || stored.expiresAt <= now) { throw new RpError('SESSION_INVALID', 'The RP session has expired.', 401); } - const lifecycle = await this.lifecycle.getAuthoritativeStatus(stored.subject); - if (lifecycle.state !== 'active') { - throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401); - } - if ( - stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 && - stored.deviceId !== undefined && - stored.authorizationId !== undefined - ) { + let identitySequence: number; + if (stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) { + if (stored.deviceId === undefined || stored.authorizationId === undefined) { + throw new RpError('SESSION_INVALID', 'The RP session device binding is invalid.', 401); + } const device = await this.#requireDeviceLifecycle().getAuthoritativeDeviceStatus( stored.subject, stored.deviceId, @@ -383,6 +380,13 @@ export class ReferenceRpRepository { ) { throw new RpError('SESSION_INVALID', 'The Nexus device is no longer active.', 401); } + identitySequence = device.identitySequence; + } else { + const lifecycle = await this.lifecycle.getAuthoritativeStatus(stored.subject); + if (lifecycle.state !== 'active') { + throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401); + } + identitySequence = lifecycle.sequence; } return { stored, @@ -390,7 +394,7 @@ export class ReferenceRpRepository { subject: stored.subject, friendlyName: this.#profiles.get(stored.subject)?.friendlyName ?? null, state: 'active', - sequence: lifecycle.sequence, + sequence: identitySequence, expiresAt: stored.expiresAt, checkedAt: now, ...(stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 diff --git a/apps/reference-rp/worker/reference-rp-state.ts b/apps/reference-rp/worker/reference-rp-state.ts index 850d5f6..13ed3e6 100644 --- a/apps/reference-rp/worker/reference-rp-state.ts +++ b/apps/reference-rp/worker/reference-rp-state.ts @@ -314,6 +314,7 @@ export class ReferenceRpState extends DurableObject { maxClockSkewSeconds: MAX_CLOCK_SKEW_SECONDS, }; let normalizedVerified: VerifiedSessionSigner; + let identitySequence: number; if (input.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) { const expectedV2: VerificationExpectationV2 = { ...expected, @@ -326,46 +327,49 @@ export class ReferenceRpState extends DurableObject { deviceId: verified.deviceId, authorizationId: verified.authorizationId, }; + const device = await getAuthoritativeDeviceStatus( + this.env, + verified.subject, + verified.deviceId, + verified.authorizationId, + ); + if (device.identityState !== 'active') { + throw new RpWorkerError( + 'IDENTITY_REVOKED', + 'This Nexus identity is revoked and cannot change notes.', + 403, + ); + } + if (device.deviceState !== 'active') { + throw new RpWorkerError( + 'DEVICE_INACTIVE', + 'This Nexus device is not active and cannot start a session.', + 403, + ); + } + identitySequence = device.identitySequence; } else { const verified = await verifyOwnershipProof(input.proof, expected); normalizedVerified = { subject: verified.subject, proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1, }; - } - const lifecycle = await getAuthoritativeLifecycle(this.env, normalizedVerified.subject); - if (lifecycle.state === 'not-found') { - throw new RpWorkerError( - 'IDENTITY_NOT_FOUND', - 'This Nexus identity is not active in the authoritative registry.', - 403, - ); - } - if (lifecycle.state !== 'active') { - throw new RpWorkerError( - 'IDENTITY_REVOKED', - 'This Nexus identity is revoked and cannot change notes.', - 403, - ); - } - if ( - normalizedVerified.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 && - normalizedVerified.deviceId !== undefined && - normalizedVerified.authorizationId !== undefined - ) { - const device = await getAuthoritativeDeviceStatus( - this.env, - normalizedVerified.subject, - normalizedVerified.deviceId, - normalizedVerified.authorizationId, - ); - if (device.identityState !== 'active' || device.deviceState !== 'active') { + const lifecycle = await getAuthoritativeLifecycle(this.env, verified.subject); + if (lifecycle.state === 'not-found') { throw new RpWorkerError( - 'DEVICE_INACTIVE', - 'This Nexus device is not active and cannot start a session.', + 'IDENTITY_NOT_FOUND', + 'This Nexus identity is not active in the authoritative registry.', 403, ); } + if (lifecycle.state !== 'active') { + throw new RpWorkerError( + 'IDENTITY_REVOKED', + 'This Nexus identity is revoked and cannot change notes.', + 403, + ); + } + identitySequence = lifecycle.sequence; } const proofHash = await canonicalSha256(input.proof); @@ -387,7 +391,7 @@ export class ReferenceRpState extends DurableObject { token, tokenHash, receiptId, - sequence: lifecycle.sequence, + sequence: identitySequence, }); } @@ -503,13 +507,6 @@ export class ReferenceRpState extends DurableObject { throw new RpWorkerError('SESSION_INVALID', 'The RP session has expired.', 401); } const subject = row.subject as NexusSubject; - const lifecycle = await getAuthoritativeLifecycle(this.env, subject); - if (lifecycle.state === 'not-found') { - throw new RpWorkerError('SESSION_INVALID', 'The identity is no longer registered.', 401); - } - if (lifecycle.state !== 'active') { - throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401); - } const v2Session = row.proof_protocol === OWNERSHIP_PROOF_PROTOCOL_V2; if ( v2Session && @@ -520,6 +517,7 @@ export class ReferenceRpState extends DurableObject { ) { throw new RpWorkerError('SESSION_INVALID', 'The RP session device binding is invalid.', 401); } + let identitySequence: number; if (v2Session && row.device_id !== null && row.authorization_id !== null) { const deviceId = row.device_id as NexusDeviceIdV2; const authorizationId = row.authorization_id as NexusDeviceAuthorizationIdV2; @@ -529,9 +527,22 @@ export class ReferenceRpState extends DurableObject { deviceId, authorizationId, ); - if (device.identityState !== 'active' || device.deviceState !== 'active') { + if (device.identityState !== 'active') { + throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401); + } + if (device.deviceState !== 'active') { throw new RpWorkerError('SESSION_INVALID', 'The Nexus device is no longer active.', 401); } + identitySequence = device.identitySequence; + } else { + const lifecycle = await getAuthoritativeLifecycle(this.env, subject); + if (lifecycle.state === 'not-found') { + throw new RpWorkerError('SESSION_INVALID', 'The identity is no longer registered.', 401); + } + if (lifecycle.state !== 'active') { + throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401); + } + identitySequence = lifecycle.sequence; } return { tokenHash, @@ -540,7 +551,7 @@ export class ReferenceRpState extends DurableObject { subject, friendlyName: this.#friendlyName(subject), state: 'active', - sequence: lifecycle.sequence, + sequence: identitySequence, expiresAt: row.expires_at, checkedAt: now, ...(v2Session diff --git a/apps/reference-rp/worker/reference-rp.worker.test.ts b/apps/reference-rp/worker/reference-rp.worker.test.ts index fd81a0f..e412928 100644 --- a/apps/reference-rp/worker/reference-rp.worker.test.ts +++ b/apps/reference-rp/worker/reference-rp.worker.test.ts @@ -267,6 +267,7 @@ describe('deployed reference RP Worker', () => { deviceId: device.deviceId, authorizationId: device.authorizationId, }); + await expect(lifecycleRequestCounts()).resolves.toEqual({ identity: 0, device: 1 }); const cookie = cookiePair(response.headers.get('Set-Cookie')); const stored = await runInDurableObject( env.RP_STATE.getByName('reference-rp-primary'), @@ -291,6 +292,7 @@ describe('deployed reference RP Worker', () => { friendlyName: 'revoked_device', }); expect(denied.status).toBe(401); + await expect(lifecycleRequestCounts()).resolves.toEqual({ identity: 0, device: 2 }); }); it.each(['stale', 'forged', 'wrong-tuple'] as const)( @@ -317,6 +319,7 @@ describe('deployed reference RP Worker', () => { friendlyName: 'revoked_identity', }); expect(denied.status).toBe(401); + await expect(lifecycleRequestCounts()).resolves.toEqual({ identity: 0, device: 2 }); }); it('rejects a v2 session at the exact device authorization expiry second', async () => { @@ -617,6 +620,14 @@ async function lifecycleControl( expect(response.ok).toBe(true); } +async function lifecycleRequestCounts(): Promise<{ identity: number; device: number }> { + const response = await env.TEST_LIFECYCLE.fetch( + 'https://lifecycle.test/__test/status-request-counts', + ); + expect(response.ok).toBe(true); + return await response.json(); +} + async function createRegisteredDevice(identity: TestIdentity): Promise { const pair = await provider.generateEd25519KeyPair({ privateKeyExtractable: true }); const publicKey = await provider.exportEd25519PublicKey(pair.publicKey); diff --git a/apps/reference-rp/worker/vitest.config.ts b/apps/reference-rp/worker/vitest.config.ts index 9b82731..8ffbe23 100644 --- a/apps/reference-rp/worker/vitest.config.ts +++ b/apps/reference-rp/worker/vitest.config.ts @@ -6,6 +6,8 @@ const lifecycleService = ` const signature = 'A'.repeat(86); const statusPrivateKey = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; // gitleaks:allow -- fixed test-only Ed25519 fixture let deviceStatusMode = 'fresh'; + let identityStatusRequests = 0; + let deviceStatusRequests = 0; const decode = (value) => Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - value.length % 4) % 4)), (character) => character.charCodeAt(0)); const encode = (value) => btoa(String.fromCharCode(...value)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, ''); @@ -29,8 +31,13 @@ const lifecycleService = ` identities.clear(); devices.clear(); deviceStatusMode = 'fresh'; + identityStatusRequests = 0; + deviceStatusRequests = 0; return Response.json({ ok: true }); } + if (url.pathname === '/__test/status-request-counts') { + return Response.json({ identity: identityStatusRequests, device: deviceStatusRequests }); + } if (url.pathname === '/__test/register') { identities.set(body.subject, { genesis: body.genesis, state: 'active' }); return Response.json({ ok: true }); @@ -54,6 +61,7 @@ const lifecycleService = ` return Response.json({ ok: true }); } if (url.pathname === '/v2/device/status') { + deviceStatusRequests += 1; const device = devices.get(body.deviceId); if (!device || device.subject !== body.subject || device.authorizationId !== body.authorizationId) { return Response.json({ error: { code: 'DEVICE_NOT_FOUND', message: 'Not found.' } }, { status: 404 }); @@ -61,7 +69,11 @@ const lifecycleService = ` const now = Math.floor(Date.now() / 1000); const identity = identities.get(body.subject); const identityState = identity?.state === 'active' ? 'active' : 'revoked'; - const deviceState = deviceStatusMode === 'expiry-boundary' ? 'expired' : device.state; + const deviceState = identityState === 'revoked' + ? 'revoked' + : deviceStatusMode === 'expiry-boundary' + ? 'expired' + : device.state; const statementAuthorizationId = deviceStatusMode === 'wrong-tuple' ? 'nxa2_' + 'A'.repeat(43) : body.authorizationId; const issuedAt = deviceStatusMode === 'stale' ? now - 120 : now; const authorizationExpiresAt = deviceStatusMode === 'expiry-boundary' || deviceStatusMode === 'active-expiry-boundary' ? now : device.authorizationExpiresAt; @@ -102,6 +114,7 @@ const lifecycleService = ` }); } if (url.pathname !== '/v1/identity/status') return new Response('Not Found', { status: 404 }); + identityStatusRequests += 1; const identity = identities.get(body.subject); if (!identity) return Response.json({ error: { code: 'IDENTITY_NOT_FOUND', message: 'Not found.' } }, { status: 404 }); const now = Math.floor(Date.now() / 1000); diff --git a/apps/wallet/src/App.tsx b/apps/wallet/src/App.tsx index 12a8537..4e16ba8 100644 --- a/apps/wallet/src/App.tsx +++ b/apps/wallet/src/App.tsx @@ -133,6 +133,8 @@ function App() { () => identities.find((identity) => identity.localId === selectedId), [identities, selectedId], ); + const rootKeys = identities.filter((identity) => identity.device === undefined); + const deviceKeys = identities.filter((identity) => identity.device !== undefined); const activeCount = identities.filter((identity) => identity.localState === 'active').length; const scopedCount = identities.filter((identity) => identity.localScopes.length > 0).length; @@ -562,21 +564,33 @@ function App() { ) : ( -
- {identities.map((identity) => ( - - setSelectedId((current) => - current === identity.localId ? undefined : identity.localId, - ) - } - onRetryRegistration={() => void retryRegistration(identity)} - /> - ))} +
+ + setSelectedId((current) => (current === localId ? undefined : localId)) + } + onRetryRegistration={(identity) => void retryRegistration(identity)} + /> + + setSelectedId((current) => (current === localId ? undefined : localId)) + } + onRetryRegistration={(identity) => void retryRegistration(identity)} + />
)} @@ -697,6 +711,65 @@ function App() { ); } +interface KeySectionProps { + id: string; + title: string; + description: string; + emptyMessage: string; + identities: readonly LocalIdentitySummary[]; + selectedId: string | undefined; + retryingRegistrationId: string | undefined; + onSelect: (localId: string) => void; + onRetryRegistration: (identity: LocalIdentitySummary) => void; +} + +function KeySection({ + id, + title, + description, + emptyMessage, + identities, + selectedId, + retryingRegistrationId, + onSelect, + onRetryRegistration, +}: KeySectionProps) { + return ( +
+
+
+

+ {title} +

+

{description}

+
+ + {String(identities.length)} {identities.length === 1 ? 'key' : 'keys'} + +
+ + {identities.length === 0 ? ( +

+ {emptyMessage} +

+ ) : ( +
+ {identities.map((identity) => ( + onSelect(identity.localId)} + onRetryRegistration={() => onRetryRegistration(identity)} + /> + ))} +
+ )} +
+ ); +} + function Assurance({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) { return (
diff --git a/packages/verifier/src/index.ts b/packages/verifier/src/index.ts index 9cf4392..dc20296 100644 --- a/packages/verifier/src/index.ts +++ b/packages/verifier/src/index.ts @@ -50,4 +50,5 @@ export type { TransparencyCheckpointExpectation, TransparencyFreshnessExpectation, VerifiedDeviceSubject, + VerifiedRpDeviceOperation, } from './types.js'; diff --git a/packages/verifier/src/orchestrate.ts b/packages/verifier/src/orchestrate.ts index d40c962..d5aefe9 100644 --- a/packages/verifier/src/orchestrate.ts +++ b/packages/verifier/src/orchestrate.ts @@ -13,7 +13,7 @@ import type { ChallengeStore, DeviceLifecycleProvider, LifecycleProvider, - VerifiedDeviceSubject, + VerifiedRpDeviceOperation, } from './types.js'; /** @@ -62,9 +62,8 @@ async function verifyRpOperationV2Internal( proof: OwnershipProofV2, expected: VerificationExpectationV2, challengeStore: ChallengeStore, - lifecycle: LifecycleProvider, deviceLifecycle: DeviceLifecycleProvider, -): Promise { +): Promise { const verified = await verifyOwnershipProofV2(proof, expected); const challenge = await challengeStore.get(expected.nonce); @@ -79,19 +78,14 @@ async function verifyRpOperationV2Internal( throw verificationError('NONCE_REPLAY_OR_EXPIRED'); } - const identityStatus = await lifecycle.getAuthoritativeStatus(verified.subject); - if (identityStatus.state === 'not-found') throw verificationError('IDENTITY_NOT_FOUND'); - if (identityStatus.state !== 'active') throw verificationError('IDENTITY_REVOKED'); - const deviceStatus = await deviceLifecycle.getAuthoritativeDeviceStatus( verified.subject, verified.deviceId, verified.authorizationId, ); if (deviceStatus.state === 'not-found') throw verificationErrorV2('DEVICE_NOT_FOUND'); - // The device status response is a combined snapshot. Requiring its identity - // component prevents an independently fetched stale v1-active result from - // overriding a current terminal identity revocation. + // The device response is one authoritative combined snapshot. Its identity + // component makes a separate, independently raced v1 status lookup redundant. if (deviceStatus.identityState !== 'active') throw verificationError('IDENTITY_REVOKED'); if (deviceStatus.deviceId !== verified.deviceId) throw verificationErrorV2('WRONG_DEVICE'); if (deviceStatus.authorizationId !== verified.authorizationId) { @@ -108,24 +102,21 @@ async function verifyRpOperationV2Internal( const consumed = await challengeStore.consumeAtomically(expected.nonce); if (!consumed) throw verificationError('NONCE_REPLAY_OR_EXPIRED'); - return verified; + return { + ...verified, + identitySequence: deviceStatus.identitySequence, + deviceLedgerSequence: deviceStatus.deviceLedgerSequence, + }; } export async function verifyRpOperationV2( proof: OwnershipProofV2, expected: VerificationExpectationV2, challengeStore: ChallengeStore, - lifecycle: LifecycleProvider, deviceLifecycle: DeviceLifecycleProvider, -): Promise { +): Promise { try { - return await verifyRpOperationV2Internal( - proof, - expected, - challengeStore, - lifecycle, - deviceLifecycle, - ); + return await verifyRpOperationV2Internal(proof, expected, challengeStore, deviceLifecycle); } catch (error) { rethrowVerificationErrorV2(error); } diff --git a/packages/verifier/src/ownership-v2.test.ts b/packages/verifier/src/ownership-v2.test.ts index 1b134b0..a1cf3f1 100644 --- a/packages/verifier/src/ownership-v2.test.ts +++ b/packages/verifier/src/ownership-v2.test.ts @@ -49,7 +49,6 @@ import { import type { ChallengeStore, DeviceLifecycleProvider, - LifecycleProvider, NexusVerificationErrorCodeV2, } from './index.js'; @@ -441,13 +440,11 @@ describe('v2 RP orchestration', () => { return Promise.resolve(true); }, }; - const lifecycle: LifecycleProvider = { - getAuthoritativeStatus: () => - Promise.resolve({ state: 'active', sequence: 0, registeredAt: NOW - 100 }), - }; + const requestedStatuses: unknown[][] = []; const activeDevice: DeviceLifecycleProvider = { - getAuthoritativeDeviceStatus: () => - Promise.resolve({ + getAuthoritativeDeviceStatus: (subject, deviceId, authorizationId) => { + requestedStatuses.push([subject, deviceId, authorizationId]); + return Promise.resolve({ state: 'active', identityState: 'active', identitySequence: 0, @@ -456,14 +453,40 @@ describe('v2 RP orchestration', () => { deviceLedgerSequence: 1, activatedAt: NOW - 100, authorizationExpiresAt: NOW + 10_000, - }), + }); + }, }; await expect( - verifyRpOperationV2(proof, expectation(), store, lifecycle, activeDevice), - ).resolves.toMatchObject({ subject: identity.subject }); + verifyRpOperationV2(proof, expectation(), store, activeDevice), + ).resolves.toMatchObject({ + subject: identity.subject, + identitySequence: 0, + deviceLedgerSequence: 1, + }); + expect(requestedStatuses).toEqual([ + [identity.subject, identity.authorization.payload.deviceId, identity.authorizationId], + ]); expect(consumed).toBe(true); consumed = false; + const revokedIdentity: DeviceLifecycleProvider = { + getAuthoritativeDeviceStatus: () => + Promise.resolve({ + state: 'revoked', + identityState: 'revoked', + identitySequence: 1, + deviceId: identity.authorization.payload.deviceId, + authorizationId: identity.authorizationId, + deviceLedgerSequence: 1, + revokedAt: NOW - 1, + }), + }; + await expectCode( + verifyRpOperationV2(proof, expectation(), store, revokedIdentity), + 'IDENTITY_REVOKED', + ); + expect(consumed).toBe(false); + const revokedDevice: DeviceLifecycleProvider = { getAuthoritativeDeviceStatus: () => Promise.resolve({ @@ -477,7 +500,7 @@ describe('v2 RP orchestration', () => { }), }; await expectCode( - verifyRpOperationV2(proof, expectation(), store, lifecycle, revokedDevice), + verifyRpOperationV2(proof, expectation(), store, revokedDevice), 'DEVICE_REVOKED', ); expect(consumed).toBe(false); @@ -496,7 +519,7 @@ describe('v2 RP orchestration', () => { }), }; await expectCode( - verifyRpOperationV2(proof, expectation(), store, lifecycle, expiredAtBoundary), + verifyRpOperationV2(proof, expectation(), store, expiredAtBoundary), 'DEVICE_EXPIRED', ); expect(consumed).toBe(false); diff --git a/packages/verifier/src/types.ts b/packages/verifier/src/types.ts index b68fdb3..b5a044d 100644 --- a/packages/verifier/src/types.ts +++ b/packages/verifier/src/types.ts @@ -111,6 +111,12 @@ export interface VerifiedDeviceSubject { authorizationId: DeviceAuthorizationIdV2; } +/** A verified v2 proof plus the authoritative combined lifecycle snapshot used to accept it. */ +export interface VerifiedRpDeviceOperation extends VerifiedDeviceSubject { + identitySequence: number; + deviceLedgerSequence: number; +} + export interface OwnershipProofAnyOptions { acceptedProtocols: readonly ( typeof OWNERSHIP_PROOF_PROTOCOL_V1 | typeof OWNERSHIP_PROOF_PROTOCOL_V2 diff --git a/tests/browser-e2e/security.spec.ts b/tests/browser-e2e/security.spec.ts index 7e14276..7064b39 100644 --- a/tests/browser-e2e/security.spec.ts +++ b/tests/browser-e2e/security.spec.ts @@ -197,13 +197,22 @@ test.describe.serial('Nexus browser security boundary', () => { await expect( wallet.getByText('Identity created. Its separate device key is active on this device.'), ).toBeVisible(); + + const rootKeys = wallet.getByRole('region', { name: 'Root keys' }); + const deviceKeys = wallet.getByRole('region', { name: 'Device keys' }); await expect( - wallet.getByRole('heading', { name: 'E2E local device root · this device' }).first(), + deviceKeys.getByRole('heading', { name: 'E2E local device root · this device' }), ).toBeVisible(); - await expect(wallet.getByText('Active device', { exact: true })).toBeVisible(); + await expect(deviceKeys.getByText('Active device', { exact: true })).toBeVisible(); await expect( - wallet.getByRole('heading', { name: 'E2E local device root', exact: true }), + rootKeys.getByRole('heading', { name: 'E2E local device root', exact: true }), ).toBeVisible(); + await expect( + rootKeys.getByRole('heading', { name: 'E2E local device root · this device' }), + ).toHaveCount(0); + await expect( + deviceKeys.getByRole('heading', { name: 'E2E local device root', exact: true }), + ).toHaveCount(0); } finally { await localDeviceContext.close(); } diff --git a/workers/registry/test/identity-state.worker.test.ts b/workers/registry/test/identity-state.worker.test.ts index 0ca5512..4c8e68a 100644 --- a/workers/registry/test/identity-state.worker.test.ts +++ b/workers/registry/test/identity-state.worker.test.ts @@ -30,7 +30,7 @@ import { signProtocolPayload, } from '@nexus/crypto'; import { verifyRpOperationV2 } from '@nexus/verifier'; -import type { ChallengeStore, DeviceLifecycleProvider, LifecycleProvider } from '@nexus/verifier'; +import type { ChallengeStore, DeviceLifecycleProvider } from '@nexus/verifier'; import { createDeviceFixture, createDeviceRootRevocation, @@ -159,49 +159,28 @@ function challengeStore(expected: VerificationExpectationV2): ChallengeStore { }; } -function authoritativeProviders( +function authoritativeDeviceProvider( stub: DurableObjectStub, expectedSubject: NexusSubject, -): { lifecycle: LifecycleProvider; devices: DeviceLifecycleProvider } { +): DeviceLifecycleProvider { return { - lifecycle: { - async getAuthoritativeStatus(subject) { - if (subject !== expectedSubject) return { state: 'not-found' }; - const result = await stub.status(); - if (!result.ok) return { state: 'not-found' }; - return result.value.state === 'active' - ? { - state: 'active', - sequence: result.value.sequence, - registeredAt: result.value.registeredAt, - } - : { - state: 'revoked', - sequence: result.value.sequence, - registeredAt: result.value.registeredAt, - ...(result.value.revokedAt === null ? {} : { revokedAt: result.value.revokedAt }), - }; - }, - }, - devices: { - async getAuthoritativeDeviceStatus(subject, deviceId, authorizationId) { - if (subject !== expectedSubject) return { state: 'not-found' }; - const result = await stub.deviceStatus({ deviceId, authorizationId }); - if (!result.ok || result.value.deviceState === 'unknown') return { state: 'not-found' }; - return { - state: result.value.deviceState, - identityState: result.value.identityState, - identitySequence: result.value.identitySequence, - deviceId: result.value.deviceId, - authorizationId: result.value.authorizationId, - deviceLedgerSequence: result.value.deviceLedgerSequence, - ...(result.value.activatedAt === null ? {} : { activatedAt: result.value.activatedAt }), - ...(result.value.revokedAt === null ? {} : { revokedAt: result.value.revokedAt }), - ...(result.value.authorizationExpiresAt === null - ? {} - : { authorizationExpiresAt: result.value.authorizationExpiresAt }), - }; - }, + async getAuthoritativeDeviceStatus(subject, deviceId, authorizationId) { + if (subject !== expectedSubject) return { state: 'not-found' }; + const result = await stub.deviceStatus({ deviceId, authorizationId }); + if (!result.ok || result.value.deviceState === 'unknown') return { state: 'not-found' }; + return { + state: result.value.deviceState, + identityState: result.value.identityState, + identitySequence: result.value.identitySequence, + deviceId: result.value.deviceId, + authorizationId: result.value.authorizationId, + deviceLedgerSequence: result.value.deviceLedgerSequence, + ...(result.value.activatedAt === null ? {} : { activatedAt: result.value.activatedAt }), + ...(result.value.revokedAt === null ? {} : { revokedAt: result.value.revokedAt }), + ...(result.value.authorizationExpiresAt === null + ? {} + : { authorizationExpiresAt: result.value.authorizationExpiresAt }), + }; }, }; } @@ -1127,19 +1106,13 @@ describe('IdentityState SQLite Durable Object', () => { }), ).resolves.toMatchObject({ ok: true, value: { deviceState: 'active' } }); - const providers = authoritativeProviders(stub, fixture.prepared.subject); + const devices = authoritativeDeviceProvider(stub, fixture.prepared.subject); for (const logicalInstall of ['A1', 'A2'] as const) { const nonce = freshNonce(); const expected = proofExpectation(nonce); const proof = await createOwnershipProof(fixture, deviceA, nonce); await expect( - verifyRpOperationV2( - proof, - expected, - challengeStore(expected), - providers.lifecycle, - providers.devices, - ), + verifyRpOperationV2(proof, expected, challengeStore(expected), devices), logicalInstall, ).resolves.toMatchObject({ subject: fixture.prepared.subject, @@ -1173,13 +1146,7 @@ describe('IdentityState SQLite Durable Object', () => { const expected = proofExpectation(nonce); const proof = await createOwnershipProof(fixture, deviceA, nonce); await expect( - verifyRpOperationV2( - proof, - expected, - challengeStore(expected), - providers.lifecycle, - providers.devices, - ), + verifyRpOperationV2(proof, expected, challengeStore(expected), devices), logicalInstall, ).rejects.toMatchObject({ code: 'IDENTITY_REVOKED', @@ -1191,13 +1158,7 @@ describe('IdentityState SQLite Durable Object', () => { const expectedB = proofExpectation(nonceB); const proofB = await createOwnershipProof(fixture, deviceB, nonceB); await expect( - verifyRpOperationV2( - proofB, - expectedB, - challengeStore(expectedB), - providers.lifecycle, - providers.devices, - ), + verifyRpOperationV2(proofB, expectedB, challengeStore(expectedB), devices), ).resolves.toMatchObject({ subject: fixture.prepared.subject, deviceId: deviceB.authorization.payload.deviceId,