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();