From 3d67a610ec0465d4e72759e930c929a1542947f7 Mon Sep 17 00:00:00 2001 From: Pitrick3141 Date: Wed, 12 Aug 2026 15:56:36 +0800 Subject: [PATCH] feat(wallet): Add refresh function to poll device key status --- apps/wallet/src/App.tsx | 28 ++++ apps/wallet/src/components/IdentityDetail.tsx | 97 +++++++++-- apps/wallet/src/lib/device-management.ts | 2 + apps/wallet/src/lib/wallet-adapter.ts | 30 +++- docs/protocol/device-delegation-v2.md | 8 + packages/wallet-core/src/index.ts | 2 + packages/wallet-core/src/types.ts | 33 ++++ packages/wallet-core/src/wallet-core.ts | 150 ++++++++++++++++++ packages/wallet-core/test/device-v2.test.ts | 130 +++++++++++++++ tests/browser-e2e/security.spec.ts | 5 + 10 files changed, 470 insertions(+), 15 deletions(-) diff --git a/apps/wallet/src/App.tsx b/apps/wallet/src/App.tsx index 5e6b927..d153d43 100644 --- a/apps/wallet/src/App.tsx +++ b/apps/wallet/src/App.tsx @@ -79,6 +79,7 @@ function App() { const [retryingRegistrationId, setRetryingRegistrationId] = useState(); const [clearingHistoryId, setClearingHistoryId] = useState(); const [deviceActionId, setDeviceActionId] = useState(); + const [refreshingDeviceId, setRefreshingDeviceId] = useState(); const pendingRef = useRef(undefined); const refresh = useCallback(async () => { @@ -282,6 +283,31 @@ function App() { } }; + const refreshDeviceStatus = async ( + identity: LocalIdentitySummary, + deviceId?: LocalIdentitySummary['issuedDevices'][number]['deviceId'], + ) => { + const targetDeviceId = deviceId ?? identity.device?.deviceId; + if (targetDeviceId === undefined || refreshingDeviceId !== undefined) return; + setRefreshingDeviceId(targetDeviceId); + try { + const status = await walletAdapter.refreshDeviceStatus(identity.localId, deviceId); + await refresh(); + setNotice({ + kind: status.deviceState === 'active' ? 'success' : 'error', + message: `Registry device status: ${status.deviceState}.`, + }); + } catch (error) { + setNotice({ + kind: 'error', + message: + error instanceof Error ? error.message : 'The device status could not be refreshed.', + }); + } finally { + setRefreshingDeviceId(undefined); + } + }; + const approveProof = async ( localId: string, rememberScope: boolean, @@ -506,6 +532,8 @@ function App() { onRootRevokeDevice={(device) => setFlow({ type: 'root-revoke-device', identity: selectedIdentity, device }) } + onRefreshDevice={(deviceId) => refreshDeviceStatus(selectedIdentity, deviceId)} + {...(refreshingDeviceId === undefined ? {} : { refreshingDeviceId })} deviceActionBusy={deviceActionId !== undefined} /> diff --git a/apps/wallet/src/components/IdentityDetail.tsx b/apps/wallet/src/components/IdentityDetail.tsx index e566794..15a4a92 100644 --- a/apps/wallet/src/components/IdentityDetail.tsx +++ b/apps/wallet/src/components/IdentityDetail.tsx @@ -42,6 +42,10 @@ interface IdentityDetailProps { onActivateDevice: () => Promise; onSelfRevokeDevice: () => void; onRootRevokeDevice: (device: LocalIdentitySummary['issuedDevices'][number]) => void; + onRefreshDevice: ( + deviceId?: LocalIdentitySummary['issuedDevices'][number]['deviceId'], + ) => Promise; + refreshingDeviceId?: string; deviceActionBusy: boolean; } @@ -60,6 +64,8 @@ export function IdentityDetail({ onActivateDevice, onSelfRevokeDevice, onRootRevokeDevice, + onRefreshDevice, + refreshingDeviceId, deviceActionBusy, }: IdentityDetailProps) { const active = identity.localState === 'active'; @@ -71,6 +77,7 @@ export function IdentityDetail({ const [renameError, setRenameError] = useState(); const [now, setNow] = useState(() => Math.floor(Date.now() / 1_000)); const deviceCapabilities = deviceManagementCapabilities(identity, now); + const installedRegistryState = identity.device?.registryState; useEffect(() => { if (identity.device?.localState !== 'pending-activation') return; @@ -298,11 +305,17 @@ export function IdentityDetail({

- {identity.device.localState === 'pending-activation' - ? 'Pending activation' - : identity.device.localState === 'active' - ? 'Active delegated key' - : 'Revoked delegated key'} + {installedRegistryState === 'revoked' + ? 'Revoked delegated key' + : installedRegistryState === 'expired' + ? 'Expired delegated key' + : installedRegistryState === 'unknown' + ? 'Not active at registry' + : identity.device.localState === 'pending-activation' + ? 'Pending activation' + : identity.device.localState === 'active' + ? 'Active delegated key' + : 'Revoked delegated key'}

+ {identity.device.statusCheckedAt !== undefined && ( +

+ Registry checked{' '} + {new Date(identity.device.statusCheckedAt * 1_000).toLocaleString()} +

+ )}
+ {deviceCapabilities.activateDevice && (
Expires {new Date(device.expiresAt * 1_000).toLocaleString()}

+ {device.statusCheckedAt !== undefined && ( +

+ Registry checked{' '} + {new Date(device.statusCheckedAt * 1_000).toLocaleString()} +

+ )} - {device.localState !== 'revoked' && deviceCapabilities.rootRevokeDevice && ( +
- )} + {device.localState !== 'revoked' && + device.registryState !== 'revoked' && + deviceCapabilities.rootRevokeDevice && ( + + )} +
))} @@ -564,3 +631,5 @@ const smallPrimaryButton = 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-indigo-300'; const smallDangerButton = 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-700 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50'; +const smallSecondaryButton = + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50'; diff --git a/apps/wallet/src/lib/device-management.ts b/apps/wallet/src/lib/device-management.ts index 95bce66..a3d2a0c 100644 --- a/apps/wallet/src/lib/device-management.ts +++ b/apps/wallet/src/lib/device-management.ts @@ -34,6 +34,8 @@ export function deviceManagementCapabilities( activateDevice: locallyActive && identity.device.localState === 'pending-activation' && + identity.device.registryState !== 'revoked' && + identity.device.registryState !== 'expired' && now < identity.device.activationDeadline && now < identity.device.expiresAt, selfRevokeDevice: locallyActive && identity.device.localState !== 'revoked', diff --git a/apps/wallet/src/lib/wallet-adapter.ts b/apps/wallet/src/lib/wallet-adapter.ts index 711e134..fc6fa51 100644 --- a/apps/wallet/src/lib/wallet-adapter.ts +++ b/apps/wallet/src/lib/wallet-adapter.ts @@ -1,12 +1,15 @@ import { registryReceiptV1Schema, registryStatusV1Schema, + deviceRegistryStatusV2Schema, serviceKeySetSchema, type ContinuityLinkV1, type DeviceActivationRequestV2, type DeviceRegistryReceiptV2, + type DeviceRegistryStatusV2, type DeviceRootRevokeRequestV2, type DeviceSelfRevokeRequestV2, + type DeviceStatusRequestV2, type OwnershipProofV1, type OwnershipProofV2, type ProofRequest, @@ -15,7 +18,11 @@ import { type RevokeBySignatureRequestV1, type ServiceKeySet, } from '@nexus/protocol'; -import { verifyDeviceRegistryReceipt, verifyRegistryReceipt } from '@nexus/verifier'; +import { + verifyDeviceRegistryReceipt, + verifyDeviceStatusStatement, + verifyRegistryReceipt, +} from '@nexus/verifier'; import { IndexedDbIdentityStore, WalletCore, @@ -163,6 +170,10 @@ class HttpRegistryClient implements RegistryClient { return this.#deviceMutation('/v2/device/revoke-root', request); } + public async getDeviceStatus(request: DeviceStatusRequestV2): Promise { + return deviceRegistryStatusV2Schema.parse(await postRegistry('/v2/device/status', request)); + } + async #deviceMutation(path: string, request: unknown): Promise { const value = await postRegistry(path, request); if (!isRecord(value)) throw new Error('The device registry response is malformed.'); @@ -179,6 +190,16 @@ const walletCore = new WalletCore({ verifyRegistryReceipt(receipt, await loadServiceKeyset()), verifyDeviceRegistryReceipt: async (receipt) => verifyDeviceRegistryReceipt(receipt, await loadServiceKeyset()), + verifyDeviceRegistryStatus: async (status, expected) => { + const parsed = deviceRegistryStatusV2Schema.parse(status); + await verifyDeviceStatusStatement( + parsed.statusStatement, + await loadServiceKeyset(), + Math.floor(Date.now() / 1_000), + expected, + ); + return parsed; + }, }); export interface CreateIdentityInput { @@ -268,6 +289,13 @@ export const walletAdapter = { return walletCore.revokeDeviceRoot(rootLocalId, deviceId, { reasonCode: 'replaced' }); }, + refreshDeviceStatus( + localId: string, + deviceId?: LocalIdentitySummary['issuedDevices'][number]['deviceId'], + ): Promise { + return walletCore.refreshDeviceStatus(localId, deviceId); + }, + dispose(localId: string): Promise { return walletCore.dispose(localId, { method: 'signature', diff --git a/docs/protocol/device-delegation-v2.md b/docs/protocol/device-delegation-v2.md index f0e0d68..cdeb3ef 100644 --- a/docs/protocol/device-delegation-v2.md +++ b/docs/protocol/device-delegation-v2.md @@ -362,6 +362,14 @@ reports at least the subject, genesis hash, identity state and sequence, device- device and authorization IDs, effective device state, authorization expiry, statement validity, and service signing `kid`. +The wallet permits the holder to poll this exact status tuple manually for an installed device or +for a device in the root wallet's local authorization catalog. It MUST validate the response schema, +service signature, freshness, and exact subject, genesis, device, and authorization binding before +recording the observation. An observed revoked, expired, or unknown state disables device proofs +locally. Polling alone MUST NOT delete a device key or mark receipt-gated local revocation complete; +key deletion still requires a verified terminal registry receipt. The reference wallet records the +last checked time and does not require a background service or push channel. + Recommended additive endpoints are: ```text diff --git a/packages/wallet-core/src/index.ts b/packages/wallet-core/src/index.ts index 4258859..74a21dd 100644 --- a/packages/wallet-core/src/index.ts +++ b/packages/wallet-core/src/index.ts @@ -24,6 +24,8 @@ export type { CreatedLocalIdentity, CreateIdentityOptions, DeviceRegistryReceiptVerifier, + DeviceRegistryStatusExpectation, + DeviceRegistryStatusVerifier, DeviceRequestOptions, DeviceRevocationOptions, DeviceTransferEnvelopeV2, diff --git a/packages/wallet-core/src/types.ts b/packages/wallet-core/src/types.ts index 82109a6..eae7dda 100644 --- a/packages/wallet-core/src/types.ts +++ b/packages/wallet-core/src/types.ts @@ -1,10 +1,14 @@ import type { + Base64Url32, ContinuityLinkV1, DeviceActivationRequestV2, DeviceAuthorizationV2, DeviceRegistryReceiptV2, + DeviceRegistryStatusV2, DeviceRootRevokeRequestV2, DeviceSelfRevokeRequestV2, + DeviceStatusRequestV2, + DeviceStatusStateV2, DeviceRevocationReasonCode, IdentityGenesisV1, NexusDeviceAuthorizationIdV2, @@ -81,6 +85,10 @@ export interface LocalDeviceRecordV2 { importStartedAt?: number; activationReceipt?: DeviceRegistryReceiptV2; revocationReceipt?: DeviceRegistryReceiptV2; + /** Latest manually verified registry observation; absence means this wallet has not polled. */ + registryState?: DeviceStatusStateV2; + statusCheckedAt?: number; + registryRevokedAt?: number; } export interface LocalIssuedDeviceRecordV2 { @@ -91,6 +99,10 @@ export interface LocalIssuedDeviceRecordV2 { label?: string; localState: 'issued' | 'revoked'; revocationReceipt?: DeviceRegistryReceiptV2; + /** Latest manually verified registry observation; absence means this wallet has not polled. */ + registryState?: DeviceStatusStateV2; + statusCheckedAt?: number; + registryRevokedAt?: number; } export interface AuthorizationHistoryEntry { @@ -140,6 +152,7 @@ export interface RegistryClient { activateDevice?(request: DeviceActivationRequestV2): Promise; revokeDeviceSelf?(request: DeviceSelfRevokeRequestV2): Promise; revokeDeviceRoot?(request: DeviceRootRevokeRequestV2): Promise; + getDeviceStatus?(request: DeviceStatusRequestV2): Promise; } /** @@ -151,6 +164,19 @@ export type RegistryReceiptVerifier = (receipt: unknown) => Promise Promise; +export interface DeviceRegistryStatusExpectation { + subject: NexusSubject; + genesisHash: Base64Url32; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; +} + +/** Strict-parses and verifies a service-signed v2 device status response and its exact tuple. */ +export type DeviceRegistryStatusVerifier = ( + status: unknown, + expected: DeviceRegistryStatusExpectation, +) => Promise; + export interface Clock { now(): number; } @@ -190,6 +216,9 @@ export interface LocalIdentitySummary { localState: LocalDeviceRecordV2['localState']; activationDeadline: number; expiresAt: number; + registryState?: DeviceStatusStateV2; + statusCheckedAt?: number; + registryRevokedAt?: number; }; issuedDevices: readonly { deviceId: NexusDeviceIdV2; @@ -199,6 +228,9 @@ export interface LocalIdentitySummary { expiresAt: number; label?: string; localState: LocalIssuedDeviceRecordV2['localState']; + registryState?: DeviceStatusStateV2; + statusCheckedAt?: number; + registryRevokedAt?: number; }[]; } @@ -338,4 +370,5 @@ export interface WalletCoreApi { deviceId: NexusDeviceIdV2, options?: DeviceRevocationOptions, ): Promise; + refreshDeviceStatus(localId: string, deviceId?: NexusDeviceIdV2): Promise; } diff --git a/packages/wallet-core/src/wallet-core.ts b/packages/wallet-core/src/wallet-core.ts index 41cd638..311ba15 100644 --- a/packages/wallet-core/src/wallet-core.ts +++ b/packages/wallet-core/src/wallet-core.ts @@ -17,6 +17,7 @@ import { deviceAuthorizationPayloadV2Schema, deviceAuthorizationV2Schema, deviceRegistryReceiptV2Schema, + deviceRegistryStatusV2Schema, deviceRootRevokePayloadV2Schema, deviceSelfRevokePayloadV2Schema, continuityLinkPayloadV1Schema, @@ -37,6 +38,7 @@ import { type DeviceAuthorizationPayloadV2, type DeviceAuthorizationV2, type DeviceRegistryReceiptV2, + type DeviceRegistryStatusV2, type DeviceRootRevokePayloadV2, type DeviceRootRevokeRequestV2, type DeviceSelfRevokePayloadV2, @@ -64,6 +66,7 @@ import type { CreateIdentityOptions, DisposeIdentityOptions, DeviceRegistryReceiptVerifier, + DeviceRegistryStatusVerifier, DeviceRequestOptions, DeviceRevocationOptions, DeviceTransferEnvelopeV2, @@ -112,6 +115,7 @@ export interface WalletCoreOptions { crypto?: CryptoProvider; clock?: Clock; verifyDeviceRegistryReceipt?: DeviceRegistryReceiptVerifier; + verifyDeviceRegistryStatus?: DeviceRegistryStatusVerifier; } const systemClock: Clock = { @@ -170,6 +174,8 @@ function summarize(record: LocalIdentityRecordV1, now: number): LocalIdentitySum (record.deviceV2 === undefined ? record.registrationReceipt !== undefined : record.deviceV2.localState === 'active' && + (record.deviceV2.registryState === undefined || + record.deviceV2.registryState === 'active') && now >= record.deviceV2.authorization.payload.validFrom && now < record.deviceV2.authorization.payload.expiresAt), hasAgreementKey: record.agreementPrivateKeyRef !== undefined, @@ -182,6 +188,15 @@ function summarize(record: LocalIdentityRecordV1, now: number): LocalIdentitySum localState: record.deviceV2.localState, activationDeadline: record.deviceV2.authorization.payload.activationDeadline, expiresAt: record.deviceV2.authorization.payload.expiresAt, + ...(record.deviceV2.registryState === undefined + ? {} + : { registryState: record.deviceV2.registryState }), + ...(record.deviceV2.statusCheckedAt === undefined + ? {} + : { statusCheckedAt: record.deviceV2.statusCheckedAt }), + ...(record.deviceV2.registryRevokedAt === undefined + ? {} + : { registryRevokedAt: record.deviceV2.registryRevokedAt }), }, }), issuedDevices: (record.issuedDevicesV2 ?? []).map((device) => ({ @@ -192,6 +207,11 @@ function summarize(record: LocalIdentityRecordV1, now: number): LocalIdentitySum expiresAt: device.authorization.payload.expiresAt, ...(device.label === undefined ? {} : { label: device.label }), localState: device.localState, + ...(device.registryState === undefined ? {} : { registryState: device.registryState }), + ...(device.statusCheckedAt === undefined ? {} : { statusCheckedAt: device.statusCheckedAt }), + ...(device.registryRevokedAt === undefined + ? {} + : { registryRevokedAt: device.registryRevokedAt }), })), }; } @@ -235,6 +255,7 @@ export class WalletCore implements WalletCoreApi { readonly #crypto: CryptoProvider; readonly #clock: Clock; readonly #verifyDeviceRegistryReceipt: DeviceRegistryReceiptVerifier | undefined; + readonly #verifyDeviceRegistryStatus: DeviceRegistryStatusVerifier | undefined; public constructor(options: WalletCoreOptions) { this.#keyVault = options.keyVault; @@ -244,6 +265,7 @@ export class WalletCore implements WalletCoreApi { this.#crypto = options.crypto ?? getDefaultCryptoProvider(); this.#clock = options.clock ?? systemClock; this.#verifyDeviceRegistryReceipt = options.verifyDeviceRegistryReceipt; + this.#verifyDeviceRegistryStatus = options.verifyDeviceRegistryStatus; } public async createIdentity(options: CreateIdentityOptions = {}): Promise { @@ -954,6 +976,19 @@ export class WalletCore implements WalletCoreApi { const audience = requireTrustedWalletEventBoundary(boundary).audience; const record = await this.#getDeviceRecord(deviceLocalId, true); const device = record.deviceV2; + if (device.registryState === 'revoked') { + throw new WalletCoreError('IDENTITY_REVOKED', 'The registry reports this device as revoked.'); + } + if ( + device.registryState !== undefined && + device.registryState !== 'active' && + device.localState === 'active' + ) { + throw new WalletCoreError( + 'IDENTITY_NOT_REGISTERED', + `The registry reports this device as ${device.registryState}.`, + ); + } if (device.localState !== 'active') { throw new WalletCoreError( 'IDENTITY_NOT_REGISTERED', @@ -1200,6 +1235,121 @@ export class WalletCore implements WalletCoreApi { return structuredClone(receipt); } + public async refreshDeviceStatus( + localId: string, + deviceIdInput?: Parameters[1], + ): Promise { + if ( + this.#registryClient.getDeviceStatus === undefined || + this.#verifyDeviceRegistryStatus === undefined + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The registry client does not support verified v2 device status polling.', + ); + } + const record = await this.#getRecord(localId); + const target = + record.deviceV2 !== undefined + ? record.deviceV2 + : (() => { + if (deviceIdInput === undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'A root identity must select an issued device to refresh.', + ); + } + const deviceId = nexusDeviceIdV2Schema.parse(deviceIdInput); + const issued = (record.issuedDevicesV2 ?? []).find( + (device) => device.deviceId === deviceId, + ); + if (issued === undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'This root has no local record of issuing the requested device.', + ); + } + return issued; + })(); + if (deviceIdInput !== undefined && target.deviceId !== deviceIdInput) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The selected device does not match this wallet.', + ); + } + const genesisHash = base64Url32Schema.parse( + encodeBase64Url(await deriveGenesisHash(record.genesis, this.#crypto)), + ); + const expectation = { + subject: record.subject, + genesisHash, + deviceId: target.deviceId, + authorizationId: target.authorizationId, + }; + const rawStatus = await this.#registryClient.getDeviceStatus({ + subject: expectation.subject, + deviceId: expectation.deviceId, + authorizationId: expectation.authorizationId, + }); + const status = parseWalletInput( + () => deviceRegistryStatusV2Schema.parse(rawStatus), + 'The device registry status response is malformed.', + ); + const verified = await this.#verifyDeviceRegistryStatus(status, expectation); + const exact = deviceRegistryStatusV2Schema.parse(verified); + if ( + exact.subject !== expectation.subject || + exact.genesisHash !== expectation.genesisHash || + exact.deviceId !== expectation.deviceId || + exact.authorizationId !== expectation.authorizationId + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The verified device status does not match the requested device authorization.', + ); + } + const checkedAt = this.#clock.now(); + validateEpochSeconds(checkedAt, 'clock.now()'); + await this.#updateRecord(localId, (latest) => { + if (latest.deviceV2 !== undefined) { + if ( + latest.deviceV2.deviceId !== exact.deviceId || + latest.deviceV2.authorizationId !== exact.authorizationId + ) { + return latest; + } + const updated = { + ...latest.deviceV2, + registryState: exact.deviceState, + statusCheckedAt: checkedAt, + }; + if (exact.revokedAt !== null) updated.registryRevokedAt = exact.revokedAt; + else delete updated.registryRevokedAt; + return { ...latest, deviceV2: updated }; + } + return { + ...latest, + issuedDevicesV2: (latest.issuedDevicesV2 ?? []).map((device) => { + if ( + device.deviceId !== exact.deviceId || + device.authorizationId !== exact.authorizationId + ) { + return device; + } + const updated = { + ...device, + registryState: exact.deviceState, + statusCheckedAt: checkedAt, + }; + if (exact.revokedAt !== null) updated.registryRevokedAt = exact.revokedAt; + else delete updated.registryRevokedAt; + return updated; + }), + }; + }); + return structuredClone(exact); + } + public async listIdentitySummaries(): Promise { const now = this.#clock.now(); validateEpochSeconds(now, 'clock.now()'); diff --git a/packages/wallet-core/test/device-v2.test.ts b/packages/wallet-core/test/device-v2.test.ts index 0f98870..abc955c 100644 --- a/packages/wallet-core/test/device-v2.test.ts +++ b/packages/wallet-core/test/device-v2.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { createProtocolSignaturePreimage, + deriveGenesisHash, getDefaultCryptoProvider, WebCryptoProvider, } from '@nexus/crypto'; @@ -12,14 +13,17 @@ import { decodeBase64Url, decodeBase64UrlExact, deviceRegistryReceiptV2Schema, + deviceRegistryStatusV2Schema, encodeBase64Url, formatDeviceIdV2, proofRequestSchema, type DeviceActivationRequestV2, type DeviceAuthorizationPayloadV2, type DeviceRegistryReceiptV2, + type DeviceRegistryStatusV2, type DeviceRootRevokeRequestV2, type DeviceSelfRevokeRequestV2, + type DeviceStatusRequestV2, type NexusDeviceAuthorizationIdV2, type NexusDeviceIdV2, type NexusSubject, @@ -149,6 +153,58 @@ class DeviceRegistryClient implements RegistryClient { } } +class PollingDeviceRegistryClient extends DeviceRegistryClient { + public status: DeviceRegistryStatusV2 | undefined; + + public getDeviceStatus(request: DeviceStatusRequestV2): Promise { + if (this.status === undefined) return Promise.reject(new Error('missing test status')); + expect(request).toMatchObject({ + subject: this.status.subject, + deviceId: this.status.deviceId, + authorizationId: this.status.authorizationId, + }); + return Promise.resolve(this.status); + } +} + +function revokedDeviceStatus( + request: DeviceStatusRequestV2, + genesisHash: string, +): DeviceRegistryStatusV2 { + const payload = { + protocol: 'nexus.device-status-statement.v2' as const, + ...request, + genesisHash, + identityState: 'active' as const, + identitySequence: 0, + deviceLedgerSequence: 2, + deviceState: 'revoked' as const, + activatedAt: NOW - 100, + revokedAt: NOW - 10, + authorizationExpiresAt: NOW + 10_000, + iat: NOW, + exp: NOW + 60, + signerKid: 'test-device-registry', + }; + return deviceRegistryStatusV2Schema.parse({ + subject: request.subject, + genesisHash, + identityState: payload.identityState, + identitySequence: payload.identitySequence, + deviceLedgerSequence: payload.deviceLedgerSequence, + deviceId: request.deviceId, + authorizationId: request.authorizationId, + deviceState: payload.deviceState, + activatedAt: payload.activatedAt, + revokedAt: payload.revokedAt, + authorizationExpiresAt: payload.authorizationExpiresAt, + statusStatement: { + payload, + signature: encodeBase64Url(new Uint8Array(64).fill(8)), + }, + }); +} + class FlakyDeleteKeyVault extends InMemoryKeyVault { public failDeletes = false; @@ -497,6 +553,80 @@ describe('v2 root-authorized devices', () => { ).toBe(false); }); + it('manually polls an exact signed status and disables a remotely revoked device locally', async () => { + const keyVault = new InMemoryKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new PollingDeviceRegistryClient(); + const verifiedExpectations: unknown[] = []; + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryStatus: (status, expected) => { + verifiedExpectations.push(expected); + return Promise.resolve(deviceRegistryStatusV2Schema.parse(status)); + }, + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId, { label: 'Remote phone' }); + const imported = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + const importedRecord = await identityStore.get(imported.localId); + if (importedRecord?.deviceV2 === undefined) throw new Error('Expected imported device record.'); + await identityStore.put({ + ...importedRecord, + deviceV2: { ...importedRecord.deviceV2, localState: 'active' }, + }); + const request = { + subject: root.subject, + deviceId: imported.deviceId, + authorizationId: imported.authorizationId, + }; + registryClient.status = revokedDeviceStatus( + request, + encodeBase64Url(await deriveGenesisHash(root.genesis, getDefaultCryptoProvider())), + ); + + await expect(wallet.refreshDeviceStatus(imported.localId)).resolves.toMatchObject({ + deviceState: 'revoked', + }); + await expect( + wallet.refreshDeviceStatus(root.localId, imported.deviceId), + ).resolves.toMatchObject({ deviceState: 'revoked' }); + + const summaries = await wallet.listIdentitySummaries(); + expect(summaries.find((summary) => summary.localId === imported.localId)).toMatchObject({ + proofReady: false, + device: { + localState: 'active', + registryState: 'revoked', + statusCheckedAt: NOW, + registryRevokedAt: NOW - 10, + }, + }); + expect( + summaries.find((summary) => summary.localId === root.localId)?.issuedDevices[0], + ).toMatchObject({ + localState: 'issued', + registryState: 'revoked', + statusCheckedAt: NOW, + }); + expect(verifiedExpectations).toHaveLength(2); + await expect( + wallet.proveDevice( + imported.localId, + boundary('https://rp-a.test'), + proofRequestSchema.parse({ + action: 'post.edit', + resource: 'post:01JABC', + nonce: 'AQEBAQEBAQEBAQEBAQEBAQ', + expiresAt: NOW + 60, + }), + ), + ).rejects.toMatchObject({ code: 'IDENTITY_REVOKED' }); + }); + it('atomically permits only one concurrent installation of the same device key', async () => { const source = createHarness(); const root = await source.wallet.createIdentity(); diff --git a/tests/browser-e2e/security.spec.ts b/tests/browser-e2e/security.spec.ts index 1b3d5a9..0f70a84 100644 --- a/tests/browser-e2e/security.spec.ts +++ b/tests/browser-e2e/security.spec.ts @@ -242,6 +242,11 @@ test.describe.serial('Nexus browser security boundary', () => { wallet.getByText('Device installed and activated for this identity.', { exact: true }), ).toBeVisible(); await expect(wallet.getByText('Active device', { exact: true })).toBeVisible(); + await wallet.getByRole('button', { name: 'Refresh status', exact: true }).click(); + await expect( + wallet.getByText('Registry device status: active.', { exact: true }), + ).toBeVisible(); + await expect(wallet.getByText(/Registry checked/u)).toBeVisible(); await wallet.getByLabel('Edit identity nickname').click(); await wallet.getByLabel('Identity nickname').fill('E2E phone'); await wallet.getByRole('button', { name: 'Save', exact: true }).click();