+ {deviceCapabilities.activateDevice
+ ? 'The signing key is installed but cannot create proofs yet. Retry activation before the authorization deadline.'
+ : 'The activation window has closed. This installation cannot create proofs; cancel it to invalidate the device authorization.'}
+
+ )}
+
+ ) : identity.issuedDevices.length === 0 ? (
+
+ No device keys have been issued by this root identity. Adding one exports only a
+ separately authorized device key—not this identity key.
+
+ This signs directly with the identity's root key because this app accepts v1.
+ Prefer an active device key when possible; root compromise affects the whole
+ identity.
+
+
+
+
+
+ )}
+
{expired && (
void approve()}
- disabled={busy || expired || selected === undefined}
+ disabled={
+ busy ||
+ expired ||
+ selectedProofProtocol === undefined ||
+ (legacyRootProof && !legacyRootConfirmed)
+ }
className="flex flex-[1.4] items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-indigo-400"
>
{busy ? (
diff --git a/apps/wallet/src/lib/device-management.test.ts b/apps/wallet/src/lib/device-management.test.ts
new file mode 100644
index 0000000..7013816
--- /dev/null
+++ b/apps/wallet/src/lib/device-management.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it, vi } from 'vitest';
+import type {
+ DeviceTransferEnvelopeV2,
+ ImportedDeviceV2,
+ LocalIdentitySummary,
+} from '@nexus/wallet-core';
+
+import { canCreateProof } from './identity-capabilities';
+import {
+ decodeDeviceTransferKey,
+ deviceManagementCapabilities,
+ encodeDeviceTransferKey,
+ installAndActivateDevice,
+ serializeDeviceTransferBundle,
+} from './device-management';
+
+function identity(overrides: Partial = {}): LocalIdentitySummary {
+ return {
+ localId: 'local-1',
+ subject: 'nx1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ localScopes: [],
+ authorizationHistory: [],
+ localState: 'active',
+ registered: true,
+ proofReady: true,
+ hasAgreementKey: false,
+ issuedDevices: [],
+ ...overrides,
+ };
+}
+
+const bundle: DeviceTransferEnvelopeV2 = {
+ protocol: 'nexus.device-transfer.v2',
+ suite: 'NX-HKDF-SHA256-AES256GCM-v2',
+ bundleId: 'bundle',
+ salt: 'salt',
+ iv: 'iv',
+ ciphertext: 'ciphertext',
+};
+
+const imported = {
+ localId: 'device-local',
+ subject: 'nx1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ deviceId: 'nxd2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ authorizationId: 'nxa2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ authorization: {},
+} as unknown as ImportedDeviceV2;
+
+describe('v2 wallet device management', () => {
+ it('strictly role-gates root and delegated-device actions', () => {
+ expect(deviceManagementCapabilities(identity())).toEqual({
+ issueDevice: true,
+ activateDevice: false,
+ selfRevokeDevice: false,
+ rootRevokeDevice: true,
+ });
+
+ const pending = identity({
+ registered: false,
+ proofReady: false,
+ device: {
+ deviceId: imported.deviceId,
+ authorizationId: imported.authorizationId,
+ localState: 'pending-activation',
+ activationDeadline: 2_000_000_000,
+ expiresAt: 2_100_000_000,
+ },
+ });
+ expect(deviceManagementCapabilities(pending)).toEqual({
+ issueDevice: false,
+ activateDevice: true,
+ selfRevokeDevice: true,
+ rootRevokeDevice: false,
+ });
+ expect(canCreateProof(pending)).toBe(false);
+
+ const activeDevice = identity({
+ registered: false,
+ device: { ...pending.device!, localState: 'active' },
+ });
+ expect(deviceManagementCapabilities(activeDevice)).toEqual({
+ issueDevice: false,
+ activateDevice: false,
+ selfRevokeDevice: true,
+ rootRevokeDevice: false,
+ });
+ });
+
+ it('keeps the transfer key separate from the downloaded bundle', () => {
+ const key = Uint8Array.from({ length: 32 }, (_, index) => index);
+ const encoded = encodeDeviceTransferKey(key);
+ expect(decodeDeviceTransferKey(encoded)).toEqual(key);
+ const serialized = serializeDeviceTransferBundle(bundle);
+ expect(serialized).not.toContain(encoded);
+ expect(serialized).not.toContain('transferKey');
+ expect(Object.keys(JSON.parse(serialized) as object).sort()).toEqual([
+ 'bundleId',
+ 'ciphertext',
+ 'iv',
+ 'protocol',
+ 'salt',
+ 'suite',
+ ]);
+ });
+
+ it('retains the installed device for retry when activation fails and wipes key bytes', async () => {
+ const transferKey = new Uint8Array(32).fill(7);
+ const importDeviceTransfer = vi.fn(() => Promise.resolve(imported));
+ const activateDevice = vi.fn(() => Promise.reject(new Error('registry unavailable')));
+ const result = await installAndActivateDevice(
+ { importDeviceTransfer, activateDevice },
+ bundle,
+ transferKey,
+ );
+
+ expect(result).toEqual({
+ imported,
+ state: 'pending-activation',
+ activationError: 'registry unavailable',
+ });
+ expect(transferKey).toEqual(new Uint8Array(32));
+ expect(importDeviceTransfer).toHaveBeenCalledOnce();
+ expect(activateDevice).toHaveBeenCalledWith(imported.localId);
+ });
+
+ it('does not attempt activation when import fails and still wipes key bytes', async () => {
+ const transferKey = new Uint8Array(32).fill(9);
+ const activateDevice = vi.fn();
+ await expect(
+ installAndActivateDevice(
+ {
+ importDeviceTransfer: vi.fn(() => Promise.reject(new Error('bad bundle'))),
+ activateDevice,
+ },
+ bundle,
+ transferKey,
+ ),
+ ).rejects.toThrow('bad bundle');
+ expect(transferKey).toEqual(new Uint8Array(32));
+ expect(activateDevice).not.toHaveBeenCalled();
+ });
+
+ it('closes activation exactly at the deadline or authorization expiry but keeps cancellation', () => {
+ const pending = identity({
+ registered: false,
+ proofReady: false,
+ device: {
+ deviceId: imported.deviceId,
+ authorizationId: imported.authorizationId,
+ localState: 'pending-activation',
+ activationDeadline: 2_000,
+ expiresAt: 3_000,
+ },
+ });
+
+ expect(deviceManagementCapabilities(pending, 1_999)).toMatchObject({
+ activateDevice: true,
+ selfRevokeDevice: true,
+ });
+ expect(deviceManagementCapabilities(pending, 2_000)).toMatchObject({
+ activateDevice: false,
+ selfRevokeDevice: true,
+ });
+
+ const expiryFirst = identity({
+ registered: false,
+ proofReady: false,
+ device: { ...pending.device!, activationDeadline: 3_000, expiresAt: 2_000 },
+ });
+ expect(deviceManagementCapabilities(expiryFirst, 1_999).activateDevice).toBe(true);
+ expect(deviceManagementCapabilities(expiryFirst, 2_000)).toMatchObject({
+ activateDevice: false,
+ selfRevokeDevice: true,
+ });
+ });
+});
diff --git a/apps/wallet/src/lib/device-management.ts b/apps/wallet/src/lib/device-management.ts
new file mode 100644
index 0000000..3dfebfb
--- /dev/null
+++ b/apps/wallet/src/lib/device-management.ts
@@ -0,0 +1,161 @@
+import type {
+ DeviceTransferEnvelopeV2,
+ ImportedDeviceV2,
+ LocalIdentitySummary,
+} from '@nexus/wallet-core';
+
+export const MAX_DEVICE_TRANSFER_FILE_BYTES = 96 * 1024;
+
+export interface DeviceManagementCapabilities {
+ issueDevice: boolean;
+ activateDevice: boolean;
+ selfRevokeDevice: boolean;
+ rootRevokeDevice: boolean;
+}
+
+export function deviceManagementCapabilities(
+ identity: LocalIdentitySummary,
+ now = Math.floor(Date.now() / 1_000),
+): DeviceManagementCapabilities {
+ const locallyActive = identity.localState === 'active';
+ if (identity.device === undefined) {
+ const rootReady = locallyActive && identity.registered;
+ return {
+ issueDevice: rootReady,
+ activateDevice: false,
+ selfRevokeDevice: false,
+ rootRevokeDevice: rootReady,
+ };
+ }
+ return {
+ issueDevice: false,
+ activateDevice:
+ locallyActive &&
+ identity.device.localState === 'pending-activation' &&
+ now < identity.device.activationDeadline &&
+ now < identity.device.expiresAt,
+ selfRevokeDevice: locallyActive && identity.device.localState !== 'revoked',
+ rootRevokeDevice: false,
+ };
+}
+
+function encodeBase64Url(bytes: Uint8Array): string {
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
+}
+
+/** Encodes the 32-byte transfer secret without including it in the bundle. */
+export function encodeDeviceTransferKey(bytes: Uint8Array): string {
+ if (bytes.byteLength !== 32) {
+ throw new Error('The device transfer key must contain exactly 32 bytes.');
+ }
+ return encodeBase64Url(bytes);
+}
+
+/** Strictly accepts the canonical, unpadded base64url encoding of exactly 32 bytes. */
+export function decodeDeviceTransferKey(value: string): Uint8Array {
+ if (!/^[A-Za-z0-9_-]{43}$/u.test(value)) {
+ throw new Error('Enter the 43-character transfer key exactly as it was shown.');
+ }
+ let binary: string;
+ try {
+ binary = atob(`${value.replaceAll('-', '+').replaceAll('_', '/')}=`);
+ } catch (error) {
+ throw new Error('The transfer key is not valid base64url.', { cause: error });
+ }
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
+ if (bytes.byteLength !== 32 || encodeBase64Url(bytes) !== value) {
+ bytes.fill(0);
+ throw new Error('The transfer key is not the canonical encoding of 32 bytes.');
+ }
+ return bytes;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+export function parseDeviceTransferBundleJson(
+ text: string,
+ byteLength = new TextEncoder().encode(text).byteLength,
+): DeviceTransferEnvelopeV2 {
+ if (byteLength > MAX_DEVICE_TRANSFER_FILE_BYTES) {
+ throw new Error('The device transfer file is larger than 96 KiB.');
+ }
+ let value: unknown;
+ try {
+ value = JSON.parse(text) as unknown;
+ } catch (error) {
+ throw new Error('The selected file is not valid JSON.', { cause: error });
+ }
+ if (!isRecord(value)) throw new Error('The selected file is not a device transfer bundle.');
+ const expectedKeys = ['bundleId', 'ciphertext', 'iv', 'protocol', 'salt', 'suite'];
+ const keys = Object.keys(value).sort();
+ if (
+ keys.length !== expectedKeys.length ||
+ keys.some((key, index) => key !== expectedKeys[index])
+ ) {
+ throw new Error('The device transfer bundle has unexpected or missing fields.');
+ }
+ if (
+ value.protocol !== 'nexus.device-transfer.v2' ||
+ value.suite !== 'NX-HKDF-SHA256-AES256GCM-v2' ||
+ typeof value.bundleId !== 'string' ||
+ typeof value.salt !== 'string' ||
+ typeof value.iv !== 'string' ||
+ typeof value.ciphertext !== 'string'
+ ) {
+ throw new Error('The selected file uses an unsupported device transfer format.');
+ }
+ return value as unknown as DeviceTransferEnvelopeV2;
+}
+
+export function serializeDeviceTransferBundle(bundle: DeviceTransferEnvelopeV2): string {
+ const serialized = `${JSON.stringify(bundle, null, 2)}\n`;
+ const parsed = JSON.parse(serialized) as Record;
+ if ('transferKey' in parsed || 'transfer_key' in parsed || 'key' in parsed) {
+ throw new Error('Refusing to serialize a bundle that contains its transfer key.');
+ }
+ return serialized;
+}
+
+export interface DeviceInstallOperations {
+ importDeviceTransfer(
+ bundle: DeviceTransferEnvelopeV2,
+ transferKey: Uint8Array,
+ ): Promise;
+ activateDevice(localId: string): Promise;
+}
+
+export type DeviceInstallResult =
+ | { imported: ImportedDeviceV2; state: 'active' }
+ | { imported: ImportedDeviceV2; state: 'pending-activation'; activationError: string };
+
+/**
+ * Installs first, then attempts activation. The imported signing key remains available for an
+ * explicit retry if registry activation fails. The caller-owned transfer bytes are always wiped.
+ */
+export async function installAndActivateDevice(
+ operations: DeviceInstallOperations,
+ bundle: DeviceTransferEnvelopeV2,
+ transferKey: Uint8Array,
+): Promise {
+ let imported: ImportedDeviceV2;
+ try {
+ imported = await operations.importDeviceTransfer(bundle, transferKey);
+ } finally {
+ transferKey.fill(0);
+ }
+ try {
+ await operations.activateDevice(imported.localId);
+ return { imported, state: 'active' };
+ } catch (error) {
+ return {
+ imported,
+ state: 'pending-activation',
+ activationError:
+ error instanceof Error ? error.message : 'The registry could not activate this device.',
+ };
+ }
+}
diff --git a/apps/wallet/src/lib/identity-capabilities.test.ts b/apps/wallet/src/lib/identity-capabilities.test.ts
index c270d5c..fbf9604 100644
--- a/apps/wallet/src/lib/identity-capabilities.test.ts
+++ b/apps/wallet/src/lib/identity-capabilities.test.ts
@@ -2,6 +2,7 @@ import type { LocalIdentitySummary } from '@nexus/wallet-core';
import { describe, expect, it } from 'vitest';
import {
+ canCreateProof,
canUseRegisteredIdentityActions,
needsRegistrationRecovery,
} from './identity-capabilities';
@@ -17,7 +18,9 @@ function identity(
authorizationHistory: [],
localState,
registered,
+ proofReady: localState === 'active' && registered,
hasAgreementKey: false,
+ issuedDevices: [],
};
}
@@ -32,4 +35,44 @@ describe('identity action capabilities', () => {
expect(canUseRegisteredIdentityActions(identity('active', true))).toBe(true);
expect(canUseRegisteredIdentityActions(identity('revoked', true))).toBe(false);
});
+
+ it('never offers v1 registration recovery or root lifecycle actions to a delegated device', () => {
+ const device: LocalIdentitySummary = {
+ ...identity('active', true),
+ device: {
+ deviceId: 'nxd2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ authorizationId: 'nxa2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ localState: 'active',
+ activationDeadline: 1_800_000_000,
+ expiresAt: 1_900_000_000,
+ },
+ proofReady: true,
+ };
+ expect(needsRegistrationRecovery(device)).toBe(false);
+ expect(canUseRegisteredIdentityActions(device)).toBe(false);
+ expect(canCreateProof(device)).toBe(true);
+ expect(
+ canCreateProof({
+ ...device,
+ device: { ...device.device!, localState: 'pending-activation' },
+ proofReady: false,
+ }),
+ ).toBe(false);
+ });
+
+ it('does not offer a proof when core marks an otherwise active device unready', () => {
+ const expiredDevice: LocalIdentitySummary = {
+ ...identity('active', true),
+ proofReady: false,
+ device: {
+ deviceId: 'nxd2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ authorizationId: 'nxa2_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
+ localState: 'active',
+ activationDeadline: 1_700_000_000,
+ expiresAt: 1_800_000_000,
+ },
+ };
+
+ expect(canCreateProof(expiredDevice)).toBe(false);
+ });
});
diff --git a/apps/wallet/src/lib/identity-capabilities.ts b/apps/wallet/src/lib/identity-capabilities.ts
index fbb5d99..71bca3c 100644
--- a/apps/wallet/src/lib/identity-capabilities.ts
+++ b/apps/wallet/src/lib/identity-capabilities.ts
@@ -1,9 +1,15 @@
import type { LocalIdentitySummary } from '@nexus/wallet-core';
+export function canCreateProof(identity: LocalIdentitySummary): boolean {
+ return identity.localState === 'active' && identity.proofReady;
+}
+
export function needsRegistrationRecovery(identity: LocalIdentitySummary): boolean {
- return identity.localState === 'active' && !identity.registered;
+ return identity.device === undefined && identity.localState === 'active' && !identity.registered;
}
export function canUseRegisteredIdentityActions(identity: LocalIdentitySummary): boolean {
- return identity.localState === 'active' && identity.registered;
+ // Rotation, continuity, and terminal identity disposal require the root/v1 key. Delegated
+ // devices get their own self-revocation flow and must never be offered root operations.
+ return identity.device === undefined && identity.localState === 'active' && identity.registered;
}
diff --git a/apps/wallet/src/lib/popup-protocol.test.ts b/apps/wallet/src/lib/popup-protocol.test.ts
index 2752702..15a06af 100644
--- a/apps/wallet/src/lib/popup-protocol.test.ts
+++ b/apps/wallet/src/lib/popup-protocol.test.ts
@@ -4,7 +4,9 @@ import { describe, expect, it, vi } from 'vitest';
import {
NEXUS_POPUP_CHANNEL,
+ NEXUS_POPUP_CHANNEL_V2,
announceWalletReady,
+ announceWalletReadyV2,
parseProofRequestMessage,
postProofError,
postProofResult,
@@ -68,10 +70,15 @@ describe('wallet popup boundary', () => {
origin: 'https://rp.example',
boundary: { audience: 'https://rp.example' } as TrustedWalletEventBoundary,
source: { postMessage } as unknown as Window,
+ popupProtocol: NEXUS_POPUP_CHANNEL,
+ acceptedProofProtocols: ['nexus.ownership-proof.v1'],
};
- const proof = { payload: {}, signature: 'signature' } as unknown as OwnershipProofV1;
+ const proof = {
+ payload: { protocol: 'nexus.ownership-proof.v1' },
+ signature: 'signature',
+ } as unknown as OwnershipProofV1;
- postProofResult(pending, proof);
+ postProofResult(pending, { proofProtocol: 'nexus.ownership-proof.v1', proof });
postProofError(pending, 'USER_CANCELLED');
expect(postMessage).toHaveBeenNthCalledWith(
@@ -95,4 +102,115 @@ describe('wallet popup boundary', () => {
'*',
);
});
+
+ it('strictly parses an ordered v2 allow-list and pins it to the trusted event boundary', () => {
+ const channel = new MessageChannel();
+ const source = channel.port1 as unknown as Window;
+ const event = new MessageEvent('message', {
+ source,
+ origin: 'https://rp.example',
+ data: {
+ channel: NEXUS_POPUP_CHANNEL_V2,
+ type: 'NEXUS_PROOF_REQUEST',
+ requestId: REQUEST_ID,
+ request: proofRequest(1_788_000_120),
+ acceptedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'],
+ },
+ });
+
+ const parsed = parseProofRequestMessage(event, source, 1_788_000_000);
+ expect(parsed).toMatchObject({
+ popupProtocol: NEXUS_POPUP_CHANNEL_V2,
+ acceptedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'],
+ origin: 'https://rp.example',
+ });
+ expect(parsed?.boundary.audience).toBe('https://rp.example');
+ });
+
+ it.each([
+ [[]],
+ [['nexus.ownership-proof.v2', 'nexus.ownership-proof.v2']],
+ [['nexus.ownership-proof.v3']],
+ ])('rejects invalid v2 protocol policy %j', (acceptedProofProtocols) => {
+ const channel = new MessageChannel();
+ const source = channel.port1 as unknown as Window;
+ const event = new MessageEvent('message', {
+ source,
+ origin: 'https://rp.example',
+ data: {
+ channel: NEXUS_POPUP_CHANNEL_V2,
+ type: 'NEXUS_PROOF_REQUEST',
+ requestId: REQUEST_ID,
+ request: proofRequest(1_788_000_120),
+ acceptedProofProtocols,
+ },
+ });
+ expect(parseProofRequestMessage(event, source, 1_788_000_000)).toBeUndefined();
+ });
+
+ it('advertises v2 without changing the exact v1 READY envelope', () => {
+ const postMessage = vi.fn();
+ const opener = { postMessage } as unknown as Window;
+ announceWalletReady(opener);
+ announceWalletReadyV2(opener);
+
+ expect(postMessage).toHaveBeenNthCalledWith(
+ 1,
+ { channel: NEXUS_POPUP_CHANNEL, type: 'NEXUS_READY' },
+ '*',
+ );
+ expect(postMessage).toHaveBeenNthCalledWith(
+ 2,
+ {
+ channel: NEXUS_POPUP_CHANNEL_V2,
+ type: 'NEXUS_READY',
+ supportedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'],
+ },
+ '*',
+ );
+ });
+
+ it('never returns a v2 proof through the v1 channel', () => {
+ const pending: PendingProofRequest = {
+ requestId: REQUEST_ID,
+ request: proofRequest(1_788_000_120),
+ origin: 'https://rp.example',
+ boundary: { audience: 'https://rp.example' } as TrustedWalletEventBoundary,
+ source: { postMessage: vi.fn() } as unknown as Window,
+ popupProtocol: NEXUS_POPUP_CHANNEL,
+ acceptedProofProtocols: ['nexus.ownership-proof.v1'],
+ };
+
+ expect(() =>
+ postProofResult(pending, {
+ proofProtocol: 'nexus.ownership-proof.v2',
+ proof: {
+ payload: { protocol: 'nexus.ownership-proof.v2' },
+ deviceSignature: 'signature',
+ } as never,
+ }),
+ ).toThrow(/not accepted|v1 popup/u);
+ });
+
+ it('rejects a result whose selected protocol disagrees with its signed payload', () => {
+ const pending: PendingProofRequest = {
+ requestId: REQUEST_ID,
+ request: proofRequest(1_788_000_120),
+ origin: 'https://rp.example',
+ boundary: { audience: 'https://rp.example' } as TrustedWalletEventBoundary,
+ source: { postMessage: vi.fn() } as unknown as Window,
+ popupProtocol: NEXUS_POPUP_CHANNEL_V2,
+ acceptedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'],
+ };
+
+ expect(() =>
+ postProofResult(pending, {
+ proofProtocol: 'nexus.ownership-proof.v2',
+ proof: {
+ payload: { protocol: 'nexus.ownership-proof.v1' },
+ deviceSignature: 'signature',
+ } as never,
+ }),
+ ).toThrow(/does not match/u);
+ });
});
diff --git a/apps/wallet/src/lib/popup-protocol.ts b/apps/wallet/src/lib/popup-protocol.ts
index 7ab309b..2739114 100644
--- a/apps/wallet/src/lib/popup-protocol.ts
+++ b/apps/wallet/src/lib/popup-protocol.ts
@@ -3,11 +3,24 @@ import {
base64UrlAtLeast16Schema,
proofRequestSchema,
type OwnershipProofV1,
+ type OwnershipProofV2,
type ProofRequest,
} from '@nexus/protocol';
import { captureWalletEventBoundary, type TrustedWalletEventBoundary } from '@nexus/wallet-core';
export const NEXUS_POPUP_CHANNEL = 'nexus.popup.v1' as const;
+export const NEXUS_POPUP_CHANNEL_V2 = 'nexus.popup.v2' as const;
+
+export const SUPPORTED_PROOF_PROTOCOLS = [
+ 'nexus.ownership-proof.v2',
+ 'nexus.ownership-proof.v1',
+] as const;
+
+export type NexusOwnershipProofProtocol = (typeof SUPPORTED_PROOF_PROTOCOLS)[number];
+export type AcceptedProofProtocols = readonly [
+ NexusOwnershipProofProtocol,
+ ...NexusOwnershipProofProtocol[],
+];
export interface PendingProofRequest {
requestId: string;
@@ -15,6 +28,9 @@ export interface PendingProofRequest {
origin: string;
boundary: TrustedWalletEventBoundary;
source: Window;
+ popupProtocol: typeof NEXUS_POPUP_CHANNEL | typeof NEXUS_POPUP_CHANNEL_V2;
+ /** Ordered policy asserted by the RP. Its backend must also bind and enforce it. */
+ acceptedProofProtocols: AcceptedProofProtocols;
}
export type WalletProofErrorCode =
@@ -24,8 +40,16 @@ export type WalletProofErrorCode =
| 'NO_ACTIVE_IDENTITY'
| 'REGISTRATION_FAILED'
| 'INTERNAL_ERROR';
+export type WalletProofErrorCodeV2 = WalletProofErrorCode | 'UNSUPPORTED_PROOF_PROTOCOL';
const MESSAGE_KEYS = ['channel', 'request', 'requestId', 'type'] as const;
+const MESSAGE_KEYS_V2 = [
+ 'acceptedProofProtocols',
+ 'channel',
+ 'request',
+ 'requestId',
+ 'type',
+] as const;
const REQUEST_KEYS = ['action', 'contextHash', 'expiresAt', 'nonce', 'resource'] as const;
function isRecord(value: unknown): value is Record {
@@ -48,14 +72,17 @@ export function parseProofRequestMessage(
if (expectedSource === null || event.source !== expectedSource || !isRecord(event.data)) {
return undefined;
}
+ const isV1 = event.data.channel === NEXUS_POPUP_CHANNEL;
+ const isV2 = event.data.channel === NEXUS_POPUP_CHANNEL_V2;
+ if (!isV1 && !isV2) return undefined;
+ const expectedKeys = isV2 ? MESSAGE_KEYS_V2 : MESSAGE_KEYS;
if (
- !hasOnlyKeys(event.data, MESSAGE_KEYS) ||
- Object.keys(event.data).length !== MESSAGE_KEYS.length
+ !hasOnlyKeys(event.data, expectedKeys) ||
+ Object.keys(event.data).length !== expectedKeys.length
) {
return undefined;
}
if (
- event.data.channel !== NEXUS_POPUP_CHANNEL ||
event.data.type !== 'NEXUS_PROOF_REQUEST' ||
!isRequestId(event.data.requestId) ||
!isRecord(event.data.request) ||
@@ -64,6 +91,11 @@ export function parseProofRequestMessage(
return undefined;
}
+ const acceptedProofProtocols = isV2
+ ? parseAcceptedProofProtocols(event.data.acceptedProofProtocols)
+ : (['nexus.ownership-proof.v1'] as const);
+ if (acceptedProofProtocols === undefined) return undefined;
+
const parsed = proofRequestSchema.safeParse(event.data.request);
if (!parsed.success || parsed.data.expiresAt <= nowSeconds) return undefined;
if (parsed.data.expiresAt > nowSeconds + 120) return undefined;
@@ -87,6 +119,8 @@ export function parseProofRequestMessage(
origin: event.origin,
boundary,
source: expectedSource,
+ popupProtocol: isV2 ? NEXUS_POPUP_CHANNEL_V2 : NEXUS_POPUP_CHANNEL,
+ acceptedProofProtocols,
};
}
@@ -95,22 +129,62 @@ export function announceWalletReady(opener: Window | null): void {
opener?.postMessage({ channel: NEXUS_POPUP_CHANNEL, type: 'NEXUS_READY' }, '*');
}
-export function postProofResult(pending: PendingProofRequest, proof: OwnershipProofV1): void {
- pending.source.postMessage(
+/** NEXUS_READY v2 advertises capabilities but contains no proof or identity data. */
+export function announceWalletReadyV2(opener: Window | null): void {
+ opener?.postMessage(
{
- channel: NEXUS_POPUP_CHANNEL,
- type: 'NEXUS_PROOF_RESULT',
- requestId: pending.requestId,
- proof,
+ channel: NEXUS_POPUP_CHANNEL_V2,
+ type: 'NEXUS_READY',
+ supportedProofProtocols: SUPPORTED_PROOF_PROTOCOLS,
},
+ '*',
+ );
+}
+
+export function postProofResult(
+ pending: PendingProofRequest,
+ result:
+ | { proofProtocol: 'nexus.ownership-proof.v1'; proof: OwnershipProofV1 }
+ | { proofProtocol: 'nexus.ownership-proof.v2'; proof: OwnershipProofV2 },
+): void {
+ if (!pending.acceptedProofProtocols.includes(result.proofProtocol)) {
+ throw new Error('Proof protocol was not accepted by the RP request.');
+ }
+ if (result.proof.payload.protocol !== result.proofProtocol) {
+ throw new Error('Proof payload protocol does not match the selected popup protocol.');
+ }
+ if (
+ pending.popupProtocol === NEXUS_POPUP_CHANNEL &&
+ result.proofProtocol !== 'nexus.ownership-proof.v1'
+ ) {
+ throw new Error('The v1 popup protocol can only return a v1 ownership proof.');
+ }
+ pending.source.postMessage(
+ pending.popupProtocol === NEXUS_POPUP_CHANNEL
+ ? {
+ channel: NEXUS_POPUP_CHANNEL,
+ type: 'NEXUS_PROOF_RESULT',
+ requestId: pending.requestId,
+ proof: result.proof,
+ }
+ : {
+ channel: NEXUS_POPUP_CHANNEL_V2,
+ type: 'NEXUS_PROOF_RESULT',
+ requestId: pending.requestId,
+ proofProtocol: result.proofProtocol,
+ proof: result.proof,
+ },
pending.origin,
);
}
-export function postProofError(pending: PendingProofRequest, code: WalletProofErrorCode): void {
+export function postProofError(pending: PendingProofRequest, code: WalletProofErrorCodeV2): void {
+ if (pending.popupProtocol === NEXUS_POPUP_CHANNEL && code === 'UNSUPPORTED_PROOF_PROTOCOL') {
+ throw new Error('The v1 popup protocol does not define UNSUPPORTED_PROOF_PROTOCOL.');
+ }
pending.source.postMessage(
{
- channel: NEXUS_POPUP_CHANNEL,
+ channel: pending.popupProtocol,
type: 'NEXUS_PROOF_ERROR',
requestId: pending.requestId,
error: { code },
@@ -118,3 +192,25 @@ export function postProofError(pending: PendingProofRequest, code: WalletProofEr
pending.origin,
);
}
+
+export function selectProofProtocol(
+ accepted: AcceptedProofProtocols,
+ supported: readonly NexusOwnershipProofProtocol[],
+): NexusOwnershipProofProtocol | undefined {
+ return accepted.find((protocol) => supported.includes(protocol));
+}
+
+function parseAcceptedProofProtocols(value: unknown): AcceptedProofProtocols | undefined {
+ if (!Array.isArray(value) || value.length === 0) return undefined;
+ if (
+ !value.every(
+ (protocol) =>
+ typeof protocol === 'string' &&
+ (SUPPORTED_PROOF_PROTOCOLS as readonly string[]).includes(protocol),
+ ) ||
+ new Set(value).size !== value.length
+ ) {
+ return undefined;
+ }
+ return value as unknown as AcceptedProofProtocols;
+}
diff --git a/apps/wallet/src/lib/wallet-adapter.ts b/apps/wallet/src/lib/wallet-adapter.ts
index e3d6612..711e134 100644
--- a/apps/wallet/src/lib/wallet-adapter.ts
+++ b/apps/wallet/src/lib/wallet-adapter.ts
@@ -3,19 +3,28 @@ import {
registryStatusV1Schema,
serviceKeySetSchema,
type ContinuityLinkV1,
+ type DeviceActivationRequestV2,
+ type DeviceRegistryReceiptV2,
+ type DeviceRootRevokeRequestV2,
+ type DeviceSelfRevokeRequestV2,
type OwnershipProofV1,
+ type OwnershipProofV2,
type ProofRequest,
type RegistryReceiptV1,
type RevokeBySecretRequestV1,
type RevokeBySignatureRequestV1,
type ServiceKeySet,
} from '@nexus/protocol';
-import { verifyRegistryReceipt } from '@nexus/verifier';
+import { verifyDeviceRegistryReceipt, verifyRegistryReceipt } from '@nexus/verifier';
import {
IndexedDbIdentityStore,
WalletCore,
WebCryptoIndexedDbKeyVault,
type CreatedLocalIdentity,
+ type DeviceTransferEnvelopeV2,
+ type ImportedDeviceV2,
+ type IssueDeviceTransferOptions,
+ type IssuedDeviceTransferV2,
type LocalIdentitySummary,
type RegistryClient,
type RegistryIdentityStatus,
@@ -24,6 +33,7 @@ import {
} from '@nexus/wallet-core';
import { retryIdentityRegistration } from './registration-recovery';
+import type { NexusOwnershipProofProtocol } from './popup-protocol';
const API_MEDIA_TYPE = 'application/nexus+json';
const DATABASE_NAME = 'rowo-nexus-wallet-v1';
@@ -134,6 +144,30 @@ class HttpRegistryClient implements RegistryClient {
if (!isRecord(value)) throw new Error('The revocation response is malformed.');
return registryReceiptV1Schema.parse(value.receipt);
}
+
+ public async activateDevice(
+ request: DeviceActivationRequestV2,
+ ): Promise {
+ return this.#deviceMutation('/v2/device/activate', request);
+ }
+
+ public async revokeDeviceSelf(
+ request: DeviceSelfRevokeRequestV2,
+ ): Promise {
+ return this.#deviceMutation('/v2/device/revoke-self', request);
+ }
+
+ public async revokeDeviceRoot(
+ request: DeviceRootRevokeRequestV2,
+ ): Promise {
+ return this.#deviceMutation('/v2/device/revoke-root', 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.');
+ return verifyDeviceRegistryReceipt(value.receipt, await loadServiceKeyset());
+ }
}
const identityStore = new IndexedDbIdentityStore({ databaseName: DATABASE_NAME });
@@ -143,6 +177,8 @@ const walletCore = new WalletCore({
registryClient: new HttpRegistryClient(),
verifyRegistryReceipt: async (receipt) =>
verifyRegistryReceipt(receipt, await loadServiceKeyset()),
+ verifyDeviceRegistryReceipt: async (receipt) =>
+ verifyDeviceRegistryReceipt(receipt, await loadServiceKeyset()),
});
export interface CreateIdentityInput {
@@ -184,10 +220,54 @@ export const walletAdapter = {
return walletCore.proveAndRecordAuthorization(localId, boundary, request, addScope);
},
+ async proveForProtocol(
+ localId: string,
+ boundary: TrustedWalletEventBoundary,
+ request: ProofRequest,
+ addScope: boolean,
+ proofProtocol: NexusOwnershipProofProtocol,
+ ): Promise {
+ if (proofProtocol === 'nexus.ownership-proof.v2') {
+ const proof = await walletCore.proveDevice(localId, boundary, request);
+ if (addScope) await walletCore.addScope(localId, boundary);
+ return proof;
+ }
+ return walletCore.proveAndRecordAuthorization(localId, boundary, request, addScope);
+ },
+
clearAuthorizationHistory(localId: string): Promise {
return walletCore.clearAuthorizationHistory(localId);
},
+ issueDeviceTransfer(
+ rootLocalId: string,
+ options: IssueDeviceTransferOptions = {},
+ ): Promise {
+ return walletCore.issueDeviceTransfer(rootLocalId, options);
+ },
+
+ importDeviceTransfer(
+ bundle: DeviceTransferEnvelopeV2,
+ transferKey: Uint8Array,
+ ): Promise {
+ return walletCore.importDeviceTransfer(bundle, transferKey);
+ },
+
+ activateDevice(localId: string): Promise {
+ return walletCore.activateDevice(localId);
+ },
+
+ revokeDeviceSelf(localId: string): Promise {
+ return walletCore.revokeDeviceSelf(localId, { reasonCode: 'replaced' });
+ },
+
+ revokeDeviceRoot(
+ rootLocalId: string,
+ deviceId: LocalIdentitySummary['issuedDevices'][number]['deviceId'],
+ ): Promise {
+ return walletCore.revokeDeviceRoot(rootLocalId, deviceId, { reasonCode: 'replaced' });
+ },
+
dispose(localId: string): Promise {
return walletCore.dispose(localId, {
method: 'signature',
diff --git a/docs/adr/0011-root-authorized-device-keys.md b/docs/adr/0011-root-authorized-device-keys.md
new file mode 100644
index 0000000..8cd7790
--- /dev/null
+++ b/docs/adr/0011-root-authorized-device-keys.md
@@ -0,0 +1,73 @@
+# ADR-0011: Root-Authorized Device Keys
+
+- Status: Accepted
+- Date: 2026-08-12
+- Scope: identity compatibility, wallet key vault, proofs, registry lifecycle, offline transfer
+
+## Context
+
+Nexus v1 binds one non-extractable Ed25519 key to a self-certifying `nx1_` subject. Copying that
+identity key to several devices would make every copy full identity authority, prevent individual
+device revocation, and turn a migration artifact into an indefinite identity clone. Changing the
+genesis key would change the subject and break existing RP ownership records.
+
+Users nevertheless need to use one subject on several devices, invalidate one device without
+destroying the identity, and provision another device through an offline root-controlled flow.
+Existing active v1 identities must be able to adopt the same workflow without replacing their key or
+subject.
+
+## Decision
+
+The existing `nexus.identity.v1` signing key becomes the per-identity root for the additive v2
+profile. The genesis and `nx1_` subject remain unchanged. The root key remains non-extractable and
+authorizes independently generated Ed25519 device keys with `nexus.device-authorization.v2`. Device
+keys use subject-bound `nxd2_` identifiers; authorizations use `nxa2_` identifiers; signed device
+operations use `nxo2_` idempotency identifiers.
+
+The root does not make ordinary `nexus.ownership-proof.v2` proofs. Each device activates its exact
+authorization with `nexus.device-activation.v2` and then signs v2 RP proofs. A device may revoke
+only itself with `nexus.device-self-revoke.v2`; the root may irrevocably tombstone any device with
+`nexus.device-root-revoke.v2`. Terminal v1 subject revocation dominates all device state.
+
+The existing per-subject Durable Object remains authoritative and gains a separate monotonic device
+ledger. Device events do not change the v1 identity sequence or masquerade as v1 lifecycle events.
+Relying parties store the same subject for resource ownership but must check both subject and exact
+device authorization state when accepting v2 proofs.
+
+An existing v1 wallet can enable v2 by using its current signing key as root and generating a new
+device key. It may still make v1 root-key proofs only for explicitly negotiated legacy flows. A
+v1-only verifier cannot validate a v2 proof; backward compatibility preserves existing contracts
+rather than making new proof types understandable to old software.
+
+The root may provision another device by exporting only a newly generated device private key in a
+versioned authenticated encrypted bundle. The root private key, revocation secret, RP scopes,
+labels, and other identities never enter the bundle. The destination imports the device key as
+non-extractable and proves possession during activation. A destination-generated non-extractable key
+with an offline public enrollment request is preferred when available.
+
+## Consequences
+
+- Several device keys can prove the same stable `nx1_` subject without sharing the root private key.
+- Compromise or loss of one device can be contained by revoking its `nxd2_` identifier; sibling
+ devices remain active unless the subject itself is revoked.
+- A root can tombstone a lost authorization before activation, and a device can dispose itself
+ without being able to affect siblings.
+- The registry and updated RPs learn a subject-bound device identifier and device-change timing.
+ They still receive no global physical-device, wallet, account, or controller identifier.
+- Device authorization chains and exact device status add verification and registry complexity.
+- An exported device bundle is copyable. Nexus cannot distinguish physical clones; revoking the
+ shared device ID invalidates all of them.
+- Root compromise is catastrophic for device authorization under that subject. Root loss is not
+ recoverable: devices cannot elevate themselves, renew, or authorize replacements.
+- An upgraded browser-held v1 key is compatible but is not retroactively an air-gapped or
+ hardware-backed root.
+- Existing v1 wire objects, endpoints, proofs, verifier behavior, and subject lifecycle remain
+ unchanged. Unsupported v2 objects fail closed in v1-only implementations.
+
+## Compliance
+
+The normative profile is
+[`docs/protocol/device-delegation-v2.md`](../protocol/device-delegation-v2.md). Protocol vectors and
+tests verify root/device signature separation, subject and identifier recomputation, activation
+possession, monotonic per-device revocation, terminal identity dominance, downgrade rejection,
+transfer-envelope integrity, non-extractable target import, and unchanged v1 vectors and behavior.
diff --git a/docs/cloudflare/deployment.md b/docs/cloudflare/deployment.md
index 464851b..17b18cf 100644
--- a/docs/cloudflare/deployment.md
+++ b/docs/cloudflare/deployment.md
@@ -13,11 +13,12 @@ nexus.rowo.link (edge validation, CORS, receipts/status signing)
|
+-- REGISTRY_SERVICE --> registry Worker
| +-- IdentityState SQLite DOs (authority)
- | +-- durable DO outbox --> registry-events Queue
- | +-- D1 projector (sole consumer)
- | +-- TRANSPARENCY_SERVICE RPC
- | +-- shard DOs
- | +-- R2 checkpoints
+ | +-- v1 outbox --> nexus-registry-events Queue --+
+ | +-- v2 device outbox |
+ | --> nexus-registry-device-events Queue --+--> D1 projector
+ | +-- TRANSPARENCY_SERVICE RPC
+ | +-- shard DOs
+ | +-- R2 checkpoints
status.rowo.link (optional controlled transparency/checkpoint publication)
@@ -70,12 +71,18 @@ Public read/status CORS may use a wildcard only because responses are public and
credentials. Mutation endpoints admit only the wallet origin and explicitly approved native/CLI
clients. Wildcard CORS is never combined with credentials.
+Discovery is versioned for strict-client compatibility. `/.well-known/nexus.json` retains the exact
+v1 response. V2-aware clients use `/.well-known/nexus-v2.json`, which advertises v1 and v2 proof
+protocols, the `/v2/device` registry base, `nexus.popup.v2` and `nexus.popup.v1`, and a link back to
+the v1 discovery document. Adding v2 MUST NOT widen the legacy response.
+
## Bindings and authority
-- Registry Worker: `IDENTITY_STATE` SQLite Durable Objects and a producer binding to
- `nexus-registry-events`.
-- Projector Worker: Queue consumer, non-authoritative `INDEX_DB` D1, and internal transparency
- binding.
+- Registry Worker: `IDENTITY_STATE` SQLite Durable Objects, `REGISTRY_EVENTS` producing to
+ `nexus-registry-events`, and `REGISTRY_DEVICE_EVENTS` producing to the separate
+ `nexus-registry-device-events` queue.
+- Projector Worker: separate consumers for both queues, non-authoritative `INDEX_DB` D1, and an
+ internal transparency binding. Each queue has its own dead-letter queue.
- Edge Worker: internal `REGISTRY_SERVICE`, coarse rate limiter, and aggregate-only metrics.
- Transparency Worker: shard Durable Objects and R2 checkpoint storage.
- Reference RP Worker: static assets, a coarse API rate limiter, and one SQLite Durable Object for
@@ -87,6 +94,11 @@ Lifecycle mutations and outbox events commit atomically inside the subject Durab
failure may delay projection/transparency, but cannot roll back or weaken revocation. D1 is rebuilt
from events and never queried to authorize current control.
+The identity and device queues are independently at-least-once and unordered. Projector deployment
+must tolerate a v2 device event arriving before its v1 registration anchor, deduplicate each event
+by its deterministic ID, and reconcile materialized device state after the anchor appears. Device
+events are never parsed as v1 lifecycle events and never change the v1 identity sequence.
+
The transparency Worker publishes all 256 shard checkpoints and the ordered global manifest through
its daily UTC cron. There is no public checkpoint-administration endpoint. Alert on failed scheduled
invocations and retain historical public verification keys for every published checkpoint lifetime.
@@ -107,12 +119,17 @@ Cloudflare secrets, logs, environment variables, or storage.
2. Verify no production credentials are present in PR/preview context and resource IDs resolve only
to the target environment.
3. Apply version-controlled D1 migrations to a restored realistic fixture; confirm projector replay,
- duplicate, and out-of-order behavior.
+ duplicate, and out-of-order behavior. Before enabling the device queue consumer, apply
+ `migrations/d1/0002_device_projection_v2.sql`; confirm early device-event reconciliation and that
+ existing v1 tables and projections remain unchanged.
4. Apply Durable Object class/storage migrations using versioned Wrangler migration tags and
application-level schema migration code.
-5. Deploy internal services before the public edge, then the wallet/reference surfaces.
-6. Run smoke checks for discovery/JWKS, register/status/revoke, service receipt verification, outbox
- recovery, and D1 non-authority.
+5. Provision both source queues and their distinct dead-letter queues. Deploy the projector with
+ both consumers, then the registry with both producer bindings. Deploy remaining internal services
+ before the public edge, then the wallet/reference surfaces.
+6. Run smoke checks for exact v1 discovery, versioned v2 discovery, JWKS,
+ register/status/revoke/device operations, service receipt verification, both outboxes and queues,
+ early-event reconciliation, and D1 non-authority.
7. Confirm wallet CSP, exact-origin popup behavior, and absence of key/proof material in network and
logs.
8. Monitor privacy-safe error/latency buckets and outbox/projector lag; do not add identifiers to
diff --git a/docs/protocol/device-delegation-v2.md b/docs/protocol/device-delegation-v2.md
new file mode 100644
index 0000000..c630db6
--- /dev/null
+++ b/docs/protocol/device-delegation-v2.md
@@ -0,0 +1,508 @@
+# Root and Device Delegation Protocol v2
+
+**Status:** Additive protocol profile
+**Wire version:** `v2`
+**Identity anchor:** unchanged `nexus.identity.v1` genesis and `nx1_` subject
+
+This document is normative for the Nexus v2 root/device delegation profile. The v1 identity, proof,
+registry, and popup contracts in [`NEXUS_SPEC.md`](../../NEXUS_SPEC.md) remain normative and
+unchanged. Implementations use the v1 signing-key pair as an identity root and add independently
+generated, individually revocable device signing keys beneath it.
+
+The profile lets several devices prove control of the same `nx1_` subject without copying the root
+private key. It does not create a wallet-wide root, global account, global installation identifier,
+or new subject format.
+
+## Normative invariants
+
+1. Every identity has its own independently generated root key. The root is the Ed25519 signing key
+ already committed to by that identity's `nexus.identity.v1` genesis.
+2. A root private key MUST be created and retained as non-extractable. It MUST NOT enter a device
+ transfer bundle, network request, log, server store, or relying-party proof.
+3. Device private keys MUST be independently CSPRNG-generated Ed25519 keys. They MUST NOT be derived
+ from the root or from another device key.
+4. A device key proves the same identity only through a valid root-signed authorization whose
+ subject and genesis hash recompute to that identity.
+5. A device MAY activate and revoke only its own authorization. It MUST NOT authorize another key,
+ revoke another device, replace the root, or terminally revoke the subject.
+6. The root MAY authorize or revoke any device for its own subject. Root revocation targets the
+ device key identifier and is irreversible, including when the device has not yet activated.
+7. Terminal v1 identity revocation dominates all device state. No device operation may reactivate a
+ terminally revoked subject.
+8. Root and device signing are typed operations. Neither the wallet nor RP-facing SDK exposes a
+ generic raw-sign API.
+9. Public device IDs are subject-and-key-bound authorization identifiers, not physical-device,
+ wallet, controller, account, or cross-subject identifiers. A fresh device key MUST be generated
+ for every identity and intended installation.
+10. All objects use strict versioned schemas, reject unknown fields, use canonical unpadded
+ base64url binary values and integer Unix seconds, and use the v1 RFC 8785 JCS signature
+ construction.
+
+## Compatibility model
+
+V2 preserves the v1 identity anchor:
+
+```text
+IdentityGenesisV1.signingKey -> v2 root public key
+IdentityGenesisV1 -> unchanged
+subject -> unchanged nx1_ value
+v1 identity lifecycle -> unchanged
+```
+
+An existing active v1 identity can opt into v2 on the device that already holds its non-extractable
+v1 signing key. The wallet treats that key as the root, generates a new device key, root-authorizes
+it, and follows the normal activation flow. The existing key is not exported, converted, or
+replaced, and RP records containing `author_subject` do not migrate.
+
+An upgraded wallet SHOULD stop using the root for routine v2 RP proofs. It MAY continue to make a
+`nexus.ownership-proof.v1` with the root for an explicitly negotiated legacy flow. This is a
+compatibility path, not a v2 device proof.
+
+Compatibility has an intentional limit: a v1-only verifier does not understand a device
+authorization chain and MUST reject `nexus.ownership-proof.v2` as unsupported. An updated RP may
+accept v1, v2, or both only when its backend challenge explicitly states the accepted proof
+protocols. It MUST NOT silently downgrade a challenge from v2 to v1 based on browser-supplied data.
+
+| Flow | Required behavior |
+| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
+| Existing v1 proof or endpoint | Remains byte-for-byte and semantically unchanged. |
+| Existing v1 identity enables v2 | Keeps the same genesis, subject, and terminal lifecycle; current signing key becomes the root. |
+| V2 device proves to updated RP | RP verifies the root authorization, device signature, exact challenge, identity state, and exact device state. |
+| V2 device contacts v1-only RP | Fails as unsupported unless that installation also holds the root and the user explicitly chooses a v1 legacy proof. |
+| V1 identity is terminally revoked | Every v2 device is unusable regardless of its own recorded state. |
+
+### Popup negotiation
+
+The `nexus.popup.v1` channel remains unchanged and can return only a v1 proof. Updated clients use
+the additive `nexus.popup.v2` channel. The wallet advertises its ordered `supportedProofProtocols`,
+and the RP sends a non-empty, duplicate-free ordered `acceptedProofProtocols` list containing
+`nexus.ownership-proof.v2`, `nexus.ownership-proof.v1`, or both. The result names the selected
+`proofProtocol` and contains the matching strict proof type.
+
+Popup negotiation is not authorization policy. The RP backend MUST bind the same accepted protocol
+list to its stored challenge and independently reject a result whose protocol was not offered. The
+wallet MUST return `UNSUPPORTED_PROOF_PROTOCOL` when there is no intersection and MUST NOT silently
+fall back to v1.
+
+## Cryptographic encodings and identifiers
+
+Every signature in this profile uses the existing Nexus preimage:
+
+```text
+UTF8("NEXUS-SIGNATURE\0") ||
+UTF8(payload.protocol) ||
+0x00 ||
+UTF8(JCS(payload))
+```
+
+Identifiers are recomputed by consumers; a wire value is never trusted by itself:
+
+```text
+device_id_payload = JCS({
+ subject,
+ signingKey: { alg: "Ed25519", publicKey: device_public_key }
+})
+
+deviceId = "nxd2_" || base64url(SHA256(
+ UTF8("NEXUS-DEVICE-ID\0v2\0") || UTF8(device_id_payload)
+))
+
+authorizationId = "nxa2_" || base64url(SHA256(
+ UTF8("NEXUS-DEVICE-AUTHORIZATION\0v2\0") ||
+ UTF8(JCS(device_authorization_payload))
+))
+
+operationId = "nxo2_" || base64url(SHA256(
+ UTF8("NEXUS-DEVICE-OPERATION\0v2\0") || UTF8(JCS(signed_operation_payload))
+))
+```
+
+`deviceId` identifies one public key under one subject. Reusing the same private key under another
+subject produces a different ID but is forbidden because it creates a correlation and compromise
+link. `authorizationId` identifies the complete root grant, including its nonce and time window.
+`operationId` provides deterministic idempotency for the exact signed activation or revocation
+payload.
+
+## Device authorization
+
+The root signs the complete authorization payload:
+
+```ts
+interface DeviceAuthorizationPayloadV2 {
+ protocol: 'nexus.device-authorization.v2';
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ deviceId: NexusDeviceIdV2; // nxd2_...
+ signingKey: {
+ alg: 'Ed25519';
+ publicKey: Base64Url32;
+ };
+ authorizationNonce: Base64Url32;
+ validFrom: number;
+ activationDeadline: number;
+ expiresAt: number;
+}
+
+interface DeviceAuthorizationV2 {
+ payload: DeviceAuthorizationPayloadV2;
+ rootSignature: Base64Url64;
+}
+```
+
+The root MUST verify or construct the subject from its own stored genesis. It MUST NOT sign a
+caller-selected subject, genesis hash, device ID, or authorization time outside wallet policy.
+Validators require:
+
+```text
+validFrom <= activationDeadline <= expiresAt
+activationDeadline - validFrom <= 30 days
+expiresAt - validFrom <= 366 days
+```
+
+The authorization grants only proof signing, activation of that exact authorization, and
+self-revocation of that exact device. V2 deliberately has no extensible capability array: unknown
+future authority requires a new protocol version.
+
+Renewal uses a new device key, `deviceId`, authorization nonce, and authorization. An expired or
+revoked device key is never reauthorized.
+
+## Device activation
+
+An authorization is not active until the target proves possession of the device private key:
+
+```ts
+interface DeviceActivationPayloadV2 {
+ protocol: 'nexus.device-activation.v2';
+ subject: NexusSubject;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2; // nxa2_...
+ requestId: Base64Url32;
+ iat: number;
+ exp: number;
+}
+
+interface DeviceActivationRequestV2 {
+ authorization: DeviceAuthorizationV2;
+ payload: DeviceActivationPayloadV2;
+ deviceSignature: Base64Url64;
+}
+```
+
+The registry MUST atomically:
+
+1. strict-validate both payloads and reject unknown fields;
+2. require the v1 identity to exist and be active;
+3. derive its genesis hash and compare it with the authorization;
+4. recompute `deviceId` and `authorizationId`;
+5. verify the root signature against `genesis.signingKey`;
+6. enforce the activation and authorization windows using registry time;
+7. require a short-lived activation payload and verify the device signature;
+8. reject a device tombstone, previous revocation, or conflicting authorization; and
+9. commit active device state, the `nxo2_` operation ID, and an outbox event together.
+
+Exact replay is idempotent. A different authorization for an existing device ID fails closed.
+
+## Ownership proof v2
+
+```ts
+interface OwnershipProofPayloadV2 {
+ protocol: 'nexus.ownership-proof.v2';
+ subject: NexusSubject;
+ genesis: IdentityGenesisV1;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ authorization: DeviceAuthorizationV2;
+ aud: string;
+ act: string;
+ resource: string;
+ nonce: Base64UrlAtLeast16;
+ iat: number;
+ exp: number;
+ contextHash?: Base64Url32;
+}
+
+interface OwnershipProofV2 {
+ payload: OwnershipProofPayloadV2;
+ deviceSignature: Base64Url64;
+}
+
+interface VerificationExpectationV2 extends VerificationExpectation {
+ // null requires the proof to omit contextHash; otherwise it must match exactly.
+ contextHash: Base64Url32 | null;
+}
+```
+
+The authorization is embedded so verification is self-contained apart from fresh state. The device
+signature covers the complete nested authorization and RP challenge binding. Verification MUST:
+
+1. dispatch strictly on `nexus.ownership-proof.v2` and strict-validate nested objects;
+2. recompute the `nx1_` subject and genesis hash from the unchanged v1 genesis;
+3. recompute and exact-match `nxd2_` and `nxa2_` identifiers;
+4. verify the root signature using the public key in genesis;
+5. enforce the authorization validity period;
+6. exact-match `aud`, `act`, `resource`, nonce, and `VerificationExpectationV2.contextHash`; a null
+ expectation requires omission rather than accepting any value;
+7. validate the proof time window and verify the device signature;
+8. obtain sufficiently fresh status for the exact `{ subject, deviceId, authorizationId }`;
+9. require both the identity and exact device authorization to be active; and
+10. atomically consume the RP challenge before or transactionally with the protected mutation.
+
+A v1 subject-only status result is insufficient for a v2 current-control decision. RP sessions
+created with v2 MUST retain the device and authorization IDs and recheck that exact authority under
+their documented freshness policy.
+
+The authorization interval is half-open: it is usable at `validFrom` and expired when registry or
+verifier time is `>= expiresAt`. Device authorization expiry receives no clock-skew extension.
+Activation at `activationDeadline` follows the registry's bounded clock-skew policy; proof `exp` and
+RP challenge expiry retain the existing v1 verification policy. An active status statement cannot
+extend an authorization beyond its signed `expiresAt`.
+
+## Per-device revocation
+
+### Self-revocation
+
+```ts
+interface DeviceSelfRevokePayloadV2 {
+ protocol: 'nexus.device-self-revoke.v2';
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ requestId: Base64Url32;
+ issuedAt: number;
+ reasonCode?: 'dispose' | 'key-compromise' | 'lost-device' | 'replaced';
+}
+
+interface DeviceSelfRevokeRequestV2 {
+ authorization: DeviceAuthorizationV2;
+ payload: DeviceSelfRevokePayloadV2;
+ deviceSignature: Base64Url64;
+}
+```
+
+Self-revocation affects only the signing key that verifies the request. The registry MUST accept a
+valid self-revocation even before activation or after authorization expiry because the operation
+only removes authority. It creates a permanent device tombstone.
+
+The installed wallet submits and verifies the signed registry result before best-effort deletion of
+the local device key. Deletion without registry-confirmed revocation MUST NOT be presented as
+completed disposal.
+
+### Root revocation
+
+```ts
+interface DeviceRootRevokePayloadV2 {
+ protocol: 'nexus.device-root-revoke.v2';
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ deviceId: NexusDeviceIdV2;
+ requestId: Base64Url32;
+ issuedAt: number;
+ reasonCode?: 'dispose' | 'key-compromise' | 'lost-device' | 'replaced';
+}
+
+interface DeviceRootRevokeRequestV2 {
+ payload: DeviceRootRevokePayloadV2;
+ rootSignature: Base64Url64;
+}
+```
+
+Root revocation permanently tombstones the `deviceId`, whether active, expired, absent, or pending
+activation. This lets a root invalidate a lost transfer package before it is installed. The root
+does not need the target device's private key or current authorization ID.
+
+Device operations MUST NOT use the v1 identity `expectedSequence`. An offline root cannot know about
+unrelated device changes. Device events instead use operation idempotency and monotonic rules:
+
+- revocation dominates activation;
+- a revoked device never becomes active again;
+- racing root and self-revocations converge on revoked;
+- exact replay returns the original result; and
+- terminal identity revocation dominates every race.
+
+## Authoritative registry state
+
+The existing deterministic per-subject Durable Object remains the sole authority. V2 adds a
+per-device ledger under that subject; it does not change v1 identity state or sequence.
+
+```text
+v1 identity sequence:
+ active = 0
+ terminally revoked = 1
+
+v2 device-ledger sequence:
+ increments for every effective device activation or revocation
+```
+
+Device events MUST use separately versioned v2 event, receipt, status, and outbox schemas. They MUST
+NOT be emitted as v1 identity events because strict v1 consumers require active identity sequence
+`0` and revoked identity sequence `1`.
+
+The reference deployment publishes the v1 outbox through `REGISTRY_EVENTS` to
+`nexus-registry-events` and the device outbox through `REGISTRY_DEVICE_EVENTS` to
+`nexus-registry-device-events`. The projector consumes them independently and applies
+`migrations/d1/0002_device_projection_v2.sql` for its non-authoritative device projection. Because
+the queues are unordered relative to each other, it persists an early device event and defers
+materialized device state until the matching v1 anchor arrives. Each stream has its own dead-letter
+queue and deterministic event-ID deduplication.
+
+Per-device state is monotonic:
+
+```text
+ABSENT --activate--> ACTIVE --self/root revoke--> REVOKED
+ | (terminal)
+ +-------------root/self revoke--------------> REVOKED TOMBSTONE
+
+ACTIVE --authorization time passes--> EXPIRED (derived authorization result)
+```
+
+The registry MUST NOT expose a public endpoint that lists devices for a subject. Status requests
+name the exact device and authorization IDs already present in a proof. A v2 signed status statement
+reports at least the subject, genesis hash, identity state and sequence, device-ledger sequence,
+device and authorization IDs, effective device state, authorization expiry, statement validity, and
+service signing `kid`.
+
+Recommended additive endpoints are:
+
+```text
+POST /v2/device/activate
+POST /v2/device/status
+POST /v2/device/status-batch
+POST /v2/device/revoke-self
+POST /v2/device/revoke-root
+```
+
+All `/v1` routes and wire responses remain unchanged. Terminal identity disposal continues through
+`POST /v1/identity/revoke`.
+
+The same compatibility rule applies to discovery: `/.well-known/nexus.json` retains its exact v1
+shape. V2-aware clients fetch `/.well-known/nexus-v2.json`, which advertises the device registry and
+popup channels and links to the v1 discovery document.
+
+V2 device endpoints use an additive `NexusDeviceError` wire schema. It accepts the closed v1
+protocol-domain error set plus these transport and device codes:
+
+```text
+METHOD_NOT_ALLOWED
+UNSUPPORTED_MEDIA_TYPE
+ORIGIN_NOT_ALLOWED
+HTTPS_REQUIRED
+SERVICE_UNAVAILABLE
+DEVICE_NOT_FOUND
+DEVICE_REVOKED
+DEVICE_AUTHORIZATION_CONFLICT
+```
+
+None of them are added to the strict v1 `NexusError` schema. The five HTTP/Edge transport faults
+pre-exist v2 outside the strict v1 protocol-domain schema; including them in the additive v2 schema
+does not change the v1 wire object. Verifier-only outcomes such as `DEVICE_EXPIRED`, `WRONG_DEVICE`,
+`WRONG_AUTHORIZATION`, and `WRONG_CONTEXT` are not registry wire error codes.
+
+## Offline device transfer
+
+The root may create an exportable device key solely so it can install that device authority on
+another device. This is not identity/root-key export.
+
+The root-created flow is:
+
+1. Generate a fresh device Ed25519 key as temporarily extractable.
+2. Construct and sign its `nexus.device-authorization.v2` authorization.
+3. Export the device private key directly into an authenticated encrypted transfer envelope.
+4. Release the temporary key handle and overwrite transient byte arrays where practical.
+5. On the target, decrypt and strict-validate the bundle locally.
+6. Recompute the subject, root signature, key pair, and identifiers before import.
+7. Import the device private key as non-extractable.
+8. Activate by proving device-key possession to the registry.
+9. Delete the transfer file/code and transient plaintext on a best-effort basis.
+
+The encrypted plaintext contains only the subject, public genesis, authorization, algorithm and
+format metadata, and the device private key. It MUST NOT contain the root private key, revocation
+secret, other identities, RP scope mappings, device labels, or authorization history.
+
+The outer envelope MUST use versioned authenticated encryption such as AES-256-GCM with fresh random
+nonces and authenticated metadata. Key establishment SHOULD use either a destination-generated
+ephemeral X25519 transport public key or a uniformly random transfer code with at least 128 bits of
+entropy expanded with HKDF-SHA-256. An ordinary user password MUST NOT be used as the only
+protection unless a separately versioned, reviewed Argon2id suite defines its parameters and
+recovery UX.
+
+The reference wallet implements the high-entropy transfer-key option with this envelope:
+
+```ts
+interface DeviceTransferEnvelopeV2 {
+ protocol: 'nexus.device-transfer.v2';
+ suite: 'NX-HKDF-SHA256-AES256GCM-v2';
+ bundleId: string; // canonical base64url, 32 bytes
+ salt: string; // canonical base64url, 32 bytes
+ iv: string; // canonical base64url, 12 bytes
+ ciphertext: string; // canonical base64url, includes 128-bit GCM tag
+}
+```
+
+It generates a separate uniformly random 32-byte transfer key, expands the content key with
+HKDF-SHA-256 using `NEXUS-DEVICE-TRANSFER\0v2\0` as `info`, and authenticates the canonical envelope
+header as AES-GCM additional data. The transfer key is delivered over a channel separate from the
+bundle and MUST NOT be embedded in or stored beside it.
+
+The destination-generated alternative is safer: the destination creates a non-extractable device key
+and sends only its public enrollment request to the offline root. The root signs an authorization,
+so no private key is transferred. Implementations SHOULD offer this mode when a one-way offline
+channel is available.
+
+### Transfer is copying, not a guaranteed move
+
+An exported device bundle can be copied before deletion. All copies contain the same private key,
+produce the same `nxd2_` identifier, and are indistinguishable to Nexus. The product MUST NOT claim
+that it can enumerate or individually revoke physical clones. Revoking that device ID invalidates
+every clone. A separate intended installation therefore requires a separately generated key and
+authorization.
+
+## Root storage, upgrade, and loss
+
+A newly created v2 identity SHOULD keep its root in an offline or rarely connected device and use a
+device key even on the root installation for ordinary proofs. Hardware-backed, user-mediated root
+signing is preferred where available.
+
+For an upgraded v1 identity, `extractable:false` prevents a standards-compliant raw key export but
+does not retroactively make the browser installation an isolated offline signer. Wallet-origin
+malicious code may still invoke a browser-held key and some browser threat models may permit a
+usable `CryptoKey` capability to be copied without exposing raw bytes. The UI MUST describe this as
+"existing key used as root," not as a hardware or air-gapped guarantee.
+
+Root loss is not recoverable by a device key. Existing active devices continue only until their
+authorizations expire or the identity is terminally revoked. They may self-revoke, but cannot renew,
+authorize replacements, revoke siblings, or recover the root. The v1 revocation secret, if retained
+separately, can terminally destroy the identity but cannot restore root authority.
+
+## Threats and residual risk
+
+| Threat | Consequence and control |
+| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Root compromise | Attacker can authorize or revoke any device for that subject. Keep root non-extractable, offline/rarely used, typed, and user-mediated. Terminal v1 revocation is the final containment mechanism. |
+| Device compromise | Attacker can make proofs as that subject until exact device status reflects revocation. Use short proofs, fresh status, and root/self revocation. |
+| Transfer-bundle theft | Attacker can install or clone that device. Encrypt locally with high-entropy key establishment and let the root preemptively tombstone the device ID. |
+| Bundle cloning | Physical copies are indistinguishable. Generate one key per installation and revoke the shared device ID to invalidate every clone. |
+| Root loss | No renewal or new device authorization. Existing devices cannot elevate themselves; retained revocation secret only permits terminal destruction. |
+| Registry rollback/staleness | A revoked device could appear active. The per-subject Durable Object is authoritative; D1/KV/Queue state never authorizes current control. |
+| Correlation | A device ID is stable within one subject and can link uses of that subject. Never reuse a device key across subjects; keep labels/platform metadata local and expose no device-list endpoint. |
+| Wallet XSS | Non-extractable keys may still be invoked. Preserve strict CSP, dependency/build controls, origin-derived audience, typed consent, and root isolation. |
+
+## Required negative and concurrency tests
+
+Before production, implementations MUST cover at least:
+
+- root signature, device signature, subject, genesis hash, `nxd2_`, and `nxa2_` mismatch;
+- unknown fields/protocols, malformed canonical base64url, invalid time ordering, expired
+ activation, expired authorization, and excessive proof lifetime;
+- activation without possession, conflicting authorization, activation after tombstone, and
+ attempted reauthorization of a revoked key;
+- self-revocation signed by a sibling device and root revocation signed by a device;
+- v2 proof checked with only v1 subject status;
+- RP challenge replay, cross-origin/action/resource substitution, and downgrade attempts;
+- activation racing root revocation, self-revocation racing root revocation, and either operation
+ racing terminal identity revocation;
+- idempotent exact replay of activation and each revocation mode;
+- transfer tampering, wrong transfer code, key/public mismatch, root material accidentally included,
+ and target import as extractable; and
+- existing v1 identities, proofs, receipts, status statements, and terminal revocation remaining
+ unchanged after v2 support is enabled.
diff --git a/docs/protocol/identity.md b/docs/protocol/identity.md
index 96e2f34..b6f74a6 100644
--- a/docs/protocol/identity.md
+++ b/docs/protocol/identity.md
@@ -61,6 +61,11 @@ an RP or Cloudflare service.
Nexus v1 provides no private-key export, import, backup, recovery, or server-side wallet storage
path. Identity keys remain local and non-extractable in the wallet.
+The additive [v2 root/device profile](./device-delegation-v2.md) does not change that rule for the
+identity key. It treats the unchanged v1 signing key as a non-extractable per-identity root and lets
+it authorize independently generated device signing keys. An existing active v1 identity can adopt
+that workflow without changing its genesis or `nx1_` subject.
+
## Privacy properties and limits
- A wallet may manage many identities, but exposes no stable public controller.
diff --git a/docs/protocol/lifecycle.md b/docs/protocol/lifecycle.md
index f34aed4..3716db6 100644
--- a/docs/protocol/lifecycle.md
+++ b/docs/protocol/lifecycle.md
@@ -9,6 +9,10 @@ NONE --register--> ACTIVE --revoke/dispose--> REVOKED
There is no `REVOKED -> ACTIVE` transition, ownership transfer, recovery into a new key, or
server-side identity claim in v1.
+The additive [v2 root/device profile](./device-delegation-v2.md) adds a separate per-device ledger
+under an active v1 subject. It does not change this identity lifecycle or its sequence. Device
+revocation is independently terminal, and terminal identity revocation dominates every device.
+
## Authority and sequence
One SQLite-backed `IdentityState` Durable Object is addressed deterministically from each
diff --git a/docs/protocol/proofs.md b/docs/protocol/proofs.md
index 3a66ff2..56c4bc5 100644
--- a/docs/protocol/proofs.md
+++ b/docs/protocol/proofs.md
@@ -1,6 +1,8 @@
# Ownership Proofs and Receipts
-This document summarizes the v1 signed-object rules. `NEXUS_SPEC.md` remains normative.
+This document summarizes the v1 signed-object rules. `NEXUS_SPEC.md` remains normative. The
+separately versioned [v2 root/device profile](./device-delegation-v2.md) adds device-authorized
+ownership proofs without changing the v1 proof schema or verifier behavior.
## Signing format
@@ -29,6 +31,11 @@ SDK must not expose arbitrary raw signing.
- `iat` and `exp` integer timestamps;
- optional `contextHash`, normally SHA-256 over the RP-defined request/body context.
+The v1 field remains optional under the existing verifier contract. The v2 verifier instead requires
+an explicit context expectation: a 32-byte expected hash must match exactly, while `null` requires
+the proof to omit `contextHash`. This prevents an integration from accidentally accepting an
+unexamined v2 context value.
+
Proof lifetime should be at most 120 seconds; 60 seconds is the default target. Wildcard audiences
are forbidden.
diff --git a/docs/protocol/relying-party.md b/docs/protocol/relying-party.md
index 2a4de98..5e56ffb 100644
--- a/docs/protocol/relying-party.md
+++ b/docs/protocol/relying-party.md
@@ -51,6 +51,14 @@ valid `stapled-status`.
Nexus does not need to observe subsequent requests made under an RP-local session.
+For an explicitly negotiated `nexus.ownership-proof.v2`, the RP additionally verifies the embedded
+root authorization and device signature and obtains fresh status for the exact subject, `nxd2_`
+device, and `nxa2_` authorization. A v1 subject-only status statement is insufficient. V2 sessions
+retain those identifiers so sibling or root revocation of that device can terminate its authority.
+The RP passes an explicit expected `contextHash` or `null`; omission is not an "accept anything"
+mode. A device authorization is expired at `now >= expiresAt`, regardless of a still-valid cached
+status statement. See the [v2 root/device profile](./device-delegation-v2.md).
+
## Cross-origin tests
Browser E2E uses two distinct origins and proves that one cannot request or replay a proof for the
diff --git a/docs/protocol/threat-model.md b/docs/protocol/threat-model.md
index 1a0c1a6..b64cd37 100644
--- a/docs/protocol/threat-model.md
+++ b/docs/protocol/threat-model.md
@@ -6,8 +6,11 @@ anonymity.
## Privacy invariants
-1. No permanent public controller, wallet, account, installation, or device identifier.
-2. Each disposable identity uses independently generated random private key material.
+1. No permanent public controller, wallet, account, installation, or physical-device identifier. V2
+ `nxd2_` identifiers are scoped to one subject and public key, never reused across subjects, and
+ appear only in explicitly negotiated v2 device operations.
+2. Each disposable identity uses independently generated random private key material. V2 device keys
+ are also independently random and are never derived from an identity root or sibling device.
3. RP-to-identity scope mappings and local labels remain local to the wallet.
4. Genesis contains no RP audience, user metadata, or timestamp.
5. Cross-origin identity reuse requires explicit user confirmation.
@@ -20,18 +23,22 @@ anonymity.
## Threats and controls
-| Threat | Required control |
-| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| D1/R2/operational database compromise | No civil-identity map or server-custodied user keys; R2 contains transparency or restricted operational artifacts; registry state is public cryptographic data only. |
-| Public-key substitution | Subject is the domain-separated SHA-256 hash of canonical immutable genesis; verifiers recompute it. |
-| Proof replay | Exact audience/action/resource binding, short expiry, CSPRNG backend nonce, and atomic single-use consumption. |
-| Cross-application replay | Exact HTTPS `aud` match; wallet derives audience from the sender origin. |
-| Old key after disposal | Strict current lifecycle check rejects terminally revoked subjects even when signatures remain valid. |
-| Malicious RP | Typed proof operations, visible consent context, origin-derived audience, exact-origin response, and no identity-list disclosure. |
-| Wallet-origin XSS | No third-party scripts, strict CSP, pinned/audited dependencies, no eval, and explicit approval. Non-extractable keys do not eliminate signing abuse by XSS. |
-| Correlating service operator | No RP audience at registration, no proof transit through Nexus, minimal short-lived telemetry, and aggregate-only metrics. |
-| Sybil creation | Separate optional rate limits, Turnstile, invitations, or RP policy; never reinterpret anti-abuse as identity authentication. |
-| Nexus service-key compromise | User signatures remain self-certifying; service keys use `kid`, retained public-key history, rotation, and incident statements. |
+| Threat | Required control |
+| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| D1/R2/operational database compromise | No civil-identity map or server-custodied user keys; R2 contains transparency or restricted operational artifacts; registry state is public cryptographic data only. |
+| Public-key substitution | Subject is the domain-separated SHA-256 hash of canonical immutable genesis; verifiers recompute it. |
+| Proof replay | Exact audience/action/resource binding, short expiry, CSPRNG backend nonce, and atomic single-use consumption. |
+| Cross-application replay | Exact HTTPS `aud` match; wallet derives audience from the sender origin. |
+| Old key after disposal | Strict current lifecycle check rejects terminally revoked subjects even when signatures remain valid. |
+| Malicious RP | Typed proof operations, visible consent context, origin-derived audience, exact-origin response, and no identity-list disclosure. |
+| Wallet-origin XSS | No third-party scripts, strict CSP, pinned/audited dependencies, no eval, and explicit approval. Non-extractable keys do not eliminate signing abuse by XSS. |
+| Correlating service operator | No RP audience at registration, no proof transit through Nexus, minimal short-lived telemetry, and aggregate-only metrics. |
+| Sybil creation | Separate optional rate limits, Turnstile, invitations, or RP policy; never reinterpret anti-abuse as identity authentication. |
+| Nexus service-key compromise | User signatures remain self-certifying; service keys use `kid`, retained public-key history, rotation, and incident statements. |
+| Root identity-key compromise | Attacker can authorize or revoke devices only for that subject. Keep the root non-extractable and offline/rarely used; terminal identity revocation remains final containment. |
+| Device-key compromise | Attacker can prove as the subject only through that device's grant. Exact device-status checks and irreversible self/root revocation contain the compromised key without affecting siblings. |
+| Device-transfer theft or cloning | A stolen bundle may install an indistinguishable clone. Use authenticated encryption and high-entropy key establishment; one key per installation; revocation invalidates every clone of that `nxd2_`. |
+| Root loss | Active devices cannot elevate themselves, renew, or authorize replacements. They may self-revoke; a retained v1 revocation secret can destroy but not recover the identity. |
## Trust and limitations
@@ -45,6 +52,8 @@ anonymity.
traffic metadata. Nexus pseudonym unlinkability is not equivalent to network anonymity.
- Nexus does not provide one-human-one-identity, Sybil resistance from cryptography, recoverability
of disposed keys, or reliable physical erasure from browser/OS storage.
+- The v2 profile does not provide root recovery or guaranteed device-key migration. Export is a copy
+ operation; Nexus cannot enumerate or separately revoke physical clones of one device key.
Production is blocked until independent crypto/code review, schema/canonicalization fuzzing,
origin-confusion review, replay/race review, Cloudflare review, service-key incident drill,
diff --git a/migrations/d1/0002_device_projection_v2.sql b/migrations/d1/0002_device_projection_v2.sql
new file mode 100644
index 0000000..a11bc62
--- /dev/null
+++ b/migrations/d1/0002_device_projection_v2.sql
@@ -0,0 +1,80 @@
+-- Device registry events use a separate, additive ledger. They deliberately do
+-- not change the v1 identity lifecycle sequence.
+--
+-- Device events may arrive before the v1 registration event because the two
+-- queues are independently at-least-once and unordered. The event table
+-- therefore has no identity foreign key. Materialized device state is created
+-- only after the matching v1 identity anchor is present.
+CREATE TABLE device_registry_events (
+ event_id TEXT PRIMARY KEY,
+ protocol TEXT NOT NULL CHECK (protocol = 'nexus.device-registry-event.v2'),
+ operation_id TEXT NOT NULL UNIQUE,
+ event_type TEXT NOT NULL CHECK (event_type IN ('activated', 'revoked')),
+ subject TEXT NOT NULL,
+ genesis_hash TEXT NOT NULL,
+ identity_sequence INTEGER NOT NULL CHECK (identity_sequence IN (0, 1)),
+ identity_state TEXT NOT NULL CHECK (identity_state IN ('active', 'revoked')),
+ device_ledger_sequence INTEGER NOT NULL CHECK (device_ledger_sequence >= 1),
+ device_id TEXT NOT NULL,
+ authorization_id TEXT,
+ device_state TEXT NOT NULL CHECK (device_state IN ('active', 'revoked')),
+ authorization_expires_at INTEGER
+ CHECK (authorization_expires_at IS NULL OR authorization_expires_at >= 0),
+ accepted_at INTEGER NOT NULL CHECK (accepted_at >= 0),
+ action_hash TEXT NOT NULL,
+ revoked_by TEXT CHECK (revoked_by IS NULL OR revoked_by IN ('root', 'device')),
+ CHECK (
+ (event_type = 'activated' AND device_state = 'active'
+ AND identity_state = 'active' AND identity_sequence = 0
+ AND authorization_id IS NOT NULL AND authorization_expires_at IS NOT NULL
+ AND revoked_by IS NULL)
+ OR
+ (event_type = 'revoked' AND device_state = 'revoked'
+ AND revoked_by IS NOT NULL)
+ ),
+ UNIQUE (subject, device_ledger_sequence)
+);
+
+-- This is an internal operational projection only. No Worker route enumerates
+-- this table. In particular, it contains no private key, device label,
+-- platform metadata, RP scope, or installation identifier.
+CREATE TABLE device_states (
+ subject TEXT NOT NULL,
+ genesis_hash TEXT NOT NULL,
+ device_id TEXT NOT NULL,
+ authorization_id TEXT,
+ state TEXT NOT NULL CHECK (state IN ('active', 'revoked')),
+ event_sequence INTEGER NOT NULL CHECK (event_sequence >= 1),
+ authorization_expires_at INTEGER
+ CHECK (authorization_expires_at IS NULL OR authorization_expires_at >= 0),
+ activated_at INTEGER CHECK (activated_at IS NULL OR activated_at >= 0),
+ revoked_at INTEGER CHECK (revoked_at IS NULL OR revoked_at >= 0),
+ revoked_by TEXT CHECK (revoked_by IS NULL OR revoked_by IN ('root', 'device')),
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
+ latest_event_id TEXT NOT NULL UNIQUE,
+ PRIMARY KEY (subject, device_id),
+ FOREIGN KEY (subject) REFERENCES identities(subject),
+ FOREIGN KEY (latest_event_id) REFERENCES device_registry_events(event_id),
+ CHECK (
+ (state = 'active' AND authorization_id IS NOT NULL
+ AND authorization_expires_at IS NOT NULL AND activated_at IS NOT NULL
+ AND revoked_at IS NULL AND revoked_by IS NULL)
+ OR
+ (state = 'revoked' AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL)
+ )
+);
+
+CREATE TABLE device_projection_heads (
+ subject TEXT PRIMARY KEY,
+ genesis_hash TEXT NOT NULL,
+ max_device_ledger_sequence INTEGER NOT NULL
+ CHECK (max_device_ledger_sequence >= 1),
+ updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
+ FOREIGN KEY (subject) REFERENCES identities(subject)
+);
+
+CREATE INDEX idx_device_registry_events_subject_device
+ ON device_registry_events(subject, device_id);
+CREATE INDEX idx_device_registry_events_subject_accepted
+ ON device_registry_events(subject, accepted_at);
+CREATE INDEX idx_device_states_state ON device_states(state);
diff --git a/packages/cloudflare-common/src/errors.ts b/packages/cloudflare-common/src/errors.ts
index 4842cc1..58688e9 100644
--- a/packages/cloudflare-common/src/errors.ts
+++ b/packages/cloudflare-common/src/errors.ts
@@ -21,7 +21,15 @@ export const NEXUS_ERROR_CODES = [
'SERVICE_UNAVAILABLE',
] as const;
+export const NEXUS_DEVICE_ERROR_CODES = [
+ ...NEXUS_ERROR_CODES,
+ 'DEVICE_NOT_FOUND',
+ 'DEVICE_REVOKED',
+ 'DEVICE_AUTHORIZATION_CONFLICT',
+] as const;
+
export type NexusErrorCode = (typeof NEXUS_ERROR_CODES)[number];
+export type NexusDeviceErrorCode = (typeof NEXUS_DEVICE_ERROR_CODES)[number];
export interface NexusErrorBody {
readonly error: {
@@ -31,12 +39,20 @@ export interface NexusErrorBody {
};
}
+export interface NexusDeviceErrorBody {
+ readonly error: {
+ readonly code: NexusDeviceErrorCode;
+ readonly message: string;
+ readonly requestId?: string;
+ };
+}
+
interface ErrorDefinition {
readonly status: number;
readonly message: string;
}
-const ERROR_DEFINITIONS: Readonly> = {
+const ERROR_DEFINITIONS: Readonly> = {
BAD_REQUEST: { status: 400, message: 'The request is invalid.' },
UNSUPPORTED_PROTOCOL: { status: 400, message: 'The protocol version is not supported.' },
UNSUPPORTED_SUITE: { status: 400, message: 'The cryptographic suite is not supported.' },
@@ -50,6 +66,12 @@ const ERROR_DEFINITIONS: Readonly> = {
status: 409,
message: 'The subject conflicts with existing genesis data.',
},
+ DEVICE_NOT_FOUND: { status: 404, message: 'The device was not found.' },
+ DEVICE_REVOKED: { status: 409, message: 'The device is revoked.' },
+ DEVICE_AUTHORIZATION_CONFLICT: {
+ status: 409,
+ message: 'The device authorization conflicts with existing state.',
+ },
RATE_LIMITED: { status: 429, message: 'Too many requests.' },
TURNSTILE_REQUIRED: { status: 403, message: 'An anti-abuse check is required.' },
TURNSTILE_INVALID: { status: 403, message: 'The anti-abuse check failed.' },
@@ -72,18 +94,42 @@ export class NexusFault extends Error {
}
}
+export class NexusDeviceFault extends Error {
+ public readonly code: NexusDeviceErrorCode;
+
+ public constructor(code: NexusDeviceErrorCode, options?: { readonly cause?: unknown }) {
+ super(ERROR_DEFINITIONS[code].message, options);
+ this.name = 'NexusDeviceFault';
+ this.code = code;
+ }
+}
+
export function isNexusErrorCode(value: unknown): value is NexusErrorCode {
return typeof value === 'string' && (NEXUS_ERROR_CODES as readonly string[]).includes(value);
}
+export function isNexusDeviceErrorCode(value: unknown): value is NexusDeviceErrorCode {
+ return (
+ typeof value === 'string' && (NEXUS_DEVICE_ERROR_CODES as readonly string[]).includes(value)
+ );
+}
+
export function nexusErrorStatus(code: NexusErrorCode): number {
return ERROR_DEFINITIONS[code].status;
}
+export function nexusDeviceErrorStatus(code: NexusDeviceErrorCode): number {
+ return ERROR_DEFINITIONS[code].status;
+}
+
export function nexusErrorMessage(code: NexusErrorCode): string {
return ERROR_DEFINITIONS[code].message;
}
+export function nexusDeviceErrorMessage(code: NexusDeviceErrorCode): string {
+ return ERROR_DEFINITIONS[code].message;
+}
+
export function faultCode(error: unknown): NexusErrorCode {
if (error instanceof NexusFault) {
return error.code;
@@ -91,6 +137,13 @@ export function faultCode(error: unknown): NexusErrorCode {
return isNexusErrorCode(error) ? error : 'INTERNAL_ERROR';
}
+export function deviceFaultCode(error: unknown): NexusDeviceErrorCode {
+ if (error instanceof NexusDeviceFault || error instanceof NexusFault) {
+ return error.code;
+ }
+ return isNexusDeviceErrorCode(error) ? error : 'INTERNAL_ERROR';
+}
+
export function isNexusRequestId(value: unknown): value is string {
return typeof value === 'string' && /^nxr_[A-Za-z0-9_-]{22}$/u.test(value);
}
@@ -103,6 +156,17 @@ export function createNexusErrorBody(code: NexusErrorCode, requestId?: string):
return { error };
}
+export function createNexusDeviceErrorBody(
+ code: NexusDeviceErrorCode,
+ requestId?: string,
+): NexusDeviceErrorBody {
+ const error = !isNexusRequestId(requestId)
+ ? { code, message: nexusDeviceErrorMessage(code) }
+ : { code, message: nexusDeviceErrorMessage(code), requestId };
+
+ return { error };
+}
+
export function toNexusErrorResponse(
error: unknown,
options: {
@@ -122,3 +186,23 @@ export function toNexusErrorResponse(
headers,
});
}
+
+export function toNexusDeviceErrorResponse(
+ error: unknown,
+ options: {
+ readonly requestId?: string | undefined;
+ readonly headers?: HeadersInit;
+ } = {},
+): Response {
+ const code = deviceFaultCode(error);
+ const headers = new Headers(options.headers);
+ headers.set('Cache-Control', 'no-store');
+ headers.set('Content-Type', 'application/nexus+json');
+ headers.set('Referrer-Policy', 'no-referrer');
+ headers.set('X-Content-Type-Options', 'nosniff');
+
+ return new Response(JSON.stringify(createNexusDeviceErrorBody(code, options.requestId)), {
+ status: nexusDeviceErrorStatus(code),
+ headers,
+ });
+}
diff --git a/packages/cloudflare-common/test/errors.test.ts b/packages/cloudflare-common/test/errors.test.ts
index b7ba1f2..463b368 100644
--- a/packages/cloudflare-common/test/errors.test.ts
+++ b/packages/cloudflare-common/test/errors.test.ts
@@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest';
import {
NexusFault,
+ NEXUS_DEVICE_ERROR_CODES,
+ NEXUS_ERROR_CODES,
+ createNexusDeviceErrorBody,
createNexusErrorBody,
faultCode,
nexusErrorStatus,
+ nexusDeviceErrorStatus,
+ toNexusDeviceErrorResponse,
toNexusErrorResponse,
} from '../src/errors.js';
@@ -45,12 +50,49 @@ describe('Nexus errors', () => {
it('offers typed status and body helpers', () => {
expect(nexusErrorStatus('BODY_TOO_LARGE')).toBe(413);
+ expect(nexusDeviceErrorStatus('DEVICE_NOT_FOUND')).toBe(404);
+ expect(nexusDeviceErrorStatus('DEVICE_REVOKED')).toBe(409);
+ expect(nexusDeviceErrorStatus('DEVICE_AUTHORIZATION_CONFLICT')).toBe(409);
expect(faultCode(new NexusFault('SEQUENCE_CONFLICT'))).toBe('SEQUENCE_CONFLICT');
expect(createNexusErrorBody('BAD_REQUEST')).toEqual({
error: { code: 'BAD_REQUEST', message: 'The request is invalid.' },
});
});
+ it('keeps the v1 error union exact while exposing additive device errors', async () => {
+ expect(NEXUS_ERROR_CODES).toEqual([
+ 'BAD_REQUEST',
+ 'UNSUPPORTED_PROTOCOL',
+ 'UNSUPPORTED_SUITE',
+ 'INVALID_SUBJECT',
+ 'INVALID_SIGNATURE',
+ 'INVALID_REVOCATION_SECRET',
+ 'IDENTITY_NOT_FOUND',
+ 'IDENTITY_REVOKED',
+ 'SEQUENCE_CONFLICT',
+ 'SUBJECT_GENESIS_CONFLICT',
+ 'RATE_LIMITED',
+ 'TURNSTILE_REQUIRED',
+ 'TURNSTILE_INVALID',
+ 'BODY_TOO_LARGE',
+ 'METHOD_NOT_ALLOWED',
+ 'UNSUPPORTED_MEDIA_TYPE',
+ 'ORIGIN_NOT_ALLOWED',
+ 'HTTPS_REQUIRED',
+ 'INTERNAL_ERROR',
+ 'SERVICE_UNAVAILABLE',
+ ]);
+ expect(NEXUS_DEVICE_ERROR_CODES).toContain('DEVICE_NOT_FOUND');
+ expect(createNexusDeviceErrorBody('DEVICE_REVOKED')).toEqual({
+ error: { code: 'DEVICE_REVOKED', message: 'The device is revoked.' },
+ });
+ const response = toNexusDeviceErrorResponse('DEVICE_AUTHORIZATION_CONFLICT');
+ expect(response.status).toBe(409);
+ await expect(response.json()).resolves.toMatchObject({
+ error: { code: 'DEVICE_AUTHORIZATION_CONFLICT' },
+ });
+ });
+
it('accepts a typed code directly without accepting arbitrary strings', async () => {
const response = toNexusErrorResponse('RATE_LIMITED');
expect(response.status).toBe(429);
diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts
index 718f685..deb901e 100644
--- a/packages/crypto/src/index.ts
+++ b/packages/crypto/src/index.ts
@@ -18,6 +18,10 @@ export {
createRegistryEventHashPreimage,
createRevocationCommitmentPreimage,
deriveGenesisHash,
+ deriveDeviceAuthorizationIdV2,
+ deriveDeviceIdV2,
+ deriveDeviceOperationIdV2,
+ deriveDeviceRegistryEventIdV2,
deriveRegistryEventId,
deriveSubject,
signProtocolPayload,
diff --git a/packages/crypto/src/protocol.ts b/packages/crypto/src/protocol.ts
index a071407..c62b8de 100644
--- a/packages/crypto/src/protocol.ts
+++ b/packages/crypto/src/protocol.ts
@@ -1,4 +1,8 @@
import {
+ createDeviceAuthorizationHashPreimageV2,
+ createDeviceIdHashPreimageV2,
+ createDeviceOperationHashPreimageV2,
+ createDeviceRegistryEventHashPreimageV2,
createGenesisHashPreimage as createCanonicalGenesisHashPreimage,
createRegistryEventHashPreimage as createCanonicalRegistryEventHashPreimage,
createRevocationCommitmentPreimage as createCanonicalRevocationCommitmentPreimage,
@@ -7,12 +11,24 @@ import {
ED25519_SIGNATURE_BYTE_LENGTH,
encodeBase64Url,
formatRegistryEventId,
+ formatDeviceAuthorizationIdV2,
+ formatDeviceIdV2,
+ formatDeviceOperationIdV2,
+ formatDeviceRegistryEventIdV2,
formatSubject,
SHA256_BYTE_LENGTH,
} from '@nexus/protocol';
import type {
+ DeviceAuthorizationPayloadV2,
+ DeviceIdInputV2,
+ DeviceOperationPayloadV2,
+ DeviceRegistryEventWithoutEventIdV2,
IdentityGenesisV1,
NexusEventId,
+ NexusDeviceAuthorizationIdV2,
+ NexusDeviceEventIdV2,
+ NexusDeviceIdV2,
+ NexusDeviceOperationIdV2,
NexusSubject,
RegistryEventWithoutEventIdV1,
} from '@nexus/protocol';
@@ -123,3 +139,44 @@ export async function deriveRegistryEventId(
eventId: formatRegistryEventId(eventHash),
};
}
+
+export async function deriveDeviceIdV2(
+ input: DeviceIdInputV2,
+ provider: CryptoProvider = getDefaultCryptoProvider(),
+): Promise {
+ const hash = await provider.sha256(createDeviceIdHashPreimageV2(input));
+ requireExactLength(hash, SHA256_BYTE_LENGTH, 'Device ID hash');
+ return formatDeviceIdV2(hash);
+}
+
+export async function deriveDeviceAuthorizationIdV2(
+ payload: DeviceAuthorizationPayloadV2,
+ provider: CryptoProvider = getDefaultCryptoProvider(),
+): Promise {
+ const hash = await provider.sha256(createDeviceAuthorizationHashPreimageV2(payload));
+ requireExactLength(hash, SHA256_BYTE_LENGTH, 'Device authorization hash');
+ return formatDeviceAuthorizationIdV2(hash);
+}
+
+export async function deriveDeviceOperationIdV2(
+ signedPayload: DeviceOperationPayloadV2,
+ provider: CryptoProvider = getDefaultCryptoProvider(),
+): Promise {
+ const hash = await provider.sha256(createDeviceOperationHashPreimageV2(signedPayload));
+ requireExactLength(hash, SHA256_BYTE_LENGTH, 'Device operation hash');
+ return formatDeviceOperationIdV2(hash);
+}
+
+export async function deriveDeviceRegistryEventIdV2(
+ eventWithoutEventId: DeviceRegistryEventWithoutEventIdV2,
+ provider: CryptoProvider = getDefaultCryptoProvider(),
+): Promise<{
+ readonly eventHash: Uint8Array;
+ readonly eventId: NexusDeviceEventIdV2;
+}> {
+ const eventHash = await provider.sha256(
+ createDeviceRegistryEventHashPreimageV2(eventWithoutEventId),
+ );
+ requireExactLength(eventHash, SHA256_BYTE_LENGTH, 'Device registry event hash');
+ return { eventHash, eventId: formatDeviceRegistryEventIdV2(eventHash) };
+}
diff --git a/packages/crypto/test/crypto.test.ts b/packages/crypto/test/crypto.test.ts
index eb6df30..b8c2ba5 100644
--- a/packages/crypto/test/crypto.test.ts
+++ b/packages/crypto/test/crypto.test.ts
@@ -4,11 +4,22 @@ import {
concatBytes,
constantTimeEqual,
createProtocolSignaturePreimage,
+ deriveDeviceAuthorizationIdV2,
+ deriveDeviceIdV2,
+ deriveDeviceOperationIdV2,
signProtocolPayload,
utf8Encode,
verifyProtocolPayload,
WebCryptoProvider,
} from '../src/index.js';
+import {
+ DEVICE_ACTIVATION_PROTOCOL_V2,
+ DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ ED25519_ALGORITHM,
+ encodeBase64Url,
+ formatSubject,
+} from '@nexus/protocol';
+import type { Base64Url32, DeviceAuthorizationPayloadV2 } from '@nexus/protocol';
const provider = new WebCryptoProvider();
@@ -71,6 +82,45 @@ describe('WebCryptoProvider', () => {
).toEqual(createProtocolSignaturePreimage(payload));
});
+ it('derives deterministic and domain-separated v2 device identifiers', async () => {
+ const subject = formatSubject(new Uint8Array(32).fill(0x11));
+ const publicKey = encodeBase64Url(new Uint8Array(32).fill(0x22)) as Base64Url32;
+ const deviceInput = {
+ subject,
+ signingKey: { alg: ED25519_ALGORITHM, publicKey },
+ } as const;
+ const deviceId = await deriveDeviceIdV2(deviceInput, provider);
+ const authorization: DeviceAuthorizationPayloadV2 = {
+ protocol: DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ ...deviceInput,
+ genesisHash: publicKey,
+ deviceId,
+ authorizationNonce: publicKey,
+ validFrom: 1,
+ activationDeadline: 2,
+ expiresAt: 3,
+ };
+
+ await expect(deriveDeviceIdV2(deviceInput, provider)).resolves.toBe(deviceId);
+ await expect(deriveDeviceAuthorizationIdV2(authorization, provider)).resolves.toMatch(
+ /^nxa2_[A-Za-z0-9_-]{43}$/u,
+ );
+ await expect(
+ deriveDeviceOperationIdV2(
+ {
+ protocol: DEVICE_ACTIVATION_PROTOCOL_V2,
+ subject,
+ deviceId,
+ authorizationId: await deriveDeviceAuthorizationIdV2(authorization, provider),
+ requestId: publicKey,
+ iat: 1,
+ exp: 2,
+ },
+ provider,
+ ),
+ ).resolves.toMatch(/^nxo2_[A-Za-z0-9_-]{43}$/u);
+ });
+
it('derives HKDF-SHA-256 output deterministically', async () => {
const parameters = {
inputKeyMaterial: new Uint8Array(22).fill(0x0b),
diff --git a/packages/protocol/package.json b/packages/protocol/package.json
index 61d4b76..08873bf 100644
--- a/packages/protocol/package.json
+++ b/packages/protocol/package.json
@@ -2,7 +2,7 @@
"name": "@nexus/protocol",
"version": "0.1.0",
"type": "module",
- "description": "Canonical Nexus v1 wire types, schemas, encodings, and cryptographic preimages.",
+ "description": "Canonical Nexus v1 and v2 wire types, schemas, encodings, and cryptographic preimages.",
"license": "MIT",
"publishConfig": {
"access": "public",
diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts
index aa30231..fb4a12d 100644
--- a/packages/protocol/src/constants.ts
+++ b/packages/protocol/src/constants.ts
@@ -14,6 +14,17 @@ export const TRANSPARENCY_INCLUSION_PROOF_PROTOCOL_V1 =
export const TRANSPARENCY_GLOBAL_CHECKPOINT_PROTOCOL_V1 =
'nexus.transparency-global-checkpoint.v1' as const;
+// Nexus v2 keeps IdentityGenesisV1 as the stable root anchor and adds
+// independently revocable, root-authorized device keys.
+export const DEVICE_AUTHORIZATION_PROTOCOL_V2 = 'nexus.device-authorization.v2' as const;
+export const DEVICE_ACTIVATION_PROTOCOL_V2 = 'nexus.device-activation.v2' as const;
+export const DEVICE_SELF_REVOKE_PROTOCOL_V2 = 'nexus.device-self-revoke.v2' as const;
+export const DEVICE_ROOT_REVOKE_PROTOCOL_V2 = 'nexus.device-root-revoke.v2' as const;
+export const OWNERSHIP_PROOF_PROTOCOL_V2 = 'nexus.ownership-proof.v2' as const;
+export const DEVICE_REGISTRY_EVENT_PROTOCOL_V2 = 'nexus.device-registry-event.v2' as const;
+export const DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2 = 'nexus.device-registry-receipt.v2' as const;
+export const DEVICE_STATUS_STATEMENT_PROTOCOL_V2 = 'nexus.device-status-statement.v2' as const;
+
// Compatibility names used by the transparency Worker before this wire
// contract moved into @nexus/protocol.
export const TRANSPARENCY_CHECKPOINT_PROTOCOL = TRANSPARENCY_CHECKPOINT_PROTOCOL_V1;
@@ -28,9 +39,17 @@ export const SIGNATURE_DOMAIN = 'NEXUS-SIGNATURE\0' as const;
export const GENESIS_HASH_DOMAIN = 'NEXUS-IDENTITY-GENESIS\0v1\0' as const;
export const REVOCATION_COMMITMENT_DOMAIN = 'NEXUS-REVOCATION-COMMITMENT\0v1\0' as const;
export const REGISTRY_EVENT_HASH_DOMAIN = 'NEXUS-REGISTRY-EVENT\0v1\0' as const;
+export const DEVICE_ID_HASH_DOMAIN_V2 = 'NEXUS-DEVICE-ID\0v2\0' as const;
+export const DEVICE_AUTHORIZATION_HASH_DOMAIN_V2 = 'NEXUS-DEVICE-AUTHORIZATION\0v2\0' as const;
+export const DEVICE_OPERATION_HASH_DOMAIN_V2 = 'NEXUS-DEVICE-OPERATION\0v2\0' as const;
+export const DEVICE_REGISTRY_EVENT_HASH_DOMAIN_V2 = 'NEXUS-DEVICE-REGISTRY-EVENT\0v2\0' as const;
export const NEXUS_SUBJECT_PREFIX = 'nx1_' as const;
export const NEXUS_EVENT_ID_PREFIX = 'nxe1_' as const;
+export const NEXUS_DEVICE_ID_PREFIX_V2 = 'nxd2_' as const;
+export const NEXUS_DEVICE_AUTHORIZATION_ID_PREFIX_V2 = 'nxa2_' as const;
+export const NEXUS_DEVICE_OPERATION_ID_PREFIX_V2 = 'nxo2_' as const;
+export const NEXUS_DEVICE_EVENT_ID_PREFIX_V2 = 'nxde2_' as const;
export const SHA256_BYTE_LENGTH = 32 as const;
export const ED25519_PUBLIC_KEY_BYTE_LENGTH = 32 as const;
@@ -40,6 +59,8 @@ export const REVOCATION_SECRET_BYTE_LENGTH = 32 as const;
export const MIN_NONCE_BYTE_LENGTH = 16 as const;
export const MAX_NONCE_BYTE_LENGTH = 64 as const;
export const MAX_PROOF_LIFETIME_SECONDS = 120 as const;
+export const MAX_DEVICE_ACTIVATION_WINDOW_SECONDS = 30 * 24 * 60 * 60;
+export const MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS = 366 * 24 * 60 * 60;
export const MAX_ACTION_LENGTH = 128 as const;
export const MAX_RESOURCE_LENGTH = 512 as const;
@@ -68,3 +89,15 @@ export const NEXUS_ERROR_CODES = [
'BODY_TOO_LARGE',
'INTERNAL_ERROR',
] as const;
+
+export const NEXUS_DEVICE_ERROR_CODES = [
+ ...NEXUS_ERROR_CODES,
+ 'METHOD_NOT_ALLOWED',
+ 'UNSUPPORTED_MEDIA_TYPE',
+ 'ORIGIN_NOT_ALLOWED',
+ 'HTTPS_REQUIRED',
+ 'SERVICE_UNAVAILABLE',
+ 'DEVICE_NOT_FOUND',
+ 'DEVICE_REVOKED',
+ 'DEVICE_AUTHORIZATION_CONFLICT',
+] as const;
diff --git a/packages/protocol/src/identifiers.ts b/packages/protocol/src/identifiers.ts
index 90f7616..d4b6c25 100644
--- a/packages/protocol/src/identifiers.ts
+++ b/packages/protocol/src/identifiers.ts
@@ -1,6 +1,21 @@
-import { NEXUS_EVENT_ID_PREFIX, NEXUS_SUBJECT_PREFIX, SHA256_BYTE_LENGTH } from './constants.js';
+import {
+ NEXUS_DEVICE_AUTHORIZATION_ID_PREFIX_V2,
+ NEXUS_DEVICE_EVENT_ID_PREFIX_V2,
+ NEXUS_DEVICE_ID_PREFIX_V2,
+ NEXUS_DEVICE_OPERATION_ID_PREFIX_V2,
+ NEXUS_EVENT_ID_PREFIX,
+ NEXUS_SUBJECT_PREFIX,
+ SHA256_BYTE_LENGTH,
+} from './constants.js';
import { encodeBase64Url } from './base64url.js';
-import type { NexusEventId, NexusSubject } from './types.js';
+import type {
+ NexusDeviceAuthorizationIdV2,
+ NexusDeviceEventIdV2,
+ NexusDeviceIdV2,
+ NexusDeviceOperationIdV2,
+ NexusEventId,
+ NexusSubject,
+} from './types.js';
function requireHash32(hash: Uint8Array): void {
if (hash.length !== SHA256_BYTE_LENGTH) {
@@ -17,3 +32,25 @@ export function formatRegistryEventId(eventHash: Uint8Array): NexusEventId {
requireHash32(eventHash);
return `${NEXUS_EVENT_ID_PREFIX}${encodeBase64Url(eventHash)}`;
}
+
+export function formatDeviceIdV2(deviceHash: Uint8Array): NexusDeviceIdV2 {
+ requireHash32(deviceHash);
+ return `${NEXUS_DEVICE_ID_PREFIX_V2}${encodeBase64Url(deviceHash)}`;
+}
+
+export function formatDeviceAuthorizationIdV2(
+ authorizationHash: Uint8Array,
+): NexusDeviceAuthorizationIdV2 {
+ requireHash32(authorizationHash);
+ return `${NEXUS_DEVICE_AUTHORIZATION_ID_PREFIX_V2}${encodeBase64Url(authorizationHash)}`;
+}
+
+export function formatDeviceOperationIdV2(operationHash: Uint8Array): NexusDeviceOperationIdV2 {
+ requireHash32(operationHash);
+ return `${NEXUS_DEVICE_OPERATION_ID_PREFIX_V2}${encodeBase64Url(operationHash)}`;
+}
+
+export function formatDeviceRegistryEventIdV2(eventHash: Uint8Array): NexusDeviceEventIdV2 {
+ requireHash32(eventHash);
+ return `${NEXUS_DEVICE_EVENT_ID_PREFIX_V2}${encodeBase64Url(eventHash)}`;
+}
diff --git a/packages/protocol/src/preimages.ts b/packages/protocol/src/preimages.ts
index ea1f5e2..1da6fe3 100644
--- a/packages/protocol/src/preimages.ts
+++ b/packages/protocol/src/preimages.ts
@@ -1,4 +1,8 @@
import {
+ DEVICE_AUTHORIZATION_HASH_DOMAIN_V2,
+ DEVICE_ID_HASH_DOMAIN_V2,
+ DEVICE_OPERATION_HASH_DOMAIN_V2,
+ DEVICE_REGISTRY_EVENT_HASH_DOMAIN_V2,
GENESIS_HASH_DOMAIN,
REGISTRY_EVENT_HASH_DOMAIN,
REVOCATION_COMMITMENT_DOMAIN,
@@ -7,6 +11,10 @@ import {
} from './constants.js';
import { canonicalizeToBytes } from './canonical.js';
import type {
+ DeviceAuthorizationPayloadV2,
+ DeviceIdInputV2,
+ DeviceOperationPayloadV2,
+ DeviceRegistryEventWithoutEventIdV2,
GlobalTransparencyCheckpointPayloadV1,
IdentityGenesisV1,
RegistryEventWithoutEventIdV1,
@@ -63,6 +71,37 @@ export function createRegistryEventHashPreimage(
);
}
+export function createDeviceIdHashPreimageV2(input: DeviceIdInputV2): Uint8Array {
+ return concatenate(textEncoder.encode(DEVICE_ID_HASH_DOMAIN_V2), canonicalizeToBytes(input));
+}
+
+export function createDeviceAuthorizationHashPreimageV2(
+ payload: DeviceAuthorizationPayloadV2,
+): Uint8Array {
+ return concatenate(
+ textEncoder.encode(DEVICE_AUTHORIZATION_HASH_DOMAIN_V2),
+ canonicalizeToBytes(payload),
+ );
+}
+
+export function createDeviceOperationHashPreimageV2(
+ signedPayload: DeviceOperationPayloadV2,
+): Uint8Array {
+ return concatenate(
+ textEncoder.encode(DEVICE_OPERATION_HASH_DOMAIN_V2),
+ canonicalizeToBytes(signedPayload),
+ );
+}
+
+export function createDeviceRegistryEventHashPreimageV2(
+ eventWithoutEventId: DeviceRegistryEventWithoutEventIdV2,
+): Uint8Array {
+ return concatenate(
+ textEncoder.encode(DEVICE_REGISTRY_EVENT_HASH_DOMAIN_V2),
+ canonicalizeToBytes(eventWithoutEventId),
+ );
+}
+
export function createTransparencyCheckpointSignaturePreimage(
payload: TransparencyCheckpointPayloadV1,
): Uint8Array {
diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts
index 12f5aad..c2f81be 100644
--- a/packages/protocol/src/schemas.ts
+++ b/packages/protocol/src/schemas.ts
@@ -3,12 +3,21 @@ import type { ZodType } from 'zod';
import {
CONTINUITY_LINK_PROTOCOL_V1,
+ DEVICE_ACTIVATION_PROTOCOL_V2,
+ DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ DEVICE_REGISTRY_EVENT_PROTOCOL_V2,
+ DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ DEVICE_ROOT_REVOKE_PROTOCOL_V2,
+ DEVICE_SELF_REVOKE_PROTOCOL_V2,
+ DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
ED25519_ALGORITHM,
ED25519_SIGNATURE_BYTE_LENGTH,
EDDSA_JWK_ALGORITHM,
IDENTITY_PROTOCOL_V1,
MAX_ACTION_LENGTH,
MAX_AUDIENCE_LENGTH,
+ MAX_DEVICE_ACTIVATION_WINDOW_SECONDS,
+ MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS,
MAX_NONCE_BYTE_LENGTH,
MAX_RESOURCE_LENGTH,
MAX_SCOPE_LENGTH,
@@ -18,10 +27,16 @@ import {
MAX_TURNSTILE_TOKEN_LENGTH,
MIN_NONCE_BYTE_LENGTH,
NEXUS_ERROR_CODES,
+ NEXUS_DEVICE_ERROR_CODES,
+ NEXUS_DEVICE_AUTHORIZATION_ID_PREFIX_V2,
+ NEXUS_DEVICE_EVENT_ID_PREFIX_V2,
+ NEXUS_DEVICE_ID_PREFIX_V2,
+ NEXUS_DEVICE_OPERATION_ID_PREFIX_V2,
NEXUS_EVENT_ID_PREFIX,
NEXUS_SUBJECT_PREFIX,
NEXUS_SUITE_V1,
OWNERSHIP_PROOF_PROTOCOL_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V2,
REGISTRY_EVENT_PROTOCOL_V1,
REGISTRY_RECEIPT_PROTOCOL_V1,
REVOKE_PROTOCOL_V1,
@@ -42,15 +57,40 @@ import type {
ContinuityLinkPayloadV1,
ContinuityLinkV1,
CreateIdentityResult,
+ DeviceActivationPayloadV2,
+ DeviceActivationRequestV2,
+ DeviceAuthorizationPayloadV2,
+ DeviceAuthorizationV2,
+ DeviceRegistryEventV2,
+ DeviceRegistryEventWithoutEventIdV2,
+ DeviceRegistryReceiptPayloadV2,
+ DeviceRegistryReceiptV2,
+ DeviceRootRevokePayloadV2,
+ DeviceRootRevokeRequestV2,
+ DeviceSelfRevokePayloadV2,
+ DeviceSelfRevokeRequestV2,
+ DeviceStatusStatementPayloadV2,
+ DeviceStatusStatementV2,
+ DeviceStatusBatchRequestV2,
+ DeviceStatusBatchResponseV2,
+ DeviceStatusRequestV2,
+ DeviceRegistryStatusV2,
Ed25519PublicJwk,
GlobalCheckpointShardV1,
GlobalTransparencyCheckpointPayloadV1,
IdentityGenesisV1,
NexusError,
+ NexusDeviceError,
+ NexusDeviceAuthorizationIdV2,
+ NexusDeviceEventIdV2,
+ NexusDeviceIdV2,
+ NexusDeviceOperationIdV2,
NexusEventId,
NexusSubject,
OwnershipProofPayloadV1,
OwnershipProofV1,
+ OwnershipProofPayloadV2,
+ OwnershipProofV2,
ProofRequest,
RegisterIdentityRequestV1,
RegistryEventV1,
@@ -73,6 +113,7 @@ import type {
TransparencyCheckpointPayloadV1,
TransparencyInclusionProofV1,
VerificationExpectation,
+ VerificationExpectationV2,
} from './types.js';
const PRINTABLE_ASCII_PATTERN = /^[\x20-\x7e]+$/u;
@@ -113,6 +154,17 @@ function isEventId(value: unknown): value is NexusEventId {
);
}
+function isPrefixedHash(
+ value: unknown,
+ prefix: Prefix,
+): value is `${Prefix}${string}` {
+ return (
+ typeof value === 'string' &&
+ value.startsWith(prefix) &&
+ isExactBase64Url(value.slice(prefix.length), SHA256_BYTE_LENGTH)
+ );
+}
+
function printableAscii(maximumLength: number): z.ZodString {
return z
.string()
@@ -163,6 +215,29 @@ export const nexusEventIdSchema: ZodType = z.custom(
'Expected a canonical nxe1_ event ID containing a 32-byte hash.',
);
+export const nexusDeviceIdV2Schema: ZodType = z.custom(
+ (value) => isPrefixedHash(value, NEXUS_DEVICE_ID_PREFIX_V2),
+ 'Expected a canonical nxd2_ device ID containing a 32-byte hash.',
+);
+
+export const nexusDeviceAuthorizationIdV2Schema: ZodType =
+ z.custom(
+ (value) => isPrefixedHash(value, NEXUS_DEVICE_AUTHORIZATION_ID_PREFIX_V2),
+ 'Expected a canonical nxa2_ device authorization ID containing a 32-byte hash.',
+ );
+
+export const nexusDeviceOperationIdV2Schema: ZodType =
+ z.custom(
+ (value) => isPrefixedHash(value, NEXUS_DEVICE_OPERATION_ID_PREFIX_V2),
+ 'Expected a canonical nxo2_ device operation ID containing a 32-byte hash.',
+ );
+
+export const nexusDeviceEventIdV2Schema: ZodType =
+ z.custom(
+ (value) => isPrefixedHash(value, NEXUS_DEVICE_EVENT_ID_PREFIX_V2),
+ 'Expected a canonical nxde2_ device event ID containing a 32-byte hash.',
+ );
+
export const audienceOriginSchema: ZodType = printableAscii(MAX_AUDIENCE_LENGTH).refine(
isHttpsOrigin,
'Audience must be a canonical HTTPS origin without path, query, fragment, credentials, or wildcard.',
@@ -212,6 +287,197 @@ export const ownershipProofV1Schema: ZodType = z.strictObject(
signature: base64Url64Schema,
}) satisfies z.ZodType;
+export const deviceSigningKeyV2Schema = z.strictObject({
+ alg: z.literal(ED25519_ALGORITHM),
+ publicKey: base64Url32Schema,
+});
+
+export const deviceIdInputV2Schema = z.strictObject({
+ subject: nexusSubjectSchema,
+ signingKey: deviceSigningKeyV2Schema,
+});
+
+export const deviceAuthorizationPayloadV2Schema: ZodType = z
+ .strictObject({
+ protocol: z.literal(DEVICE_AUTHORIZATION_PROTOCOL_V2),
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ deviceId: nexusDeviceIdV2Schema,
+ signingKey: deviceSigningKeyV2Schema,
+ authorizationNonce: base64Url32Schema,
+ validFrom: safeNonNegativeIntegerSchema,
+ activationDeadline: safeNonNegativeIntegerSchema,
+ expiresAt: safeNonNegativeIntegerSchema,
+ })
+ .superRefine((payload, context) => {
+ if (payload.validFrom > payload.activationDeadline) {
+ context.addIssue({
+ code: 'custom',
+ message: 'validFrom must not exceed activationDeadline.',
+ path: ['activationDeadline'],
+ });
+ }
+ if (payload.activationDeadline > payload.expiresAt) {
+ context.addIssue({
+ code: 'custom',
+ message: 'activationDeadline must not exceed expiresAt.',
+ path: ['expiresAt'],
+ });
+ }
+ if (payload.activationDeadline - payload.validFrom > MAX_DEVICE_ACTIVATION_WINDOW_SECONDS) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Device activation window must not exceed 30 days.',
+ path: ['activationDeadline'],
+ });
+ }
+ if (payload.expiresAt - payload.validFrom > MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Device authorization lifetime must not exceed 366 days.',
+ path: ['expiresAt'],
+ });
+ }
+ }) satisfies z.ZodType;
+
+export const deviceAuthorizationV2Schema: ZodType = z.strictObject({
+ payload: deviceAuthorizationPayloadV2Schema,
+ rootSignature: base64Url64Schema,
+}) satisfies z.ZodType;
+
+export const deviceActivationPayloadV2Schema: ZodType = z
+ .strictObject({
+ protocol: z.literal(DEVICE_ACTIVATION_PROTOCOL_V2),
+ subject: nexusSubjectSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+ requestId: base64Url32Schema,
+ iat: safeNonNegativeIntegerSchema,
+ exp: safeNonNegativeIntegerSchema,
+ })
+ .refine((payload) => payload.iat <= payload.exp, {
+ message: 'iat must be less than or equal to exp.',
+ path: ['exp'],
+ }) satisfies z.ZodType;
+
+export const deviceActivationRequestV2Schema: ZodType = z
+ .strictObject({
+ authorization: deviceAuthorizationV2Schema,
+ payload: deviceActivationPayloadV2Schema,
+ deviceSignature: base64Url64Schema,
+ })
+ .superRefine((request, context) => {
+ const authorization = request.authorization.payload;
+ if (request.payload.subject !== authorization.subject) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Activation subject must match the authorization subject.',
+ path: ['payload', 'subject'],
+ });
+ }
+ if (request.payload.deviceId !== authorization.deviceId) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Activation deviceId must match the authorization deviceId.',
+ path: ['payload', 'deviceId'],
+ });
+ }
+ }) satisfies z.ZodType;
+export const deviceActivationV2Schema = deviceActivationRequestV2Schema;
+
+export const deviceSelfRevokePayloadV2Schema: ZodType = z.strictObject({
+ protocol: z.literal(DEVICE_SELF_REVOKE_PROTOCOL_V2),
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+ requestId: base64Url32Schema,
+ issuedAt: safeNonNegativeIntegerSchema,
+ reasonCode: z.enum(['dispose', 'key-compromise', 'lost-device', 'replaced']).optional(),
+}) satisfies z.ZodType;
+
+export const deviceSelfRevokeRequestV2Schema: ZodType = z
+ .strictObject({
+ authorization: deviceAuthorizationV2Schema,
+ payload: deviceSelfRevokePayloadV2Schema,
+ deviceSignature: base64Url64Schema,
+ })
+ .superRefine((request, context) => {
+ const authorization = request.authorization.payload;
+ for (const key of ['subject', 'genesisHash', 'deviceId'] as const) {
+ if (request.payload[key] !== authorization[key]) {
+ context.addIssue({
+ code: 'custom',
+ message: `Self-revocation ${key} must match the authorization.`,
+ path: ['payload', key],
+ });
+ }
+ }
+ }) satisfies z.ZodType;
+export const deviceSelfRevokeV2Schema = deviceSelfRevokeRequestV2Schema;
+
+export const deviceRootRevokePayloadV2Schema: ZodType = z.strictObject({
+ protocol: z.literal(DEVICE_ROOT_REVOKE_PROTOCOL_V2),
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ deviceId: nexusDeviceIdV2Schema,
+ requestId: base64Url32Schema,
+ issuedAt: safeNonNegativeIntegerSchema,
+ reasonCode: z.enum(['dispose', 'key-compromise', 'lost-device', 'replaced']).optional(),
+}) satisfies z.ZodType;
+
+export const deviceRootRevokeRequestV2Schema: ZodType = z.strictObject({
+ payload: deviceRootRevokePayloadV2Schema,
+ rootSignature: base64Url64Schema,
+}) satisfies z.ZodType;
+export const deviceRootRevokeV2Schema = deviceRootRevokeRequestV2Schema;
+
+export const ownershipProofPayloadV2Schema: ZodType = z
+ .strictObject({
+ protocol: z.literal(OWNERSHIP_PROOF_PROTOCOL_V2),
+ subject: nexusSubjectSchema,
+ genesis: identityGenesisV1Schema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+ authorization: deviceAuthorizationV2Schema,
+ aud: audienceOriginSchema,
+ act: actionSchema,
+ resource: resourceSchema,
+ nonce: base64UrlAtLeast16Schema,
+ iat: safeNonNegativeIntegerSchema,
+ exp: safeNonNegativeIntegerSchema,
+ contextHash: base64Url32Schema.optional(),
+ })
+ .superRefine((proof, context) => {
+ if (proof.iat > proof.exp) {
+ context.addIssue({
+ code: 'custom',
+ message: 'iat must be less than or equal to exp.',
+ path: ['exp'],
+ });
+ }
+ const authorization = proof.authorization.payload;
+ if (proof.subject !== authorization.subject) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Proof subject must match the authorization subject.',
+ path: ['subject'],
+ });
+ }
+ if (proof.deviceId !== authorization.deviceId) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Proof deviceId must match the authorization deviceId.',
+ path: ['deviceId'],
+ });
+ }
+ }) satisfies z.ZodType;
+
+export const ownershipProofV2Schema: ZodType = z.strictObject({
+ payload: ownershipProofPayloadV2Schema,
+ deviceSignature: base64Url64Schema,
+}) satisfies z.ZodType;
+
export const revokeBySignaturePayloadV1Schema: ZodType = z.strictObject(
{
protocol: z.literal(REVOKE_PROTOCOL_V1),
@@ -345,6 +611,118 @@ export const registryReceiptV1Schema: ZodType = z.strictObjec
signature: base64Url64Schema,
}) satisfies z.ZodType;
+const deviceRegistryEventWithoutIdBaseV2Schema = z.strictObject({
+ protocol: z.literal(DEVICE_REGISTRY_EVENT_PROTOCOL_V2),
+ operationId: nexusDeviceOperationIdV2Schema,
+ eventType: z.enum(['activated', 'revoked']),
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ identitySequence: safeNonNegativeIntegerSchema,
+ identityState: z.enum(['active', 'revoked']),
+ deviceLedgerSequence: safeNonNegativeIntegerSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema.optional(),
+ deviceState: z.enum(['active', 'revoked']),
+ authorizationExpiresAt: safeNonNegativeIntegerSchema.optional(),
+ acceptedAt: safeNonNegativeIntegerSchema,
+ actionHash: base64Url32Schema,
+ revokedBy: z.enum(['root', 'device']).optional(),
+});
+
+function addDeviceRegistryTransitionIssues(
+ value: {
+ eventType: 'activated' | 'revoked';
+ identitySequence: number;
+ identityState: 'active' | 'revoked';
+ deviceState: 'active' | 'revoked';
+ authorizationId?: NexusDeviceAuthorizationIdV2 | undefined;
+ authorizationExpiresAt?: number | undefined;
+ revokedBy?: 'root' | 'device' | undefined;
+ },
+ context: z.RefinementCtx,
+): void {
+ if (
+ (value.identityState === 'active' && value.identitySequence !== 0) ||
+ (value.identityState === 'revoked' && value.identitySequence !== 1)
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'identitySequence must match the v1 identity state.',
+ path: ['identitySequence'],
+ });
+ }
+ if (value.eventType === 'activated') {
+ if (value.identityState !== 'active' || value.deviceState !== 'active') {
+ context.addIssue({
+ code: 'custom',
+ message: 'An activation event requires active identity and device state.',
+ path: ['eventType'],
+ });
+ }
+ if (value.authorizationId === undefined || value.authorizationExpiresAt === undefined) {
+ context.addIssue({
+ code: 'custom',
+ message: 'An activation event requires authorizationId and authorizationExpiresAt.',
+ path: ['authorizationId'],
+ });
+ }
+ if (value.revokedBy !== undefined) {
+ context.addIssue({
+ code: 'custom',
+ message: 'An activation event must omit revokedBy.',
+ path: ['revokedBy'],
+ });
+ }
+ } else {
+ if (value.deviceState !== 'revoked' || value.revokedBy === undefined) {
+ context.addIssue({
+ code: 'custom',
+ message: 'A revocation event requires revoked device state and revokedBy.',
+ path: ['eventType'],
+ });
+ }
+ }
+}
+
+export const deviceRegistryEventWithoutEventIdV2Schema: ZodType =
+ deviceRegistryEventWithoutIdBaseV2Schema.superRefine(
+ addDeviceRegistryTransitionIssues,
+ ) satisfies z.ZodType;
+
+export const deviceRegistryEventV2Schema: ZodType =
+ deviceRegistryEventWithoutIdBaseV2Schema
+ .extend({ eventId: nexusDeviceEventIdV2Schema })
+ .superRefine(addDeviceRegistryTransitionIssues) satisfies z.ZodType;
+
+const deviceRegistryReceiptPayloadBaseV2Schema = z.strictObject({
+ protocol: z.literal(DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2),
+ eventId: nexusDeviceEventIdV2Schema,
+ operationId: nexusDeviceOperationIdV2Schema,
+ eventType: z.enum(['activated', 'revoked']),
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ identitySequence: safeNonNegativeIntegerSchema,
+ identityState: z.enum(['active', 'revoked']),
+ deviceLedgerSequence: safeNonNegativeIntegerSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema.optional(),
+ deviceState: z.enum(['active', 'revoked']),
+ authorizationExpiresAt: safeNonNegativeIntegerSchema.optional(),
+ acceptedAt: safeNonNegativeIntegerSchema,
+ revokedBy: z.enum(['root', 'device']).optional(),
+ signerKid: signerKidSchema,
+});
+
+export const deviceRegistryReceiptPayloadV2Schema: ZodType =
+ deviceRegistryReceiptPayloadBaseV2Schema.superRefine(
+ addDeviceRegistryTransitionIssues,
+ ) satisfies z.ZodType;
+
+export const deviceRegistryReceiptV2Schema: ZodType = z.strictObject({
+ payload: deviceRegistryReceiptPayloadV2Schema,
+ signature: base64Url64Schema,
+}) satisfies z.ZodType;
+
export const statusStatementPayloadV1Schema: ZodType = z
.strictObject({
protocol: z.literal(STATUS_STATEMENT_PROTOCOL_V1),
@@ -405,6 +783,205 @@ export const statusStatementV1Schema: ZodType = z.strictObjec
signature: base64Url64Schema,
}) satisfies z.ZodType;
+export const deviceStatusStatementPayloadV2Schema: ZodType = z
+ .strictObject({
+ protocol: z.literal(DEVICE_STATUS_STATEMENT_PROTOCOL_V2),
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ identityState: z.enum(['active', 'revoked']),
+ identitySequence: safeNonNegativeIntegerSchema,
+ deviceLedgerSequence: safeNonNegativeIntegerSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+ deviceState: z.enum(['active', 'revoked', 'expired', 'unknown']),
+ activatedAt: safeNonNegativeIntegerSchema.optional(),
+ revokedAt: safeNonNegativeIntegerSchema.optional(),
+ authorizationExpiresAt: safeNonNegativeIntegerSchema.optional(),
+ iat: safeNonNegativeIntegerSchema,
+ exp: safeNonNegativeIntegerSchema,
+ signerKid: signerKidSchema,
+ })
+ .superRefine((status, context) => {
+ if (status.iat > status.exp) {
+ context.addIssue({ code: 'custom', message: 'iat must not exceed exp.', path: ['exp'] });
+ }
+ if (
+ (status.identityState === 'active' && status.identitySequence !== 0) ||
+ (status.identityState === 'revoked' && status.identitySequence !== 1)
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'identitySequence must match the v1 identity state.',
+ path: ['identitySequence'],
+ });
+ }
+ if (status.deviceState === 'active') {
+ if (
+ status.identityState !== 'active' ||
+ status.activatedAt === undefined ||
+ status.authorizationExpiresAt === undefined ||
+ status.revokedAt !== undefined
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Active device status requires an active identity, activation and expiry only.',
+ path: ['deviceState'],
+ });
+ }
+ } else if (status.deviceState === 'expired') {
+ if (
+ status.identityState !== 'active' ||
+ status.activatedAt === undefined ||
+ status.authorizationExpiresAt === undefined ||
+ status.revokedAt !== undefined
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Expired device status requires an active identity, activation and expiry only.',
+ path: ['deviceState'],
+ });
+ }
+ } else if (status.deviceState === 'revoked') {
+ if (status.revokedAt === undefined) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Revoked device status requires revokedAt.',
+ path: ['revokedAt'],
+ });
+ }
+ } else if (
+ status.activatedAt !== undefined ||
+ status.revokedAt !== undefined ||
+ status.authorizationExpiresAt !== undefined
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Unknown device status must omit lifecycle timestamps.',
+ path: ['deviceState'],
+ });
+ }
+ if (
+ status.activatedAt !== undefined &&
+ status.authorizationExpiresAt !== undefined &&
+ status.activatedAt > status.authorizationExpiresAt
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'activatedAt must not exceed authorizationExpiresAt.',
+ path: ['authorizationExpiresAt'],
+ });
+ }
+ if (
+ status.activatedAt !== undefined &&
+ status.revokedAt !== undefined &&
+ status.activatedAt > status.revokedAt
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'activatedAt must not exceed revokedAt.',
+ path: ['revokedAt'],
+ });
+ }
+ }) satisfies z.ZodType;
+
+export const deviceStatusStatementV2Schema: ZodType = z.strictObject({
+ payload: deviceStatusStatementPayloadV2Schema,
+ signature: base64Url64Schema,
+}) satisfies z.ZodType;
+
+export const deviceStatusRequestV2Schema: ZodType = z.strictObject({
+ subject: nexusSubjectSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+}) satisfies z.ZodType;
+
+export const deviceStatusBatchRequestV2Schema: ZodType = z
+ .strictObject({
+ devices: z.array(deviceStatusRequestV2Schema).min(1).max(MAX_STATUS_BATCH_SUBJECTS),
+ })
+ .superRefine((request, context) => {
+ const seen = new Set();
+ request.devices.forEach((device, index) => {
+ const tuple = `${device.subject}\0${device.deviceId}\0${device.authorizationId}`;
+ if (seen.has(tuple)) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Device status requests must be unique.',
+ path: ['devices', index],
+ });
+ }
+ seen.add(tuple);
+ });
+ }) satisfies z.ZodType;
+
+export const deviceRegistryStatusV2Schema: ZodType = z
+ .strictObject({
+ subject: nexusSubjectSchema,
+ genesisHash: base64Url32Schema,
+ identityState: z.enum(['active', 'revoked']),
+ identitySequence: safeNonNegativeIntegerSchema,
+ deviceLedgerSequence: safeNonNegativeIntegerSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+ deviceState: z.enum(['active', 'revoked', 'expired', 'unknown']),
+ activatedAt: safeNonNegativeIntegerSchema.nullable(),
+ revokedAt: safeNonNegativeIntegerSchema.nullable(),
+ authorizationExpiresAt: safeNonNegativeIntegerSchema.nullable(),
+ statusStatement: deviceStatusStatementV2Schema,
+ })
+ .superRefine((status, context) => {
+ const statement = status.statusStatement.payload;
+ if (
+ statement.subject !== status.subject ||
+ statement.genesisHash !== status.genesisHash ||
+ statement.identityState !== status.identityState ||
+ statement.identitySequence !== status.identitySequence ||
+ statement.deviceLedgerSequence !== status.deviceLedgerSequence ||
+ statement.deviceId !== status.deviceId ||
+ statement.authorizationId !== status.authorizationId ||
+ statement.deviceState !== status.deviceState ||
+ (statement.activatedAt ?? null) !== status.activatedAt ||
+ (statement.revokedAt ?? null) !== status.revokedAt ||
+ (statement.authorizationExpiresAt ?? null) !== status.authorizationExpiresAt
+ ) {
+ context.addIssue({
+ code: 'custom',
+ message: 'Status statement does not describe the enclosing device registry status.',
+ path: ['statusStatement'],
+ });
+ }
+ }) satisfies z.ZodType;
+
+export const nexusDeviceErrorBodySchema = z.strictObject({
+ code: z.enum(NEXUS_DEVICE_ERROR_CODES),
+ message: z.string().min(1).max(1024),
+ requestId: printableAscii(128).optional(),
+});
+
+export const nexusDeviceErrorSchema: ZodType = z.strictObject({
+ error: nexusDeviceErrorBodySchema,
+}) satisfies z.ZodType;
+
+const deviceStatusBatchErrorV2Schema = z.strictObject({
+ ok: z.literal(false),
+ subject: nexusSubjectSchema,
+ deviceId: nexusDeviceIdV2Schema,
+ authorizationId: nexusDeviceAuthorizationIdV2Schema,
+ error: nexusDeviceErrorBodySchema,
+});
+
+export const deviceStatusBatchResponseV2Schema: ZodType =
+ z.strictObject({
+ results: z
+ .array(
+ z.union([
+ z.strictObject({ ok: z.literal(true), status: deviceRegistryStatusV2Schema }),
+ deviceStatusBatchErrorV2Schema,
+ ]),
+ )
+ .max(MAX_STATUS_BATCH_SUBJECTS),
+ }) satisfies z.ZodType;
+
export const proofRequestSchema: ZodType = z.strictObject({
action: actionSchema,
resource: resourceSchema,
@@ -422,6 +999,16 @@ export const verificationExpectationSchema: ZodType = z
maxClockSkewSeconds: safeNonNegativeIntegerSchema,
}) satisfies z.ZodType;
+export const verificationExpectationV2Schema: ZodType = z.strictObject({
+ audience: audienceOriginSchema,
+ action: actionSchema,
+ resource: resourceSchema,
+ nonce: base64UrlAtLeast16Schema,
+ now: safeNonNegativeIntegerSchema,
+ maxClockSkewSeconds: safeNonNegativeIntegerSchema,
+ contextHash: base64Url32Schema.nullable(),
+}) satisfies z.ZodType;
+
export const ed25519PublicJwkSchema: ZodType = z.strictObject({
kty: z.literal('OKP'),
crv: z.literal(ED25519_ALGORITHM),
diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts
index ed7e8ab..060cba8 100644
--- a/packages/protocol/src/types.ts
+++ b/packages/protocol/src/types.ts
@@ -1,11 +1,20 @@
import type {
CONTINUITY_LINK_PROTOCOL_V1,
+ DEVICE_ACTIVATION_PROTOCOL_V2,
+ DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ DEVICE_REGISTRY_EVENT_PROTOCOL_V2,
+ DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ DEVICE_ROOT_REVOKE_PROTOCOL_V2,
+ DEVICE_SELF_REVOKE_PROTOCOL_V2,
+ DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
ED25519_ALGORITHM,
EDDSA_JWK_ALGORITHM,
IDENTITY_PROTOCOL_V1,
NEXUS_ERROR_CODES,
+ NEXUS_DEVICE_ERROR_CODES,
NEXUS_SUITE_V1,
OWNERSHIP_PROOF_PROTOCOL_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V2,
REGISTRY_EVENT_PROTOCOL_V1,
REGISTRY_RECEIPT_PROTOCOL_V1,
REVOKE_PROTOCOL_V1,
@@ -29,10 +38,24 @@ export type Base64UrlAtLeast16 = string & {
export type NexusSubject = `nx1_${string}`;
export type NexusEventId = `nxe1_${string}`;
+export type NexusDeviceIdV2 = `nxd2_${string}`;
+export type NexusDeviceAuthorizationIdV2 = `nxa2_${string}`;
+export type NexusDeviceOperationIdV2 = `nxo2_${string}`;
+export type NexusDeviceEventIdV2 = `nxde2_${string}`;
+export type DeviceIdV2 = NexusDeviceIdV2;
+export type DeviceAuthorizationIdV2 = NexusDeviceAuthorizationIdV2;
+export type DeviceOperationIdV2 = NexusDeviceOperationIdV2;
+export type DeviceEventIdV2 = NexusDeviceEventIdV2;
export type LifecycleState = 'active' | 'revoked';
export type RegistryEventType = 'registered' | 'revoked';
export type RevocationReasonCode = 'dispose' | 'key-compromise' | 'lost-device';
+export type DeviceRevocationReasonCode = RevocationReasonCode | 'replaced';
+export type DeviceRegistryEventTypeV2 = 'activated' | 'revoked';
+export type DeviceRegistryStateV2 = 'active' | 'revoked';
+export type DeviceStatusStateV2 = DeviceRegistryStateV2 | 'expired' | 'unknown';
+export type DeviceRevokedByV2 = 'root' | 'device';
export type NexusErrorCode = (typeof NEXUS_ERROR_CODES)[number];
+export type NexusDeviceErrorCode = (typeof NEXUS_DEVICE_ERROR_CODES)[number];
export type JsonPrimitive = null | boolean | number | string;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
@@ -71,6 +94,213 @@ export interface OwnershipProofV1 {
signature: Base64Url64;
}
+export interface DeviceSigningKeyV2 {
+ alg: typeof ED25519_ALGORITHM;
+ publicKey: Base64Url32;
+}
+
+/** Canonical input committed to by a Nexus v2 device identifier. */
+export interface DeviceIdInputV2 {
+ subject: NexusSubject;
+ signingKey: DeviceSigningKeyV2;
+}
+
+export interface DeviceAuthorizationPayloadV2 extends DeviceIdInputV2 {
+ protocol: typeof DEVICE_AUTHORIZATION_PROTOCOL_V2;
+ genesisHash: Base64Url32;
+ deviceId: NexusDeviceIdV2;
+ authorizationNonce: Base64Url32;
+ validFrom: number;
+ activationDeadline: number;
+ expiresAt: number;
+}
+
+export interface DeviceAuthorizationV2 {
+ payload: DeviceAuthorizationPayloadV2;
+ rootSignature: Base64Url64;
+}
+
+export interface DeviceActivationPayloadV2 {
+ protocol: typeof DEVICE_ACTIVATION_PROTOCOL_V2;
+ subject: NexusSubject;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ requestId: Base64Url32;
+ iat: number;
+ exp: number;
+}
+
+export interface DeviceActivationRequestV2 {
+ authorization: DeviceAuthorizationV2;
+ payload: DeviceActivationPayloadV2;
+ deviceSignature: Base64Url64;
+}
+export type DeviceActivationV2 = DeviceActivationRequestV2;
+
+export interface DeviceSelfRevokePayloadV2 {
+ protocol: typeof DEVICE_SELF_REVOKE_PROTOCOL_V2;
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ requestId: Base64Url32;
+ issuedAt: number;
+ reasonCode?: DeviceRevocationReasonCode | undefined;
+}
+
+export interface DeviceSelfRevokeRequestV2 {
+ authorization: DeviceAuthorizationV2;
+ payload: DeviceSelfRevokePayloadV2;
+ deviceSignature: Base64Url64;
+}
+export type DeviceSelfRevokeV2 = DeviceSelfRevokeRequestV2;
+
+export interface DeviceRootRevokePayloadV2 {
+ protocol: typeof DEVICE_ROOT_REVOKE_PROTOCOL_V2;
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ deviceId: NexusDeviceIdV2;
+ requestId: Base64Url32;
+ issuedAt: number;
+ reasonCode?: DeviceRevocationReasonCode | undefined;
+}
+
+export interface DeviceRootRevokeRequestV2 {
+ payload: DeviceRootRevokePayloadV2;
+ rootSignature: Base64Url64;
+}
+export type DeviceRootRevokeV2 = DeviceRootRevokeRequestV2;
+
+export type DeviceOperationPayloadV2 =
+ DeviceActivationPayloadV2 | DeviceSelfRevokePayloadV2 | DeviceRootRevokePayloadV2;
+
+export interface OwnershipProofPayloadV2 {
+ protocol: typeof OWNERSHIP_PROOF_PROTOCOL_V2;
+ subject: NexusSubject;
+ genesis: IdentityGenesisV1;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ authorization: DeviceAuthorizationV2;
+ aud: string;
+ act: string;
+ resource: string;
+ nonce: Base64UrlAtLeast16;
+ iat: number;
+ exp: number;
+ contextHash?: Base64Url32 | undefined;
+}
+
+export interface OwnershipProofV2 {
+ payload: OwnershipProofPayloadV2;
+ deviceSignature: Base64Url64;
+}
+
+export interface DeviceRegistryEventV2 {
+ protocol: typeof DEVICE_REGISTRY_EVENT_PROTOCOL_V2;
+ eventId: NexusDeviceEventIdV2;
+ operationId: NexusDeviceOperationIdV2;
+ eventType: DeviceRegistryEventTypeV2;
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ identitySequence: number;
+ identityState: LifecycleState;
+ deviceLedgerSequence: number;
+ deviceId: NexusDeviceIdV2;
+ authorizationId?: NexusDeviceAuthorizationIdV2 | undefined;
+ deviceState: DeviceRegistryStateV2;
+ authorizationExpiresAt?: number | undefined;
+ acceptedAt: number;
+ actionHash: Base64Url32;
+ revokedBy?: DeviceRevokedByV2 | undefined;
+}
+
+export type DeviceRegistryEventWithoutEventIdV2 = Omit;
+
+export interface DeviceRegistryReceiptPayloadV2 {
+ protocol: typeof DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2;
+ eventId: NexusDeviceEventIdV2;
+ operationId: NexusDeviceOperationIdV2;
+ eventType: DeviceRegistryEventTypeV2;
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ identitySequence: number;
+ identityState: LifecycleState;
+ deviceLedgerSequence: number;
+ deviceId: NexusDeviceIdV2;
+ authorizationId?: NexusDeviceAuthorizationIdV2 | undefined;
+ deviceState: DeviceRegistryStateV2;
+ authorizationExpiresAt?: number | undefined;
+ acceptedAt: number;
+ revokedBy?: DeviceRevokedByV2 | undefined;
+ signerKid: string;
+}
+
+export interface DeviceRegistryReceiptV2 {
+ payload: DeviceRegistryReceiptPayloadV2;
+ signature: Base64Url64;
+}
+
+export interface DeviceStatusStatementPayloadV2 {
+ protocol: typeof DEVICE_STATUS_STATEMENT_PROTOCOL_V2;
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ identityState: LifecycleState;
+ identitySequence: number;
+ deviceLedgerSequence: number;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ deviceState: DeviceStatusStateV2;
+ activatedAt?: number | undefined;
+ revokedAt?: number | undefined;
+ authorizationExpiresAt?: number | undefined;
+ iat: number;
+ exp: number;
+ signerKid: string;
+}
+
+export interface DeviceStatusStatementV2 {
+ payload: DeviceStatusStatementPayloadV2;
+ signature: Base64Url64;
+}
+
+export interface DeviceStatusRequestV2 {
+ subject: NexusSubject;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+}
+
+export interface DeviceStatusBatchRequestV2 {
+ devices: DeviceStatusRequestV2[];
+}
+
+export interface DeviceRegistryStatusV2 {
+ subject: NexusSubject;
+ genesisHash: Base64Url32;
+ identityState: LifecycleState;
+ identitySequence: number;
+ deviceLedgerSequence: number;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ deviceState: DeviceStatusStateV2;
+ activatedAt: number | null;
+ revokedAt: number | null;
+ authorizationExpiresAt: number | null;
+ statusStatement: DeviceStatusStatementV2;
+}
+
+export interface DeviceStatusBatchResponseV2 {
+ results: (
+ | { ok: true; status: DeviceRegistryStatusV2 }
+ | {
+ ok: false;
+ subject: NexusSubject;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: NexusDeviceAuthorizationIdV2;
+ error: NexusDeviceError['error'];
+ }
+ )[];
+}
+
export interface RevokeBySignaturePayloadV1 {
protocol: typeof REVOKE_PROTOCOL_V1;
subject: NexusSubject;
@@ -189,6 +419,14 @@ export interface VerificationExpectation {
maxClockSkewSeconds: number;
}
+/**
+ * V2 expectation with explicit context binding. A null contextHash requires
+ * the proof to omit contextHash; otherwise the proof value must match exactly.
+ */
+export interface VerificationExpectationV2 extends VerificationExpectation {
+ contextHash: Base64Url32 | null;
+}
+
export interface VerifiedSubject {
subject: NexusSubject;
signingPublicKey: Uint8Array;
@@ -240,6 +478,14 @@ export interface NexusError {
};
}
+export interface NexusDeviceError {
+ error: {
+ code: NexusDeviceErrorCode;
+ message: string;
+ requestId?: string | undefined;
+ };
+}
+
export interface TransparencyCheckpointPayloadV1 {
protocol: typeof TRANSPARENCY_CHECKPOINT_PROTOCOL_V1;
shardId: string;
diff --git a/packages/protocol/tests/device-v2.test.ts b/packages/protocol/tests/device-v2.test.ts
new file mode 100644
index 0000000..d7db74e
--- /dev/null
+++ b/packages/protocol/tests/device-v2.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ DEVICE_ACTIVATION_PROTOCOL_V2,
+ DEVICE_AUTHORIZATION_HASH_DOMAIN_V2,
+ DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ DEVICE_ID_HASH_DOMAIN_V2,
+ DEVICE_REGISTRY_EVENT_PROTOCOL_V2,
+ ED25519_ALGORITHM,
+ MAX_DEVICE_ACTIVATION_WINDOW_SECONDS,
+ MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS,
+ createDeviceAuthorizationHashPreimageV2,
+ createDeviceIdHashPreimageV2,
+ deviceActivationRequestV2Schema,
+ deviceAuthorizationPayloadV2Schema,
+ deviceRegistryEventV2Schema,
+ encodeBase64Url,
+ formatDeviceAuthorizationIdV2,
+ formatDeviceIdV2,
+ formatDeviceOperationIdV2,
+ formatDeviceRegistryEventIdV2,
+ formatSubject,
+ nexusDeviceErrorSchema,
+ nexusErrorSchema,
+ verificationExpectationV2Schema,
+} from '../src/index.js';
+import type { Base64Url32, Base64Url64, DeviceAuthorizationPayloadV2 } from '../src/index.js';
+
+const bytes = (length: number, value: number): Uint8Array => new Uint8Array(length).fill(value);
+const hashA = encodeBase64Url(bytes(32, 0x11)) as Base64Url32;
+const hashB = encodeBase64Url(bytes(32, 0x22)) as Base64Url32;
+const signature = encodeBase64Url(bytes(64, 0x33)) as Base64Url64;
+const subject = formatSubject(bytes(32, 0x44));
+const deviceId = formatDeviceIdV2(bytes(32, 0x55));
+const authorizationId = formatDeviceAuthorizationIdV2(bytes(32, 0x66));
+const operationId = formatDeviceOperationIdV2(bytes(32, 0x77));
+const eventId = formatDeviceRegistryEventIdV2(bytes(32, 0x88));
+
+function authorization(
+ overrides: Partial = {},
+): DeviceAuthorizationPayloadV2 {
+ return {
+ protocol: DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ subject,
+ genesisHash: hashA,
+ deviceId,
+ signingKey: { alg: ED25519_ALGORITHM, publicKey: hashB },
+ authorizationNonce: hashA,
+ validFrom: 100,
+ activationDeadline: 100 + MAX_DEVICE_ACTIVATION_WINDOW_SECONDS,
+ expiresAt: 100 + MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS,
+ ...overrides,
+ };
+}
+
+describe('Nexus v2 root-authorized device protocol', () => {
+ it('formats each v2 identifier from exactly one SHA-256 hash', () => {
+ expect(deviceId).toMatch(/^nxd2_[A-Za-z0-9_-]{43}$/u);
+ expect(authorizationId).toMatch(/^nxa2_[A-Za-z0-9_-]{43}$/u);
+ expect(operationId).toMatch(/^nxo2_[A-Za-z0-9_-]{43}$/u);
+ expect(eventId).toMatch(/^nxde2_[A-Za-z0-9_-]{43}$/u);
+ expect(() => formatDeviceIdV2(bytes(31, 0))).toThrow(RangeError);
+ expect(() => formatDeviceAuthorizationIdV2(bytes(33, 0))).toThrow(RangeError);
+ });
+
+ it('uses independent canonical hash domains for device and authorization IDs', () => {
+ const decoder = new TextDecoder();
+ expect(
+ decoder.decode(
+ createDeviceIdHashPreimageV2({
+ subject,
+ signingKey: authorization().signingKey,
+ }),
+ ),
+ ).toBe(
+ `${DEVICE_ID_HASH_DOMAIN_V2}{"signingKey":{"alg":"Ed25519","publicKey":"${hashB}"},"subject":"${subject}"}`,
+ );
+ expect(decoder.decode(createDeviceAuthorizationHashPreimageV2(authorization()))).toMatch(
+ new RegExp(`^${DEVICE_AUTHORIZATION_HASH_DOMAIN_V2.replaceAll('\0', '\\0')}`),
+ );
+ });
+
+ it('accepts exact authorization caps and rejects either cap plus one second', () => {
+ expect(deviceAuthorizationPayloadV2Schema.safeParse(authorization()).success).toBe(true);
+ expect(
+ deviceAuthorizationPayloadV2Schema.safeParse(
+ authorization({ activationDeadline: 101 + MAX_DEVICE_ACTIVATION_WINDOW_SECONDS }),
+ ).success,
+ ).toBe(false);
+ expect(
+ deviceAuthorizationPayloadV2Schema.safeParse(
+ authorization({ expiresAt: 101 + MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS }),
+ ).success,
+ ).toBe(false);
+ });
+
+ it('rejects reversed authorization times and unknown nested fields', () => {
+ expect(
+ deviceAuthorizationPayloadV2Schema.safeParse(
+ authorization({ validFrom: 200, activationDeadline: 199 }),
+ ).success,
+ ).toBe(false);
+ expect(
+ deviceAuthorizationPayloadV2Schema.safeParse({
+ ...authorization(),
+ signingKey: { ...authorization().signingKey, extractable: true },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('requires activation and authorization subjects/device IDs to agree', () => {
+ const request = {
+ authorization: { payload: authorization(), rootSignature: signature },
+ payload: {
+ protocol: DEVICE_ACTIVATION_PROTOCOL_V2,
+ subject,
+ deviceId,
+ authorizationId,
+ requestId: hashB,
+ iat: 200,
+ exp: 201,
+ },
+ deviceSignature: signature,
+ };
+ expect(deviceActivationRequestV2Schema.safeParse(request).success).toBe(true);
+ expect(
+ deviceActivationRequestV2Schema.safeParse({
+ ...request,
+ payload: { ...request.payload, deviceId: formatDeviceIdV2(bytes(32, 0x99)) },
+ }).success,
+ ).toBe(false);
+ });
+
+ it('enforces activated and revoked device event state combinations', () => {
+ const event = {
+ protocol: DEVICE_REGISTRY_EVENT_PROTOCOL_V2,
+ eventId,
+ operationId,
+ eventType: 'activated',
+ subject,
+ genesisHash: hashA,
+ identitySequence: 0,
+ identityState: 'active',
+ deviceLedgerSequence: 1,
+ deviceId,
+ authorizationId,
+ deviceState: 'active',
+ authorizationExpiresAt: 1_000,
+ acceptedAt: 200,
+ actionHash: hashB,
+ } as const;
+ expect(deviceRegistryEventV2Schema.safeParse(event).success).toBe(true);
+ expect(
+ deviceRegistryEventV2Schema.safeParse({ ...event, deviceState: 'revoked' }).success,
+ ).toBe(false);
+ expect(
+ deviceRegistryEventV2Schema.safeParse({
+ ...event,
+ eventType: 'revoked',
+ deviceState: 'revoked',
+ revokedBy: 'root',
+ }).success,
+ ).toBe(true);
+ });
+
+ it('keeps v1 error codes closed while accepting device errors on v2 surfaces', () => {
+ const deviceError = {
+ error: { code: 'DEVICE_REVOKED', message: 'The device is revoked.' },
+ };
+ expect(nexusErrorSchema.safeParse(deviceError).success).toBe(false);
+ expect(nexusDeviceErrorSchema.safeParse(deviceError).success).toBe(true);
+ expect(
+ nexusDeviceErrorSchema.safeParse({
+ error: { code: 'IDENTITY_REVOKED', message: 'The identity is revoked.' },
+ }).success,
+ ).toBe(true);
+ for (const code of [
+ 'METHOD_NOT_ALLOWED',
+ 'UNSUPPORTED_MEDIA_TYPE',
+ 'ORIGIN_NOT_ALLOWED',
+ 'HTTPS_REQUIRED',
+ 'SERVICE_UNAVAILABLE',
+ ]) {
+ const transportError = { error: { code, message: 'Transport failure.' } };
+ expect(nexusErrorSchema.safeParse(transportError).success).toBe(false);
+ expect(nexusDeviceErrorSchema.safeParse(transportError).success).toBe(true);
+ }
+ });
+
+ it('requires an explicit v2 context-binding policy', () => {
+ const expectation = {
+ audience: 'https://rp.example',
+ action: 'post.edit',
+ resource: 'post:123',
+ nonce: encodeBase64Url(bytes(16, 0x99)),
+ now: 100,
+ maxClockSkewSeconds: 5,
+ };
+ expect(
+ verificationExpectationV2Schema.safeParse({ ...expectation, contextHash: null }).success,
+ ).toBe(true);
+ expect(
+ verificationExpectationV2Schema.safeParse({ ...expectation, contextHash: hashA }).success,
+ ).toBe(true);
+ expect(verificationExpectationV2Schema.safeParse(expectation).success).toBe(false);
+ });
+});
diff --git a/packages/sdk-browser/src/index.ts b/packages/sdk-browser/src/index.ts
index 1f58c13..2273084 100644
--- a/packages/sdk-browser/src/index.ts
+++ b/packages/sdk-browser/src/index.ts
@@ -1,21 +1,39 @@
export {
NEXUS_POPUP_CHANNEL,
+ NEXUS_POPUP_CHANNEL_V2,
+ NEXUS_OWNERSHIP_PROOF_PROTOCOLS,
WALLET_PROOF_ERROR_CODES,
+ parseAcceptedProofProtocols,
parseWalletMessage,
+ parseWalletMessageV2,
+ type AcceptedProofProtocols,
+ type NexusNegotiatedProofResult,
+ type NexusOwnershipProofProtocol,
type NexusProofErrorMessage,
+ type NexusProofErrorMessageV2,
type NexusProofRequestMessage,
+ type NexusProofRequestMessageV2,
type NexusProofResultMessage,
+ type NexusProofResultMessageV2,
type NexusReadyMessage,
+ type NexusReadyMessageV2,
type RpToWalletMessage,
+ type RpToWalletMessageV2,
type WalletProofErrorCode,
+ type WalletProofErrorCodeV2,
type WalletToRpMessage,
+ type WalletToRpMessageV2,
} from './messages.js';
export {
NexusClient,
NexusClientError,
+ NexusClientErrorV2,
createNexusClient,
type NexusClientErrorCode,
+ type NexusClientErrorCodeV2,
type NexusClientOptions,
type NexusProofResult,
+ type NexusProofResultV2,
type RequestProofOptions,
+ type RequestProofV2Options,
} from './popup.js';
diff --git a/packages/sdk-browser/src/messages.ts b/packages/sdk-browser/src/messages.ts
index eb202b2..3d0fae9 100644
--- a/packages/sdk-browser/src/messages.ts
+++ b/packages/sdk-browser/src/messages.ts
@@ -1,7 +1,21 @@
-import type { OwnershipProofV1, ProofRequest } from '@nexus/protocol';
+import type { OwnershipProofV1, OwnershipProofV2, ProofRequest } from '@nexus/protocol';
export const NEXUS_POPUP_CHANNEL = 'nexus.popup.v1' as const;
+/** Additive negotiation channel. The v1 channel and its envelopes remain unchanged. */
+export const NEXUS_POPUP_CHANNEL_V2 = 'nexus.popup.v2' as const;
+
+export const NEXUS_OWNERSHIP_PROOF_PROTOCOLS = [
+ 'nexus.ownership-proof.v2',
+ 'nexus.ownership-proof.v1',
+] as const;
+
+export type NexusOwnershipProofProtocol = (typeof NEXUS_OWNERSHIP_PROOF_PROTOCOLS)[number];
+export type AcceptedProofProtocols = readonly [
+ NexusOwnershipProofProtocol,
+ ...NexusOwnershipProofProtocol[],
+];
+
export interface NexusReadyMessage {
channel: typeof NEXUS_POPUP_CHANNEL;
type: 'NEXUS_READY';
@@ -47,10 +61,62 @@ export type WalletToRpMessage =
export type RpToWalletMessage = NexusProofRequestMessage;
+export interface NexusReadyMessageV2 {
+ channel: typeof NEXUS_POPUP_CHANNEL_V2;
+ type: 'NEXUS_READY';
+ supportedProofProtocols: AcceptedProofProtocols;
+}
+
+export interface NexusProofRequestMessageV2 {
+ channel: typeof NEXUS_POPUP_CHANNEL_V2;
+ type: 'NEXUS_PROOF_REQUEST';
+ requestId: string;
+ request: ProofRequest;
+ /**
+ * Ordered RP preference. This negotiates the popup response only; an RP backend MUST bind the
+ * same policy to the challenge and independently reject a returned protocol it did not allow.
+ */
+ acceptedProofProtocols: AcceptedProofProtocols;
+}
+
+export type NexusNegotiatedProofResult =
+ | {
+ proofProtocol: 'nexus.ownership-proof.v2';
+ proof: OwnershipProofV2;
+ }
+ | {
+ proofProtocol: 'nexus.ownership-proof.v1';
+ proof: OwnershipProofV1;
+ };
+
+export type NexusProofResultMessageV2 = NexusNegotiatedProofResult & {
+ channel: typeof NEXUS_POPUP_CHANNEL_V2;
+ type: 'NEXUS_PROOF_RESULT';
+ requestId: string;
+};
+
+export type WalletProofErrorCodeV2 = WalletProofErrorCode | 'UNSUPPORTED_PROOF_PROTOCOL';
+
+export interface NexusProofErrorMessageV2 {
+ channel: typeof NEXUS_POPUP_CHANNEL_V2;
+ type: 'NEXUS_PROOF_ERROR';
+ requestId: string;
+ error: {
+ code: WalletProofErrorCodeV2;
+ };
+}
+
+export type WalletToRpMessageV2 =
+ NexusReadyMessageV2 | NexusProofResultMessageV2 | NexusProofErrorMessageV2;
+
+export type RpToWalletMessageV2 = NexusProofRequestMessageV2;
+
const READY_KEYS = ['channel', 'type'] as const;
const RESULT_KEYS = ['channel', 'proof', 'requestId', 'type'] as const;
const ERROR_KEYS = ['channel', 'error', 'requestId', 'type'] as const;
const ERROR_BODY_KEYS = ['code'] as const;
+const READY_KEYS_V2 = ['channel', 'supportedProofProtocols', 'type'] as const;
+const RESULT_KEYS_V2 = ['channel', 'proof', 'proofProtocol', 'requestId', 'type'] as const;
export function parseWalletMessage(value: unknown): WalletToRpMessage | undefined {
if (!isRecord(value) || value.channel !== NEXUS_POPUP_CHANNEL) return undefined;
@@ -88,6 +154,52 @@ export function parseWalletMessage(value: unknown): WalletToRpMessage | undefine
return undefined;
}
+export function parseWalletMessageV2(value: unknown): WalletToRpMessageV2 | undefined {
+ if (!isRecord(value) || value.channel !== NEXUS_POPUP_CHANNEL_V2) return undefined;
+
+ if (value.type === 'NEXUS_READY') {
+ if (
+ !hasExactKeys(value, READY_KEYS_V2) ||
+ !isAcceptedProofProtocols(value.supportedProofProtocols)
+ ) {
+ return undefined;
+ }
+ return value as unknown as NexusReadyMessageV2;
+ }
+
+ if (value.type === 'NEXUS_PROOF_RESULT') {
+ if (
+ !hasExactKeys(value, RESULT_KEYS_V2) ||
+ !isRequestId(value.requestId) ||
+ !isNexusOwnershipProofProtocol(value.proofProtocol) ||
+ !isRecord(value.proof) ||
+ !proofMatchesProtocol(value.proof, value.proofProtocol)
+ ) {
+ return undefined;
+ }
+ return value as unknown as NexusProofResultMessageV2;
+ }
+
+ if (value.type === 'NEXUS_PROOF_ERROR') {
+ if (
+ !hasExactKeys(value, ERROR_KEYS) ||
+ !isRequestId(value.requestId) ||
+ !isRecord(value.error) ||
+ !hasExactKeys(value.error, ERROR_BODY_KEYS) ||
+ !isWalletProofErrorCodeV2(value.error.code)
+ ) {
+ return undefined;
+ }
+ return value as unknown as NexusProofErrorMessageV2;
+ }
+
+ return undefined;
+}
+
+export function parseAcceptedProofProtocols(value: unknown): AcceptedProofProtocols | undefined {
+ return isAcceptedProofProtocols(value) ? value : undefined;
+}
+
export function isRequestId(value: unknown): value is string {
return typeof value === 'string' && isUnpaddedBase64Url(value) && decodedByteLength(value) >= 16;
}
@@ -98,6 +210,30 @@ function isWalletProofErrorCode(value: unknown): value is WalletProofErrorCode {
);
}
+function isWalletProofErrorCodeV2(value: unknown): value is WalletProofErrorCodeV2 {
+ return value === 'UNSUPPORTED_PROOF_PROTOCOL' || isWalletProofErrorCode(value);
+}
+
+function isNexusOwnershipProofProtocol(value: unknown): value is NexusOwnershipProofProtocol {
+ return (
+ typeof value === 'string' &&
+ (NEXUS_OWNERSHIP_PROOF_PROTOCOLS as readonly string[]).includes(value)
+ );
+}
+
+function isAcceptedProofProtocols(value: unknown): value is AcceptedProofProtocols {
+ if (!Array.isArray(value) || value.length === 0) return false;
+ if (!value.every(isNexusOwnershipProofProtocol)) return false;
+ return new Set(value).size === value.length;
+}
+
+function proofMatchesProtocol(
+ proof: Record,
+ protocol: NexusOwnershipProofProtocol,
+): boolean {
+ return isRecord(proof.payload) && proof.payload.protocol === protocol;
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
diff --git a/packages/sdk-browser/src/popup.test.ts b/packages/sdk-browser/src/popup.test.ts
index 209a617..661511a 100644
--- a/packages/sdk-browser/src/popup.test.ts
+++ b/packages/sdk-browser/src/popup.test.ts
@@ -1,6 +1,7 @@
import { proofRequestSchema, type ProofRequest } from '@nexus/protocol';
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
-import { NexusClient, NexusClientError } from './popup.js';
+import type { WalletProofErrorCode } from './messages.js';
+import { NexusClient, NexusClientError, NexusClientErrorV2 } from './popup.js';
const WALLET_ORIGIN = 'https://wallet.nexus.test';
const RP_ORIGIN = 'https://rp-a.test';
@@ -79,6 +80,15 @@ function ready(): unknown {
return { channel: 'nexus.popup.v1', type: 'NEXUS_READY' };
}
+function readyV2(
+ supportedProofProtocols: readonly string[] = [
+ 'nexus.ownership-proof.v2',
+ 'nexus.ownership-proof.v1',
+ ],
+): unknown {
+ return { channel: 'nexus.popup.v2', type: 'NEXUS_READY', supportedProofProtocols };
+}
+
function sentRequest(popup: FakePopup): {
requestId: string;
request: Record;
@@ -300,4 +310,145 @@ describe('NexusClient popup transport', () => {
await vi.advanceTimersByTimeAsync(500);
await rejection;
});
+
+ it('negotiates the first RP-preferred protocol supported by the wallet', async () => {
+ const popup = makePopup();
+ const browser = installBrowser(popup);
+ const client = new NexusClient({ walletUrl: WALLET_ORIGIN });
+ const resultPromise = client.requestProofV2(validRequest(), {
+ acceptedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'],
+ });
+
+ // Legacy READY is intentionally ignored by the v2 request.
+ browser.emit(ready());
+ expect(popup.postMessage).not.toHaveBeenCalled();
+ browser.emit(readyV2());
+
+ expect(sentRequest(popup).request).not.toHaveProperty('aud');
+ expect(popup.postMessage.mock.calls[0]?.[0]).toMatchObject({
+ channel: 'nexus.popup.v2',
+ acceptedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'],
+ });
+
+ const requestId = sentRequest(popup).requestId;
+ const proof = {
+ payload: { protocol: 'nexus.ownership-proof.v2' },
+ deviceSignature: 'fixture',
+ };
+ browser.emit({
+ channel: 'nexus.popup.v2',
+ type: 'NEXUS_PROOF_RESULT',
+ requestId,
+ proofProtocol: 'nexus.ownership-proof.v2',
+ proof,
+ });
+ await expect(resultPromise).resolves.toEqual({
+ proofProtocol: 'nexus.ownership-proof.v2',
+ proof,
+ });
+ });
+
+ it('supports an explicitly allowed v1 proof only inside the v2 envelope', async () => {
+ const popup = makePopup();
+ const browser = installBrowser(popup);
+ const client = new NexusClient({ walletUrl: WALLET_ORIGIN });
+ const resultPromise = client.requestProofV2(validRequest(), {
+ acceptedProofProtocols: ['nexus.ownership-proof.v1'],
+ });
+ browser.emit(readyV2());
+ const requestId = sentRequest(popup).requestId;
+ const proof = { payload: { protocol: 'nexus.ownership-proof.v1' }, signature: 'fixture' };
+
+ // A v1-channel response cannot downgrade or complete this v2 negotiation.
+ browser.emit({
+ channel: 'nexus.popup.v1',
+ type: 'NEXUS_PROOF_RESULT',
+ requestId,
+ proof,
+ });
+ expect(popup.close).not.toHaveBeenCalled();
+ browser.emit({
+ channel: 'nexus.popup.v2',
+ type: 'NEXUS_PROOF_RESULT',
+ requestId,
+ proofProtocol: 'nexus.ownership-proof.v1',
+ proof,
+ });
+ await expect(resultPromise).resolves.toEqual({
+ proofProtocol: 'nexus.ownership-proof.v1',
+ proof,
+ });
+ });
+
+ it('rejects a wallet-selected protocol outside the challenge allow-list', async () => {
+ const popup = makePopup();
+ const browser = installBrowser(popup);
+ const client = new NexusClient({ walletUrl: WALLET_ORIGIN });
+ const resultPromise = client.requestProofV2(validRequest(), {
+ acceptedProofProtocols: ['nexus.ownership-proof.v2'],
+ });
+ browser.emit(readyV2());
+ const requestId = sentRequest(popup).requestId;
+ browser.emit({
+ channel: 'nexus.popup.v2',
+ type: 'NEXUS_PROOF_RESULT',
+ requestId,
+ proofProtocol: 'nexus.ownership-proof.v1',
+ proof: { payload: { protocol: 'nexus.ownership-proof.v1' }, signature: 'fixture' },
+ });
+
+ await expect(resultPromise).rejects.toMatchObject({
+ codeV2: 'UNSUPPORTED_PROOF_PROTOCOL',
+ });
+ });
+
+ it('rejects malformed RP protocol policies before opening the popup', async () => {
+ const popup = makePopup();
+ const browser = installBrowser(popup);
+ const client = new NexusClient({ walletUrl: WALLET_ORIGIN });
+ await expect(
+ client.requestProofV2(validRequest(), {
+ acceptedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v2'],
+ }),
+ ).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
+ expect(browser.open).not.toHaveBeenCalled();
+ });
+
+ it('fails closed when wallet and challenge have no common proof protocol', async () => {
+ const popup = makePopup();
+ const browser = installBrowser(popup);
+ const client = new NexusClient({ walletUrl: WALLET_ORIGIN });
+ const resultPromise = client.requestProofV2(validRequest(), {
+ acceptedProofProtocols: ['nexus.ownership-proof.v2'],
+ });
+ browser.emit(readyV2(['nexus.ownership-proof.v1']));
+ await expect(resultPromise).rejects.toMatchObject({
+ codeV2: 'UNSUPPORTED_PROOF_PROTOCOL',
+ });
+ expect(popup.postMessage).not.toHaveBeenCalled();
+ });
+
+ it('keeps the legacy error surface narrow and exposes v2-only error details additively', () => {
+ const legacy = new NexusClientError('WALLET_ERROR', 'legacy', {
+ walletCode: 'INTERNAL_ERROR',
+ });
+ const legacyWalletCode: WalletProofErrorCode | undefined = legacy.walletCode;
+ expect(legacyWalletCode).toBe('INTERNAL_ERROR');
+
+ new NexusClientError('WALLET_ERROR', 'legacy', {
+ // @ts-expect-error v2-only wallet codes must not widen the legacy constructor.
+ walletCode: 'UNSUPPORTED_PROOF_PROTOCOL',
+ });
+
+ const negotiated = new NexusClientErrorV2('UNSUPPORTED_PROOF_PROTOCOL', 'negotiated', {
+ walletCodeV2: 'UNSUPPORTED_PROOF_PROTOCOL',
+ });
+ expect(negotiated).toBeInstanceOf(NexusClientError);
+ expect(negotiated).toMatchObject({
+ code: 'WALLET_ERROR',
+ codeV2: 'UNSUPPORTED_PROOF_PROTOCOL',
+ walletCodeV2: 'UNSUPPORTED_PROOF_PROTOCOL',
+ });
+ expect(negotiated.walletCode).toBeUndefined();
+ });
});
diff --git a/packages/sdk-browser/src/popup.ts b/packages/sdk-browser/src/popup.ts
index e2d46fa..87b44a2 100644
--- a/packages/sdk-browser/src/popup.ts
+++ b/packages/sdk-browser/src/popup.ts
@@ -1,10 +1,18 @@
import type { ProofRequest } from '@nexus/protocol';
import {
NEXUS_POPUP_CHANNEL,
+ NEXUS_POPUP_CHANNEL_V2,
+ parseAcceptedProofProtocols,
parseWalletMessage,
+ parseWalletMessageV2,
+ type AcceptedProofProtocols,
+ type NexusNegotiatedProofResult,
type NexusProofRequestMessage,
+ type NexusProofRequestMessageV2,
type NexusProofResultMessage,
+ type NexusOwnershipProofProtocol,
type WalletProofErrorCode,
+ type WalletProofErrorCodeV2,
} from './messages.js';
import { parseProofRequest, ProofRequestValidationError } from './request-schema.js';
@@ -21,6 +29,8 @@ export type NexusClientErrorCode =
| 'REQUEST_TIMEOUT'
| 'WALLET_ERROR';
+export type NexusClientErrorCodeV2 = NexusClientErrorCode | 'UNSUPPORTED_PROOF_PROTOCOL';
+
export class NexusClientError extends Error {
public readonly code: NexusClientErrorCode;
public readonly walletCode?: WalletProofErrorCode;
@@ -37,6 +47,32 @@ export class NexusClientError extends Error {
}
}
+/**
+ * Additive error surface for negotiated v2 popup requests. It extends the legacy error so existing
+ * cancellation handling keeps working, while v2-only codes live on separate properties and do not
+ * widen the stable `NexusClientError` contract.
+ */
+export class NexusClientErrorV2 extends NexusClientError {
+ public readonly codeV2: NexusClientErrorCodeV2;
+ public readonly walletCodeV2?: WalletProofErrorCodeV2;
+
+ public constructor(
+ codeV2: NexusClientErrorCodeV2,
+ message: string,
+ options?: { cause?: unknown; walletCodeV2?: WalletProofErrorCodeV2 },
+ ) {
+ const legacyWalletCode =
+ options?.walletCodeV2 === 'UNSUPPORTED_PROOF_PROTOCOL' ? undefined : options?.walletCodeV2;
+ super(codeV2 === 'UNSUPPORTED_PROOF_PROTOCOL' ? 'WALLET_ERROR' : codeV2, message, {
+ ...(options?.cause === undefined ? {} : { cause: options.cause }),
+ ...(legacyWalletCode === undefined ? {} : { walletCode: legacyWalletCode }),
+ });
+ this.name = 'NexusClientErrorV2';
+ this.codeV2 = codeV2;
+ if (options?.walletCodeV2 !== undefined) this.walletCodeV2 = options.walletCodeV2;
+ }
+}
+
export interface NexusClientOptions {
walletUrl: string | URL;
allowInsecureLocalhost?: boolean;
@@ -49,7 +85,16 @@ export interface RequestProofOptions {
timeoutMs?: number;
}
+export interface RequestProofV2Options extends RequestProofOptions {
+ /**
+ * Ordered protocol policy copied from the RP's server-issued challenge. The backend remains the
+ * authority and MUST independently enforce this policy when it verifies the returned proof.
+ */
+ acceptedProofProtocols: AcceptedProofProtocols;
+}
+
export type NexusProofResult = Pick;
+export type NexusProofResultV2 = NexusNegotiatedProofResult;
export class NexusClient {
public readonly walletOrigin: string;
@@ -200,6 +245,186 @@ export class NexusClient {
}, POPUP_CLOSED_POLL_MS);
});
}
+
+ /**
+ * Requests a negotiated v2 popup proof. This never falls back to a v1 popup exchange: legacy
+ * proofs, when explicitly allowed, are returned inside the v2 envelope with their protocol
+ * selected and echoed.
+ */
+ public requestProofV2(
+ request: ProofRequest,
+ options: RequestProofV2Options,
+ ): Promise {
+ const browser = getBrowserWindowV2();
+ const acceptedProofProtocols = parseAcceptedProofProtocols(options.acceptedProofProtocols);
+ if (acceptedProofProtocols === undefined) {
+ return Promise.reject(
+ new NexusClientErrorV2(
+ 'INVALID_REQUEST',
+ 'acceptedProofProtocols must be a non-empty, duplicate-free ordered protocol list',
+ ),
+ );
+ }
+
+ const now = Math.floor(Date.now() / 1000);
+ let parsed: ProofRequest;
+ try {
+ parsed = parseProofRequest(request, now);
+ } catch (error) {
+ if (error instanceof ProofRequestValidationError) {
+ return Promise.reject(
+ new NexusClientErrorV2('INVALID_REQUEST', error.message, { cause: error }),
+ );
+ }
+ throw error;
+ }
+
+ const signal = options.signal;
+ if (signal?.aborted === true) {
+ return Promise.reject(
+ new NexusClientErrorV2('USER_CANCELLED', 'Proof request was cancelled'),
+ );
+ }
+
+ const configuredTimeout = parseTimeoutV2(options.timeoutMs ?? this.#timeoutMs);
+ const challengeRemainingMs = Math.max(1, parsed.expiresAt * 1000 - Date.now());
+ const timeoutMs = Math.min(configuredTimeout, challengeRemainingMs);
+ const requestId = createRequestId(browser.crypto);
+
+ return new Promise((resolve, reject) => {
+ let popup: Window | null = null;
+ let requestSent = false;
+ let settled = false;
+ let advertisedProtocols: readonly NexusOwnershipProofProtocol[] = [];
+ const timerHandles: { timeout?: number; closedPoll?: number } = {};
+
+ const cleanup = (closePopup: boolean): void => {
+ browser.removeEventListener('message', onMessage);
+ if (timerHandles.timeout !== undefined) browser.clearTimeout(timerHandles.timeout);
+ if (timerHandles.closedPoll !== undefined) browser.clearInterval(timerHandles.closedPoll);
+ signal?.removeEventListener('abort', onAbort);
+ if (closePopup && popup !== null && !popup.closed) {
+ try {
+ popup.close();
+ } catch {
+ // A navigated cross-origin popup can reject access; cleanup is still complete.
+ }
+ }
+ };
+
+ const succeed = (value: NexusProofResultV2): void => {
+ if (settled) return;
+ settled = true;
+ cleanup(true);
+ resolve(value);
+ };
+
+ const fail = (error: NexusClientErrorV2, closePopup = true): void => {
+ if (settled) return;
+ settled = true;
+ cleanup(closePopup);
+ reject(error);
+ };
+
+ const onAbort = (): void => {
+ fail(new NexusClientErrorV2('USER_CANCELLED', 'Proof request was cancelled'));
+ };
+
+ const onMessage = (event: MessageEvent): void => {
+ if (popup === null || event.source !== popup || event.origin !== this.walletOrigin) return;
+ const message = parseWalletMessageV2(event.data);
+ if (message === undefined) return;
+
+ if (message.type === 'NEXUS_READY') {
+ if (requestSent) return;
+ advertisedProtocols = message.supportedProofProtocols;
+ if (!acceptedProofProtocols.some((protocol) => advertisedProtocols.includes(protocol))) {
+ fail(
+ new NexusClientErrorV2(
+ 'UNSUPPORTED_PROOF_PROTOCOL',
+ 'Nexus wallet supports none of the proof protocols allowed by this challenge',
+ ),
+ );
+ return;
+ }
+ requestSent = true;
+ const outgoing: NexusProofRequestMessageV2 = {
+ channel: NEXUS_POPUP_CHANNEL_V2,
+ type: 'NEXUS_PROOF_REQUEST',
+ requestId,
+ request: parsed,
+ acceptedProofProtocols,
+ };
+ try {
+ popup.postMessage(outgoing, this.walletOrigin);
+ } catch (error) {
+ fail(
+ new NexusClientErrorV2('WALLET_ERROR', 'Unable to contact Nexus wallet', {
+ cause: error,
+ }),
+ );
+ }
+ return;
+ }
+
+ if (!requestSent || message.requestId !== requestId) return;
+ if (message.type === 'NEXUS_PROOF_RESULT') {
+ if (
+ !acceptedProofProtocols.includes(message.proofProtocol) ||
+ !advertisedProtocols.includes(message.proofProtocol)
+ ) {
+ fail(
+ new NexusClientErrorV2(
+ 'UNSUPPORTED_PROOF_PROTOCOL',
+ 'Nexus wallet returned a proof protocol not allowed by this challenge',
+ ),
+ );
+ return;
+ }
+ succeed({
+ proofProtocol: message.proofProtocol,
+ proof: message.proof,
+ } as NexusProofResultV2);
+ return;
+ }
+
+ const code = walletErrorToClientCodeV2(message.error.code);
+ fail(
+ new NexusClientErrorV2(code, walletErrorMessage(message.error.code), {
+ walletCodeV2: message.error.code,
+ }),
+ );
+ };
+
+ browser.addEventListener('message', onMessage);
+ signal?.addEventListener('abort', onAbort, { once: true });
+
+ try {
+ popup = browser.open(this.#walletUrl, `nexus-proof-${requestId}`, this.#popupFeatures);
+ } catch (error) {
+ fail(
+ new NexusClientErrorV2('WALLET_ERROR', 'Unable to open the Nexus wallet', {
+ cause: error,
+ }),
+ false,
+ );
+ return;
+ }
+ if (popup === null) {
+ fail(new NexusClientErrorV2('POPUP_BLOCKED', 'Nexus wallet popup was blocked'), false);
+ return;
+ }
+
+ timerHandles.timeout = browser.setTimeout(() => {
+ fail(new NexusClientErrorV2('REQUEST_TIMEOUT', 'Nexus proof request timed out'));
+ }, timeoutMs);
+ timerHandles.closedPoll = browser.setInterval(() => {
+ if (popup?.closed === true) {
+ fail(new NexusClientErrorV2('POPUP_CLOSED', 'Nexus wallet popup was closed'), false);
+ }
+ }, POPUP_CLOSED_POLL_MS);
+ });
+ }
}
export function createNexusClient(options: NexusClientOptions): NexusClient {
@@ -273,6 +498,23 @@ function getBrowserWindow(): Window {
return window;
}
+function getBrowserWindowV2(): Window {
+ if (typeof window === 'undefined') {
+ throw new NexusClientErrorV2(
+ 'UNSUPPORTED_ENVIRONMENT',
+ 'Nexus browser SDK requires a Window environment',
+ );
+ }
+ return window;
+}
+
+function parseTimeoutV2(value: number): number {
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new NexusClientErrorV2('INVALID_REQUEST', 'timeoutMs must be a positive number');
+ }
+ return value;
+}
+
function createRequestId(crypto: Crypto): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
let binary = '';
@@ -287,7 +529,12 @@ function walletErrorToClientCode(code: WalletProofErrorCode): NexusClientErrorCo
return 'WALLET_ERROR';
}
-function walletErrorMessage(code: WalletProofErrorCode): string {
+function walletErrorToClientCodeV2(code: WalletProofErrorCodeV2): NexusClientErrorCodeV2 {
+ if (code === 'UNSUPPORTED_PROOF_PROTOCOL') return 'UNSUPPORTED_PROOF_PROTOCOL';
+ return walletErrorToClientCode(code);
+}
+
+function walletErrorMessage(code: WalletProofErrorCode | WalletProofErrorCodeV2): string {
switch (code) {
case 'USER_CANCELLED':
return 'Proof request was cancelled';
@@ -303,5 +550,7 @@ function walletErrorMessage(code: WalletProofErrorCode): string {
return 'Nexus identity registration failed';
case 'INTERNAL_ERROR':
return 'Nexus wallet could not complete the request';
+ case 'UNSUPPORTED_PROOF_PROTOCOL':
+ return 'Nexus wallet cannot issue an allowed proof protocol';
}
}
diff --git a/packages/test-vectors/README.md b/packages/test-vectors/README.md
index d3382b9..516e853 100644
--- a/packages/test-vectors/README.md
+++ b/packages/test-vectors/README.md
@@ -1,7 +1,9 @@
# Nexus deterministic test vectors
These JSON files are language-neutral fixtures for Nexus v1 canonicalization, hashing, identifiers,
-and Ed25519 signatures.
+and Ed25519 signatures, plus the additive v2 device-delegation chain. The v2 vectors deliberately
+anchor every device authorization to an unchanged `IdentityGenesisV1`; there is no
+`IdentityGenesisV2`.
> **NON-PRODUCTION TEST FIXTURES:** `keys.json` contains public RFC 8032 test seeds wrapped in
> PKCS#8. The corresponding private keys are public knowledge. Never use these keys, seeds,
@@ -10,3 +12,15 @@ and Ed25519 signatures.
Regenerate with `pnpm --filter @nexus/test-vectors generate`, then independently verify with
`pnpm --filter @nexus/test-vectors verify`. Generation imports fixed PKCS#8 fixtures; it never
replaces or controls Web Crypto randomness.
+
+The committed v1 files remain byte-for-byte stable. The `*-v2.json` files cover:
+
+- device ID and authorization ID derivation, including the root authorization signature;
+- activation operation ID derivation and the device proof-of-possession signature;
+- device-signed ownership proofs embedding the root authorization;
+- device registry event IDs and service-signed receipts; and
+- service-signed combined identity/device status statements.
+
+The root key uses RFC 8032 TEST 1 (`identity-a`), the device key uses TEST 2 (`identity-b`), and
+registry statements use TEST 3 (`service-registry`). Verification recomputes every preimage, hash,
+identifier, and signature, and includes payload/signature tamper checks.
diff --git a/packages/test-vectors/package.json b/packages/test-vectors/package.json
index 6fe49e5..75a6987 100644
--- a/packages/test-vectors/package.json
+++ b/packages/test-vectors/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"sideEffects": false,
- "description": "Language-neutral deterministic cryptographic vectors for ROwO Nexus v1.",
+ "description": "Language-neutral deterministic cryptographic vectors for ROwO Nexus v1 and v2 device delegation.",
"license": "MIT",
"exports": {
".": {
diff --git a/packages/test-vectors/src/generate.ts b/packages/test-vectors/src/generate.ts
index 1ba51f7..8d51391 100644
--- a/packages/test-vectors/src/generate.ts
+++ b/packages/test-vectors/src/generate.ts
@@ -7,6 +7,10 @@ import {
createProtocolSignaturePreimage,
createRegistryEventHashPreimage,
createRevocationCommitmentPreimage,
+ deriveDeviceAuthorizationIdV2,
+ deriveDeviceIdV2,
+ deriveDeviceOperationIdV2,
+ deriveDeviceRegistryEventIdV2,
deriveGenesisHash,
deriveRegistryEventId,
deriveSubject,
@@ -16,11 +20,17 @@ import {
} from '@nexus/crypto';
import {
CONTINUITY_LINK_PROTOCOL_V1,
+ DEVICE_ACTIVATION_PROTOCOL_V2,
+ DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ DEVICE_REGISTRY_EVENT_PROTOCOL_V2,
+ DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
ED25519_ALGORITHM,
EDDSA_JWK_ALGORITHM,
IDENTITY_PROTOCOL_V1,
NEXUS_SUITE_V1,
OWNERSHIP_PROOF_PROTOCOL_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V2,
REGISTRY_EVENT_PROTOCOL_V1,
REGISTRY_RECEIPT_PROTOCOL_V1,
REVOKE_PROTOCOL_V1,
@@ -30,21 +40,37 @@ import {
TRANSPARENCY_INCLUSION_PROOF_PROTOCOL_V1,
X25519_ALGORITHM,
canonicalize,
+ createDeviceAuthorizationHashPreimageV2,
+ createDeviceIdHashPreimageV2,
+ createDeviceOperationHashPreimageV2,
+ createDeviceRegistryEventHashPreimageV2,
createGlobalTransparencyCheckpointSignaturePreimage,
createTransparencyCheckpointSignaturePreimage,
encodeBase64Url,
+ deviceActivationPayloadV2Schema,
+ deviceAuthorizationPayloadV2Schema,
+ deviceAuthorizationV2Schema,
+ deviceIdInputV2Schema,
+ deviceRegistryEventWithoutEventIdV2Schema,
+ deviceRegistryReceiptPayloadV2Schema,
+ deviceStatusStatementPayloadV2Schema,
identityGenesisV1Schema,
+ ownershipProofPayloadV2Schema,
registryEventWithoutEventIdV1Schema,
signedGlobalTransparencyCheckpointV1Schema,
signedTransparencyCheckpointV1Schema,
} from '@nexus/protocol';
import type {
ContinuityLinkPayloadV1,
+ DeviceRegistryEventV2,
+ DeviceRegistryReceiptPayloadV2,
+ DeviceStatusStatementPayloadV2,
Ed25519PublicJwk,
GlobalCheckpointShardV1,
GlobalTransparencyCheckpointPayloadV1,
IdentityGenesisV1,
OwnershipProofPayloadV1,
+ OwnershipProofPayloadV2,
RegistryReceiptPayloadV1,
RevokeBySignaturePayloadV1,
StatusStatementPayloadV1,
@@ -65,6 +91,7 @@ import {
} from './fixtures.js';
const VECTOR_SCHEMA = 'nexus.test-vectors.v1';
+const VECTOR_SCHEMA_V2 = 'nexus.test-vectors.v2';
const SERVICE_KEY_ID = 'registry-fixture-2026-01';
const TRANSPARENCY_KEY_ID = 'transparency-fixture-2026-01';
@@ -189,6 +216,140 @@ export async function buildTestVectorFiles(): Promise {
+function tamperBase64Url(value: string): string {
+ assert.ok(value.length > 0, 'Cannot tamper with an empty base64url value.');
+ return `${value[0] === 'A' ? 'B' : 'A'}${value.slice(1)}`;
+}
+
+async function loadVector(
+ directory: URL,
+ name: string,
+ schema = 'nexus.test-vectors.v1',
+): Promise {
const parsed: unknown = JSON.parse(await readFile(new URL(name, directory), 'utf8'));
const vector = object(parsed, name);
- assert.equal(vector.schema, 'nexus.test-vectors.v1', `${name}: schema`);
+ assert.equal(vector.schema, schema, `${name}: schema`);
return vector;
}
@@ -217,6 +246,286 @@ export async function verifyTestVectorDirectory(
assert.equal(subject, identity.subject);
await verifySubject(genesis, subject);
+ const deviceAuthorizationVector = await loadVector(
+ directory,
+ 'device-authorization-v2.json',
+ 'nexus.test-vectors.v2',
+ );
+ assert.match(
+ string(deviceAuthorizationVector.warning, 'device authorization warning'),
+ /NON-PRODUCTION/u,
+ );
+ assert.equal(deviceAuthorizationVector.identityGenesisProtocol, 'nexus.identity.v1');
+ assert.deepEqual(identityGenesisV1Schema.parse(deviceAuthorizationVector.genesis), genesis);
+ assert.equal(deviceAuthorizationVector.genesisHashBase64Url, encodeBase64Url(genesisHash));
+ const deviceIdInput = deviceIdInputV2Schema.parse(deviceAuthorizationVector.deviceIdInput);
+ assert.equal(canonicalize(deviceIdInput), deviceAuthorizationVector.canonicalDeviceIdInputJcs);
+ assert.equal(
+ bytesToHex(createDeviceIdHashPreimageV2(deviceIdInput)),
+ deviceAuthorizationVector.deviceIdHashPreimageHex,
+ );
+ const deviceId = await deriveDeviceIdV2(deviceIdInput);
+ assert.equal(deviceId, deviceAuthorizationVector.deviceId);
+
+ const authorization = deviceAuthorizationV2Schema.parse(deviceAuthorizationVector.authorization);
+ assertExpectedSignedFields(
+ deviceAuthorizationVector,
+ authorization.payload,
+ authorization.rootSignature,
+ );
+ assert.equal(
+ bytesToHex(createDeviceAuthorizationHashPreimageV2(authorization.payload)),
+ deviceAuthorizationVector.authorizationIdHashPreimageHex,
+ );
+ const authorizationId = await deriveDeviceAuthorizationIdV2(authorization.payload);
+ assert.equal(authorizationId, deviceAuthorizationVector.authorizationId);
+ assert.equal(
+ await signProtocolPayload(authorization.payload, identityKeyA.privateKey),
+ authorization.rootSignature,
+ );
+ assert.equal(
+ await verifyProtocolPayload(
+ authorization.payload,
+ authorization.rootSignature,
+ identityKeyA.publicKey,
+ ),
+ true,
+ );
+ assert.equal(
+ await verifyProtocolPayload(
+ authorization.payload,
+ tamperBase64Url(authorization.rootSignature),
+ identityKeyA.publicKey,
+ ),
+ false,
+ );
+ const authorizationPayloadTampered = {
+ ...authorization.payload,
+ expiresAt: authorization.payload.expiresAt + 1,
+ };
+ assert.notEqual(
+ await deriveDeviceAuthorizationIdV2(authorizationPayloadTampered),
+ authorizationId,
+ );
+ assert.equal(
+ await verifyProtocolPayload(
+ authorizationPayloadTampered,
+ authorization.rootSignature,
+ identityKeyA.publicKey,
+ ),
+ false,
+ );
+ assert.notEqual(
+ await deriveDeviceIdV2(
+ deviceIdInputV2Schema.parse({
+ ...deviceIdInput,
+ signingKey: {
+ ...deviceIdInput.signingKey,
+ publicKey: serviceKey.publicKeyBase64Url,
+ },
+ }),
+ ),
+ deviceId,
+ );
+
+ const activationVector = await loadVector(
+ directory,
+ 'device-activation-v2.json',
+ 'nexus.test-vectors.v2',
+ );
+ const activation = deviceActivationRequestV2Schema.parse(activationVector.request);
+ assert.deepEqual(activation.authorization, authorization);
+ assertExpectedSignedFields(activationVector, activation.payload, activation.deviceSignature);
+ assert.equal(
+ bytesToHex(createDeviceOperationHashPreimageV2(activation.payload)),
+ activationVector.operationIdHashPreimageHex,
+ );
+ const operationId = await deriveDeviceOperationIdV2(activation.payload);
+ assert.equal(operationId, activationVector.operationId);
+ assert.equal(
+ await signProtocolPayload(activation.payload, identityKeyB.privateKey),
+ activation.deviceSignature,
+ );
+ assert.equal(
+ await verifyProtocolPayload(
+ activation.payload,
+ activation.deviceSignature,
+ identityKeyB.publicKey,
+ ),
+ true,
+ );
+ assert.equal(
+ await verifyProtocolPayload(
+ activation.payload,
+ tamperBase64Url(activation.deviceSignature),
+ identityKeyB.publicKey,
+ ),
+ false,
+ );
+ assert.notEqual(
+ await deriveDeviceOperationIdV2({
+ ...activation.payload,
+ requestId: authorization.payload.authorizationNonce,
+ }),
+ operationId,
+ );
+
+ const ownershipV2Vector = await loadVector(
+ directory,
+ 'ownership-proof-v2.json',
+ 'nexus.test-vectors.v2',
+ );
+ const proofV2 = ownershipProofV2Schema.parse(ownershipV2Vector.proof);
+ assert.deepEqual(proofV2.payload.genesis, genesis);
+ assert.deepEqual(proofV2.payload.authorization, authorization);
+ assertExpectedSignedFields(ownershipV2Vector, proofV2.payload, proofV2.deviceSignature);
+ assert.equal(
+ await signProtocolPayload(proofV2.payload, identityKeyB.privateKey),
+ proofV2.deviceSignature,
+ );
+ assert.equal(
+ await verifyProtocolPayload(proofV2.payload, proofV2.deviceSignature, identityKeyB.publicKey),
+ true,
+ );
+ const ownershipV2Expectation = {
+ audience: proofV2.payload.aud,
+ action: proofV2.payload.act,
+ resource: proofV2.payload.resource,
+ nonce: proofV2.payload.nonce,
+ contextHash: proofV2.payload.contextHash ?? null,
+ now: proofV2.payload.iat,
+ maxClockSkewSeconds: 0,
+ };
+ const verifiedDevice = await verifyOwnershipProofV2(proofV2, ownershipV2Expectation);
+ assert.equal(verifiedDevice.subject, subject);
+ assert.equal(verifiedDevice.deviceId, deviceId);
+ assert.equal(verifiedDevice.authorizationId, authorizationId);
+ await assert.rejects(
+ verifyOwnershipProofV2(
+ { ...proofV2, deviceSignature: tamperBase64Url(proofV2.deviceSignature) },
+ ownershipV2Expectation,
+ ),
+ );
+
+ const deviceEventVector = await loadVector(
+ directory,
+ 'device-registry-event-v2.json',
+ 'nexus.test-vectors.v2',
+ );
+ const deviceEventWithoutId = deviceRegistryEventWithoutEventIdV2Schema.parse(
+ deviceEventVector.eventWithoutEventId,
+ );
+ const deviceEvent = deviceRegistryEventV2Schema.parse(deviceEventVector.event);
+ const eventOperationPayload = deviceActivationPayloadV2Schema.parse(
+ deviceEventVector.operationPayload,
+ );
+ assert.deepEqual(eventOperationPayload, activation.payload);
+ assert.equal(
+ bytesToHex(createDeviceOperationHashPreimageV2(eventOperationPayload)),
+ deviceEventVector.operationIdHashPreimageHex,
+ );
+ assert.equal(canonicalize(deviceEventWithoutId), deviceEventVector.canonicalEventJcs);
+ assert.equal(
+ bytesToHex(createDeviceRegistryEventHashPreimageV2(deviceEventWithoutId)),
+ deviceEventVector.eventHashPreimageHex,
+ );
+ const derivedDeviceEvent = await deriveDeviceRegistryEventIdV2(deviceEventWithoutId);
+ assert.equal(bytesToHex(derivedDeviceEvent.eventHash), deviceEventVector.eventHashHex);
+ assert.equal(encodeBase64Url(derivedDeviceEvent.eventHash), deviceEventVector.eventHashBase64Url);
+ assert.equal(derivedDeviceEvent.eventId, deviceEventVector.eventId);
+ assert.equal(deviceEvent.eventId, derivedDeviceEvent.eventId);
+ assert.equal(deviceEvent.operationId, operationId);
+ assert.notEqual(
+ (
+ await deriveDeviceRegistryEventIdV2({
+ ...deviceEventWithoutId,
+ acceptedAt: deviceEventWithoutId.acceptedAt + 1,
+ })
+ ).eventId,
+ deviceEvent.eventId,
+ );
+
+ const deviceReceiptVector = await loadVector(
+ directory,
+ 'device-registry-receipt-v2.json',
+ 'nexus.test-vectors.v2',
+ );
+ const deviceReceipt = deviceRegistryReceiptV2Schema.parse(deviceReceiptVector.receipt);
+ const deviceReceiptKeyset = serviceKeySetSchema.parse(deviceReceiptVector.keyset);
+ assertExpectedSignedFields(deviceReceiptVector, deviceReceipt.payload, deviceReceipt.signature);
+ assert.equal(
+ await signProtocolPayload(deviceReceipt.payload, serviceKey.privateKey),
+ deviceReceipt.signature,
+ );
+ assert.equal(deviceReceipt.payload.eventId, deviceEvent.eventId);
+ assert.equal(deviceReceipt.payload.operationId, deviceEvent.operationId);
+ assert.ok(deviceReceipt.payload.authorizationId);
+ assert.ok(deviceReceipt.payload.authorizationExpiresAt);
+ const receiptAuthorizationId = deviceReceipt.payload.authorizationId;
+ const receiptAuthorizationExpiresAt = deviceReceipt.payload.authorizationExpiresAt;
+ await verifyDeviceRegistryReceipt(deviceReceipt, deviceReceiptKeyset, {
+ eventId: deviceReceipt.payload.eventId,
+ operationId: deviceReceipt.payload.operationId,
+ subject: deviceReceipt.payload.subject,
+ genesisHash: deviceReceipt.payload.genesisHash,
+ eventType: deviceReceipt.payload.eventType,
+ identityState: deviceReceipt.payload.identityState,
+ identitySequence: deviceReceipt.payload.identitySequence,
+ deviceLedgerSequence: deviceReceipt.payload.deviceLedgerSequence,
+ deviceId: deviceReceipt.payload.deviceId,
+ authorizationId: receiptAuthorizationId,
+ deviceState: deviceReceipt.payload.deviceState,
+ authorizationExpiresAt: receiptAuthorizationExpiresAt,
+ acceptedAt: deviceReceipt.payload.acceptedAt,
+ });
+ await assert.rejects(
+ verifyDeviceRegistryReceipt(
+ { ...deviceReceipt, signature: tamperBase64Url(deviceReceipt.signature) },
+ deviceReceiptKeyset,
+ ),
+ );
+
+ const deviceStatusVector = await loadVector(
+ directory,
+ 'device-status-statement-v2.json',
+ 'nexus.test-vectors.v2',
+ );
+ const deviceStatus = deviceStatusStatementV2Schema.parse(deviceStatusVector.statement);
+ const deviceStatusKeyset = serviceKeySetSchema.parse(deviceStatusVector.keyset);
+ assertExpectedSignedFields(deviceStatusVector, deviceStatus.payload, deviceStatus.signature);
+ assert.equal(
+ await signProtocolPayload(deviceStatus.payload, serviceKey.privateKey),
+ deviceStatus.signature,
+ );
+ assert.equal(deviceStatus.payload.deviceId, deviceReceipt.payload.deviceId);
+ assert.equal(deviceStatus.payload.authorizationId, deviceReceipt.payload.authorizationId);
+ assert.ok(deviceStatus.payload.authorizationExpiresAt);
+ const statusAuthorizationExpiresAt = deviceStatus.payload.authorizationExpiresAt;
+ await verifyDeviceStatusStatement(
+ deviceStatus,
+ deviceStatusKeyset,
+ integer(deviceStatusVector.verificationTime, 'device status verificationTime'),
+ {
+ subject: deviceStatus.payload.subject,
+ genesisHash: deviceStatus.payload.genesisHash,
+ deviceId: deviceStatus.payload.deviceId,
+ authorizationId: deviceStatus.payload.authorizationId,
+ identityState: deviceStatus.payload.identityState,
+ identitySequence: deviceStatus.payload.identitySequence,
+ deviceLedgerSequence: deviceStatus.payload.deviceLedgerSequence,
+ deviceState: deviceStatus.payload.deviceState,
+ authorizationExpiresAt: statusAuthorizationExpiresAt,
+ maxClockSkewSeconds: 0,
+ },
+ );
+ await assert.rejects(
+ verifyDeviceStatusStatement(
+ { ...deviceStatus, signature: tamperBase64Url(deviceStatus.signature) },
+ deviceStatusKeyset,
+ deviceStatus.payload.iat,
+ ),
+ );
+
const ownership = await loadVector(directory, 'ownership-proof.json');
const proof = ownershipProofV1Schema.parse(ownership.proof);
assertExpectedSignedFields(ownership, proof.payload, proof.signature);
@@ -496,6 +805,12 @@ export async function verifyTestVectorDirectory(
'status-statement',
'signing-key-revoke',
'continuity-link',
+ 'device-activation-v2',
+ 'device-authorization-v2',
+ 'device-registry-event-v2',
+ 'device-registry-receipt-v2',
+ 'device-status-statement-v2',
+ 'ownership-proof-v2',
'transparency',
];
}
diff --git a/packages/test-vectors/test/vectors.test.ts b/packages/test-vectors/test/vectors.test.ts
index c031868..41d537e 100644
--- a/packages/test-vectors/test/vectors.test.ts
+++ b/packages/test-vectors/test/vectors.test.ts
@@ -18,6 +18,9 @@ describe('committed Nexus test vectors', () => {
});
it('pass independent protocol, crypto, and verifier checks', async () => {
- await expect(verifyTestVectorDirectory(vectorDirectory)).resolves.toContain('continuity-link');
+ const verified = await verifyTestVectorDirectory(vectorDirectory);
+ expect(verified).toContain('continuity-link');
+ expect(verified).toContain('ownership-proof-v2');
+ expect(verified).toHaveLength(18);
});
});
diff --git a/packages/test-vectors/vectors/device-activation-v2.json b/packages/test-vectors/vectors/device-activation-v2.json
new file mode 100644
index 0000000..807780e
--- /dev/null
+++ b/packages/test-vectors/vectors/device-activation-v2.json
@@ -0,0 +1,49 @@
+{
+ "schema": "nexus.test-vectors.v2",
+ "warning": "NON-PRODUCTION TEST FIXTURE: public RFC 8032 key material; never use for real identities or services.",
+ "deviceSigningKeyId": "identity-b",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "payload": {
+ "protocol": "nexus.device-activation.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "requestId": "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8",
+ "iat": 1786400010,
+ "exp": 1786400070
+ },
+ "operationIdHashPreimageHex": "4e455855532d4445564943452d4f5045524154494f4e007632007b22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870223a313738363430303037302c22696174223a313738363430303031302c2270726f746f636f6c223a226e657875732e6465766963652d61637469766174696f6e2e7632222c22726571756573744964223a22594746695932526c5a6d646f615770726247317562334278636e4e3064585a3365486c3665337839666e38222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "canonicalPayloadJcs": "{\"authorizationId\":\"nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"exp\":1786400070,\"iat\":1786400010,\"protocol\":\"nexus.device-activation.v2\",\"requestId\":\"YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8\",\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\"}",
+ "signaturePreimageHex": "4e455855532d5349474e4154555245006e657875732e6465766963652d61637469766174696f6e2e7632007b22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870223a313738363430303037302c22696174223a313738363430303031302c2270726f746f636f6c223a226e657875732e6465766963652d61637469766174696f6e2e7632222c22726571756573744964223a22594746695932526c5a6d646f615770726247317562334278636e4e3064585a3365486c3665337839666e38222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "signatureBase64Url": "WxyKyj-TdQL5cLLGOmde-k52EqR9FSMj5dosVjkd3weNKFRnMK6hFkF3iUMkE5pGUIArQmXH2NWLBPDF5OGQAQ",
+ "request": {
+ "authorization": {
+ "payload": {
+ "protocol": "nexus.device-authorization.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "signingKey": {
+ "alg": "Ed25519",
+ "publicKey": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw"
+ },
+ "authorizationNonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8",
+ "validFrom": 1786400000,
+ "activationDeadline": 1786403600,
+ "expiresAt": 1786486400
+ },
+ "rootSignature": "qdjva3WXJ8C1N6ktnEx6RH1OFGhCUwdjlT1G2Lwx9fXiBDXUnxn0AoW4YxIcwe1o2likckeKx8HaD6fnBYvjCA"
+ },
+ "payload": {
+ "protocol": "nexus.device-activation.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "requestId": "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8",
+ "iat": 1786400010,
+ "exp": 1786400070
+ },
+ "deviceSignature": "WxyKyj-TdQL5cLLGOmde-k52EqR9FSMj5dosVjkd3weNKFRnMK6hFkF3iUMkE5pGUIArQmXH2NWLBPDF5OGQAQ"
+ }
+}
diff --git a/packages/test-vectors/vectors/device-authorization-v2.json b/packages/test-vectors/vectors/device-authorization-v2.json
new file mode 100644
index 0000000..4f8883f
--- /dev/null
+++ b/packages/test-vectors/vectors/device-authorization-v2.json
@@ -0,0 +1,55 @@
+{
+ "schema": "nexus.test-vectors.v2",
+ "warning": "NON-PRODUCTION TEST FIXTURE: public RFC 8032 key material; never use for real identities or services.",
+ "rootSigningKeyId": "identity-a",
+ "deviceSigningKeyId": "identity-b",
+ "identityGenesisProtocol": "nexus.identity.v1",
+ "genesis": {
+ "protocol": "nexus.identity.v1",
+ "suite": "NX-25519-SHA256-JCS-v1",
+ "signingKey": { "alg": "Ed25519", "publicKey": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" },
+ "agreementKey": { "alg": "X25519", "publicKey": "hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo" },
+ "revocationCommitment": "_W__y81RfxyMr7yZ6dIpNYYBOkmq1iMTYB_mXUfAjyU"
+ },
+ "genesisHashBase64Url": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceIdInput": {
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "signingKey": { "alg": "Ed25519", "publicKey": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw" }
+ },
+ "canonicalDeviceIdInputJcs": "{\"signingKey\":{\"alg\":\"Ed25519\",\"publicKey\":\"PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw\"},\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\"}",
+ "deviceIdHashPreimageHex": "4e455855532d4445564943452d4944007632007b227369676e696e674b6579223a7b22616c67223a2245643235353139222c227075626c69634b6579223a2250554158772d6844695671537477716e5452742d764a79594c4d3875784a614d774d3156385372305a6777227d2c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "payload": {
+ "protocol": "nexus.device-authorization.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "signingKey": { "alg": "Ed25519", "publicKey": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw" },
+ "authorizationNonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8",
+ "validFrom": 1786400000,
+ "activationDeadline": 1786403600,
+ "expiresAt": 1786486400
+ },
+ "authorizationIdHashPreimageHex": "4e455855532d4445564943452d415554484f52495a4154494f4e007632007b2261637469766174696f6e446561646c696e65223a313738363430333630302c22617574686f72697a6174696f6e4e6f6e6365223a225145464351305246526b64495355704c5445314f54314252556c4e5556565a5857466c6157317864586c38222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870697265734174223a313738363438363430302c2267656e6573697348617368223a224e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c2270726f746f636f6c223a226e657875732e6465766963652d617574686f72697a6174696f6e2e7632222c227369676e696e674b6579223a7b22616c67223a2245643235353139222c227075626c69634b6579223a2250554158772d6844695671537477716e5452742d764a79594c4d3875784a614d774d3156385372305a6777227d2c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c2276616c696446726f6d223a313738363430303030307d",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "canonicalPayloadJcs": "{\"activationDeadline\":1786403600,\"authorizationNonce\":\"QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"expiresAt\":1786486400,\"genesisHash\":\"NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"protocol\":\"nexus.device-authorization.v2\",\"signingKey\":{\"alg\":\"Ed25519\",\"publicKey\":\"PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw\"},\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"validFrom\":1786400000}",
+ "signaturePreimageHex": "4e455855532d5349474e4154555245006e657875732e6465766963652d617574686f72697a6174696f6e2e7632007b2261637469766174696f6e446561646c696e65223a313738363430333630302c22617574686f72697a6174696f6e4e6f6e6365223a225145464351305246526b64495355704c5445314f54314252556c4e5556565a5857466c6157317864586c38222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870697265734174223a313738363438363430302c2267656e6573697348617368223a224e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c2270726f746f636f6c223a226e657875732e6465766963652d617574686f72697a6174696f6e2e7632222c227369676e696e674b6579223a7b22616c67223a2245643235353139222c227075626c69634b6579223a2250554158772d6844695671537477716e5452742d764a79594c4d3875784a614d774d3156385372305a6777227d2c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c2276616c696446726f6d223a313738363430303030307d",
+ "signatureBase64Url": "qdjva3WXJ8C1N6ktnEx6RH1OFGhCUwdjlT1G2Lwx9fXiBDXUnxn0AoW4YxIcwe1o2likckeKx8HaD6fnBYvjCA",
+ "authorization": {
+ "payload": {
+ "protocol": "nexus.device-authorization.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "signingKey": {
+ "alg": "Ed25519",
+ "publicKey": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw"
+ },
+ "authorizationNonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8",
+ "validFrom": 1786400000,
+ "activationDeadline": 1786403600,
+ "expiresAt": 1786486400
+ },
+ "rootSignature": "qdjva3WXJ8C1N6ktnEx6RH1OFGhCUwdjlT1G2Lwx9fXiBDXUnxn0AoW4YxIcwe1o2likckeKx8HaD6fnBYvjCA"
+ }
+}
diff --git a/packages/test-vectors/vectors/device-registry-event-v2.json b/packages/test-vectors/vectors/device-registry-event-v2.json
new file mode 100644
index 0000000..f815c97
--- /dev/null
+++ b/packages/test-vectors/vectors/device-registry-event-v2.json
@@ -0,0 +1,53 @@
+{
+ "schema": "nexus.test-vectors.v2",
+ "warning": "NON-PRODUCTION TEST FIXTURE: public RFC 8032 key material; never use for real identities or services.",
+ "operationPayload": {
+ "protocol": "nexus.device-activation.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "requestId": "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8",
+ "iat": 1786400010,
+ "exp": 1786400070
+ },
+ "operationIdHashPreimageHex": "4e455855532d4445564943452d4f5045524154494f4e007632007b22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870223a313738363430303037302c22696174223a313738363430303031302c2270726f746f636f6c223a226e657875732e6465766963652d61637469766174696f6e2e7632222c22726571756573744964223a22594746695932526c5a6d646f615770726247317562334278636e4e3064585a3365486c3665337839666e38222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "eventWithoutEventId": {
+ "protocol": "nexus.device-registry-event.v2",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "eventType": "activated",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "identitySequence": 0,
+ "identityState": "active",
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "authorizationExpiresAt": 1786486400,
+ "acceptedAt": 1786400015,
+ "actionHash": "YnExth0Q3U8YHmugZgCkQc2hOYtScSBWiDdqXf7Fr5U"
+ },
+ "canonicalEventJcs": "{\"acceptedAt\":1786400015,\"actionHash\":\"YnExth0Q3U8YHmugZgCkQc2hOYtScSBWiDdqXf7Fr5U\",\"authorizationExpiresAt\":1786486400,\"authorizationId\":\"nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"deviceLedgerSequence\":1,\"deviceState\":\"active\",\"eventType\":\"activated\",\"genesisHash\":\"NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"identitySequence\":0,\"identityState\":\"active\",\"operationId\":\"nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc\",\"protocol\":\"nexus.device-registry-event.v2\",\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\"}",
+ "eventHashPreimageHex": "4e455855532d4445564943452d52454749535452592d4556454e54007632007b2261636365707465644174223a313738363430303031352c22616374696f6e48617368223a22596e45787468305133553859486d75675a67436b516332684f597453635342576944647158663746723555222c22617574686f72697a6174696f6e457870697265734174223a313738363438363430302c22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c226465766963654c656467657253657175656e6365223a312c226465766963655374617465223a22616374697665222c226576656e7454797065223a22616374697661746564222c2267656e6573697348617368223a224e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c226964656e7469747953657175656e6365223a302c226964656e746974795374617465223a22616374697665222c226f7065726174696f6e4964223a226e786f325f356973486b694f31735868716242722d562d445453475070437170355a633545315164766a764e6b4b6f63222c2270726f746f636f6c223a226e657875732e6465766963652d72656769737472792d6576656e742e7632222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "eventHashHex": "0af9c60a205d10de8471152508c95dde41586fd83ed346f1c396cad05e31b597",
+ "eventHashBase64Url": "CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc",
+ "eventId": "nxde2_CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc",
+ "event": {
+ "protocol": "nexus.device-registry-event.v2",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "eventType": "activated",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "identitySequence": 0,
+ "identityState": "active",
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "authorizationExpiresAt": 1786486400,
+ "acceptedAt": 1786400015,
+ "actionHash": "YnExth0Q3U8YHmugZgCkQc2hOYtScSBWiDdqXf7Fr5U",
+ "eventId": "nxde2_CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc"
+ }
+}
diff --git a/packages/test-vectors/vectors/device-registry-receipt-v2.json b/packages/test-vectors/vectors/device-registry-receipt-v2.json
new file mode 100644
index 0000000..82c68c2
--- /dev/null
+++ b/packages/test-vectors/vectors/device-registry-receipt-v2.json
@@ -0,0 +1,72 @@
+{
+ "schema": "nexus.test-vectors.v2",
+ "warning": "NON-PRODUCTION TEST FIXTURE: public RFC 8032 key material; never use for real identities or services.",
+ "signingKeyId": "service-registry",
+ "keyset": {
+ "keys": [
+ {
+ "kty": "OKP",
+ "crv": "Ed25519",
+ "alg": "EdDSA",
+ "kid": "registry-fixture-2026-01",
+ "x": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU",
+ "use": "sig"
+ }
+ ]
+ },
+ "payload": {
+ "protocol": "nexus.device-registry-receipt.v2",
+ "eventId": "nxde2_CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "eventType": "activated",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "identitySequence": 0,
+ "identityState": "active",
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "authorizationExpiresAt": 1786486400,
+ "acceptedAt": 1786400015,
+ "signerKid": "registry-fixture-2026-01"
+ },
+ "canonicalPayloadJcs": "{\"acceptedAt\":1786400015,\"authorizationExpiresAt\":1786486400,\"authorizationId\":\"nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"deviceLedgerSequence\":1,\"deviceState\":\"active\",\"eventId\":\"nxde2_CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc\",\"eventType\":\"activated\",\"genesisHash\":\"NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"identitySequence\":0,\"identityState\":\"active\",\"operationId\":\"nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc\",\"protocol\":\"nexus.device-registry-receipt.v2\",\"signerKid\":\"registry-fixture-2026-01\",\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\"}",
+ "signaturePreimageHex": "4e455855532d5349474e4154555245006e657875732e6465766963652d72656769737472792d726563656970742e7632007b2261636365707465644174223a313738363430303031352c22617574686f72697a6174696f6e457870697265734174223a313738363438363430302c22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c226465766963654c656467657253657175656e6365223a312c226465766963655374617465223a22616374697665222c226576656e744964223a226e786465325f43766e4743694264454e36456352556c434d6c64336b46596239672d303062787735624b30463478745a63222c226576656e7454797065223a22616374697661746564222c2267656e6573697348617368223a224e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c226964656e7469747953657175656e6365223a302c226964656e746974795374617465223a22616374697665222c226f7065726174696f6e4964223a226e786f325f356973486b694f31735868716242722d562d445453475070437170355a633545315164766a764e6b4b6f63222c2270726f746f636f6c223a226e657875732e6465766963652d72656769737472792d726563656970742e7632222c227369676e65724b6964223a2272656769737472792d666978747572652d323032362d3031222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "signatureBase64Url": "WoK-JZ86_ehGE2_5DfOZSkwVapc3BHBNawlW43r9grlfFrSzDuiJ2BkHsWhtZ7CaFS9UtvF7plluJ5l6Cb8vBg",
+ "receipt": {
+ "payload": {
+ "protocol": "nexus.device-registry-receipt.v2",
+ "eventId": "nxde2_CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "eventType": "activated",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "identitySequence": 0,
+ "identityState": "active",
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "authorizationExpiresAt": 1786486400,
+ "acceptedAt": 1786400015,
+ "signerKid": "registry-fixture-2026-01"
+ },
+ "signature": "WoK-JZ86_ehGE2_5DfOZSkwVapc3BHBNawlW43r9grlfFrSzDuiJ2BkHsWhtZ7CaFS9UtvF7plluJ5l6Cb8vBg"
+ },
+ "verification": {
+ "eventId": "nxde2_CvnGCiBdEN6EcRUlCMld3kFYb9g-00bxw5bK0F4xtZc",
+ "operationId": "nxo2_5isHkiO1sXhqbBr-V-DTSGPpCqp5Zc5E1QdvjvNkKoc",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "eventType": "activated",
+ "identityState": "active",
+ "identitySequence": 0,
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "authorizationExpiresAt": 1786486400,
+ "acceptedAt": 1786400015
+ }
+}
diff --git a/packages/test-vectors/vectors/device-status-statement-v2.json b/packages/test-vectors/vectors/device-status-statement-v2.json
new file mode 100644
index 0000000..e2dd072
--- /dev/null
+++ b/packages/test-vectors/vectors/device-status-statement-v2.json
@@ -0,0 +1,68 @@
+{
+ "schema": "nexus.test-vectors.v2",
+ "warning": "NON-PRODUCTION TEST FIXTURE: public RFC 8032 key material; never use for real identities or services.",
+ "signingKeyId": "service-registry",
+ "keyset": {
+ "keys": [
+ {
+ "kty": "OKP",
+ "crv": "Ed25519",
+ "alg": "EdDSA",
+ "kid": "registry-fixture-2026-01",
+ "x": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU",
+ "use": "sig"
+ }
+ ]
+ },
+ "verificationTime": 1786400046,
+ "payload": {
+ "protocol": "nexus.device-status-statement.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "identityState": "active",
+ "identitySequence": 0,
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "activatedAt": 1786400015,
+ "authorizationExpiresAt": 1786486400,
+ "iat": 1786400016,
+ "exp": 1786400076,
+ "signerKid": "registry-fixture-2026-01"
+ },
+ "canonicalPayloadJcs": "{\"activatedAt\":1786400015,\"authorizationExpiresAt\":1786486400,\"authorizationId\":\"nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"deviceLedgerSequence\":1,\"deviceState\":\"active\",\"exp\":1786400076,\"genesisHash\":\"NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"iat\":1786400016,\"identitySequence\":0,\"identityState\":\"active\",\"protocol\":\"nexus.device-status-statement.v2\",\"signerKid\":\"registry-fixture-2026-01\",\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\"}",
+ "signaturePreimageHex": "4e455855532d5349474e4154555245006e657875732e6465766963652d7374617475732d73746174656d656e742e7632007b226163746976617465644174223a313738363430303031352c22617574686f72697a6174696f6e457870697265734174223a313738363438363430302c22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c226465766963654c656467657253657175656e6365223a312c226465766963655374617465223a22616374697665222c22657870223a313738363430303037362c2267656e6573697348617368223a224e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c22696174223a313738363430303031362c226964656e7469747953657175656e6365223a302c226964656e746974795374617465223a22616374697665222c2270726f746f636f6c223a226e657875732e6465766963652d7374617475732d73746174656d656e742e7632222c227369676e65724b6964223a2272656769737472792d666978747572652d323032362d3031222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "signatureBase64Url": "nTzDS1hszaR7oUMMyGBCzUPfLgqDkGLAIfsP0_8JMSvO-YiR-ftTCQjtLh_LFM-aRUNaiMgEQrHTX_Mmf4AHAw",
+ "statement": {
+ "payload": {
+ "protocol": "nexus.device-status-statement.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "identityState": "active",
+ "identitySequence": 0,
+ "deviceLedgerSequence": 1,
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "deviceState": "active",
+ "activatedAt": 1786400015,
+ "authorizationExpiresAt": 1786486400,
+ "iat": 1786400016,
+ "exp": 1786400076,
+ "signerKid": "registry-fixture-2026-01"
+ },
+ "signature": "nTzDS1hszaR7oUMMyGBCzUPfLgqDkGLAIfsP0_8JMSvO-YiR-ftTCQjtLh_LFM-aRUNaiMgEQrHTX_Mmf4AHAw"
+ },
+ "verification": {
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "identityState": "active",
+ "identitySequence": 0,
+ "deviceLedgerSequence": 1,
+ "deviceState": "active",
+ "authorizationExpiresAt": 1786486400,
+ "maxClockSkewSeconds": 0
+ }
+}
diff --git a/packages/test-vectors/vectors/ownership-proof-v2.json b/packages/test-vectors/vectors/ownership-proof-v2.json
new file mode 100644
index 0000000..6752ea5
--- /dev/null
+++ b/packages/test-vectors/vectors/ownership-proof-v2.json
@@ -0,0 +1,106 @@
+{
+ "schema": "nexus.test-vectors.v2",
+ "warning": "NON-PRODUCTION TEST FIXTURE: public RFC 8032 key material; never use for real identities or services.",
+ "rootSigningKeyId": "identity-a",
+ "deviceSigningKeyId": "identity-b",
+ "payload": {
+ "protocol": "nexus.ownership-proof.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesis": {
+ "protocol": "nexus.identity.v1",
+ "suite": "NX-25519-SHA256-JCS-v1",
+ "signingKey": {
+ "alg": "Ed25519",
+ "publicKey": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
+ },
+ "agreementKey": {
+ "alg": "X25519",
+ "publicKey": "hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo"
+ },
+ "revocationCommitment": "_W__y81RfxyMr7yZ6dIpNYYBOkmq1iMTYB_mXUfAjyU"
+ },
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "authorization": {
+ "payload": {
+ "protocol": "nexus.device-authorization.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "signingKey": {
+ "alg": "Ed25519",
+ "publicKey": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw"
+ },
+ "authorizationNonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8",
+ "validFrom": 1786400000,
+ "activationDeadline": 1786403600,
+ "expiresAt": 1786486400
+ },
+ "rootSignature": "qdjva3WXJ8C1N6ktnEx6RH1OFGhCUwdjlT1G2Lwx9fXiBDXUnxn0AoW4YxIcwe1o2likckeKx8HaD6fnBYvjCA"
+ },
+ "aud": "https://rp.example",
+ "act": "resource.create",
+ "resource": "urn:example:resource:alpha",
+ "nonce": "gIGCg4SFhoeIiYqLjI2Ojw",
+ "iat": 1786400020,
+ "exp": 1786400080,
+ "contextHash": "LGMMfe1XcGsjUeouLL9rXMWyOcixFupGziJwOxYjxuE"
+ },
+ "canonicalPayloadJcs": "{\"act\":\"resource.create\",\"aud\":\"https://rp.example\",\"authorization\":{\"payload\":{\"activationDeadline\":1786403600,\"authorizationNonce\":\"QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"expiresAt\":1786486400,\"genesisHash\":\"NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"protocol\":\"nexus.device-authorization.v2\",\"signingKey\":{\"alg\":\"Ed25519\",\"publicKey\":\"PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw\"},\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\",\"validFrom\":1786400000},\"rootSignature\":\"qdjva3WXJ8C1N6ktnEx6RH1OFGhCUwdjlT1G2Lwx9fXiBDXUnxn0AoW4YxIcwe1o2likckeKx8HaD6fnBYvjCA\"},\"authorizationId\":\"nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4\",\"contextHash\":\"LGMMfe1XcGsjUeouLL9rXMWyOcixFupGziJwOxYjxuE\",\"deviceId\":\"nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c\",\"exp\":1786400080,\"genesis\":{\"agreementKey\":{\"alg\":\"X25519\",\"publicKey\":\"hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo\"},\"protocol\":\"nexus.identity.v1\",\"revocationCommitment\":\"_W__y81RfxyMr7yZ6dIpNYYBOkmq1iMTYB_mXUfAjyU\",\"signingKey\":{\"alg\":\"Ed25519\",\"publicKey\":\"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo\"},\"suite\":\"NX-25519-SHA256-JCS-v1\"},\"iat\":1786400020,\"nonce\":\"gIGCg4SFhoeIiYqLjI2Ojw\",\"protocol\":\"nexus.ownership-proof.v2\",\"resource\":\"urn:example:resource:alpha\",\"subject\":\"nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo\"}",
+ "signaturePreimageHex": "4e455855532d5349474e4154555245006e657875732e6f776e6572736869702d70726f6f662e7632007b22616374223a227265736f757263652e637265617465222c22617564223a2268747470733a2f2f72702e6578616d706c65222c22617574686f72697a6174696f6e223a7b227061796c6f6164223a7b2261637469766174696f6e446561646c696e65223a313738363430333630302c22617574686f72697a6174696f6e4e6f6e6365223a225145464351305246526b64495355704c5445314f54314252556c4e5556565a5857466c6157317864586c38222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870697265734174223a313738363438363430302c2267656e6573697348617368223a224e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c2270726f746f636f6c223a226e657875732e6465766963652d617574686f72697a6174696f6e2e7632222c227369676e696e674b6579223a7b22616c67223a2245643235353139222c227075626c69634b6579223a2250554158772d6844695671537477716e5452742d764a79594c4d3875784a614d774d3156385372305a6777227d2c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f222c2276616c696446726f6d223a313738363430303030307d2c22726f6f745369676e6174757265223a2271646a76613357584a3843314e366b746e4578365248314f464768435577646a6c543147324c777839665869424458556e786e30416f5734597849637765316f326c696b636b654b783848614436666e4259766a4341227d2c22617574686f72697a6174696f6e4964223a226e7861325f4f365f505a61386b42313472524e65336550793348433139716d79554871396168734735313356636d6c34222c22636f6e7465787448617368223a224c474d4d666531586347736a55656f754c4c3972584d57794f636978467570477a694a774f78596a787545222c226465766963654964223a226e7864325f6f633866485f7a736d724e667379757151785372436238494e375972484c594d4942566a75367543723163222c22657870223a313738363430303038302c2267656e65736973223a7b2261677265656d656e744b6579223a7b22616c67223a22583235353139222c227075626c69634b6579223a226853447743596b777031523069333363744437335767325f4f67306d4f427230363653706a717162546d6f227d2c2270726f746f636f6c223a226e657875732e6964656e746974792e7631222c227265766f636174696f6e436f6d6d69746d656e74223a225f575f5f793831526678794d7237795a366449704e5959424f6b6d7131694d5459425f6d585566416a7955222c227369676e696e674b6579223a7b22616c67223a2245643235353139222c227075626c69634b6579223a223131715941594b7843726656535f3754795751484f6737686376506170694d6c727749616150634855526f227d2c227375697465223a224e582d32353531392d5348413235362d4a43532d7631227d2c22696174223a313738363430303032302c226e6f6e6365223a226749474367345346686f65496959714c6a49324f6a77222c2270726f746f636f6c223a226e657875732e6f776e6572736869702d70726f6f662e7632222c227265736f75726365223a2275726e3a6578616d706c653a7265736f757263653a616c706861222c227375626a656374223a226e78315f4e5a786977764f7844335743513636686f356b396846327676446e68745a37447348397734486e5557656f227d",
+ "signatureBase64Url": "hEWpkDuvGBJkqrHdYIepJgFCQlGMskxI0Dl9XwSTM5iOXYnUPbZFYm7RIWQocbNsO5D1mDL1COa2fY1uvAUYBw",
+ "proof": {
+ "payload": {
+ "protocol": "nexus.ownership-proof.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesis": {
+ "protocol": "nexus.identity.v1",
+ "suite": "NX-25519-SHA256-JCS-v1",
+ "signingKey": {
+ "alg": "Ed25519",
+ "publicKey": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
+ },
+ "agreementKey": {
+ "alg": "X25519",
+ "publicKey": "hSDwCYkwp1R0i33ctD73Wg2_Og0mOBr066SpjqqbTmo"
+ },
+ "revocationCommitment": "_W__y81RfxyMr7yZ6dIpNYYBOkmq1iMTYB_mXUfAjyU"
+ },
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "authorizationId": "nxa2_O6_PZa8kB14rRNe3ePy3HC19qmyUHq9ahsG513Vcml4",
+ "authorization": {
+ "payload": {
+ "protocol": "nexus.device-authorization.v2",
+ "subject": "nx1_NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "genesisHash": "NZxiwvOxD3WCQ66ho5k9hF2vvDnhtZ7DsH9w4HnUWeo",
+ "deviceId": "nxd2_oc8fH_zsmrNfsyuqQxSrCb8IN7YrHLYMIBVju6uCr1c",
+ "signingKey": {
+ "alg": "Ed25519",
+ "publicKey": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw"
+ },
+ "authorizationNonce": "QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8",
+ "validFrom": 1786400000,
+ "activationDeadline": 1786403600,
+ "expiresAt": 1786486400
+ },
+ "rootSignature": "qdjva3WXJ8C1N6ktnEx6RH1OFGhCUwdjlT1G2Lwx9fXiBDXUnxn0AoW4YxIcwe1o2likckeKx8HaD6fnBYvjCA"
+ },
+ "aud": "https://rp.example",
+ "act": "resource.create",
+ "resource": "urn:example:resource:alpha",
+ "nonce": "gIGCg4SFhoeIiYqLjI2Ojw",
+ "iat": 1786400020,
+ "exp": 1786400080,
+ "contextHash": "LGMMfe1XcGsjUeouLL9rXMWyOcixFupGziJwOxYjxuE"
+ },
+ "deviceSignature": "hEWpkDuvGBJkqrHdYIepJgFCQlGMskxI0Dl9XwSTM5iOXYnUPbZFYm7RIWQocbNsO5D1mDL1COa2fY1uvAUYBw"
+ },
+ "verification": {
+ "audience": "https://rp.example",
+ "action": "resource.create",
+ "resource": "urn:example:resource:alpha",
+ "nonce": "gIGCg4SFhoeIiYqLjI2Ojw",
+ "now": 1786400020,
+ "maxClockSkewSeconds": 0
+ }
+}
diff --git a/packages/verifier/package.json b/packages/verifier/package.json
index 26ddbbc..6605110 100644
--- a/packages/verifier/package.json
+++ b/packages/verifier/package.json
@@ -21,7 +21,7 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
- "test": "vitest run --root ../.. packages/verifier/src/verifier.test.ts packages/verifier/src/transparency.test.ts packages/verifier/src/transparency-auth.test.ts"
+ "test": "vitest run --root ../.. packages/verifier/src/verifier.test.ts packages/verifier/src/ownership-v2.test.ts packages/verifier/src/transparency.test.ts packages/verifier/src/transparency-auth.test.ts"
},
"dependencies": {
"@nexus/crypto": "workspace:*",
diff --git a/packages/verifier/src/errors.ts b/packages/verifier/src/errors.ts
index 6a05da1..1b9a51b 100644
--- a/packages/verifier/src/errors.ts
+++ b/packages/verifier/src/errors.ts
@@ -1,3 +1,4 @@
+/** Stable v1 verifier error surface. Keep this list closed for compatibility. */
export const NEXUS_VERIFICATION_ERROR_CODES = [
'BAD_REQUEST',
'UNSUPPORTED_PROTOCOL',
@@ -33,6 +34,25 @@ export const NEXUS_VERIFICATION_ERROR_CODES = [
export type NexusVerificationErrorCode = (typeof NEXUS_VERIFICATION_ERROR_CODES)[number];
+const V2_ONLY_VERIFICATION_ERROR_CODES = [
+ 'WRONG_CONTEXT',
+ 'DEVICE_NOT_FOUND',
+ 'DEVICE_REVOKED',
+ 'DEVICE_EXPIRED',
+ 'WRONG_DEVICE',
+ 'WRONG_AUTHORIZATION',
+ 'WRONG_EVENT_ID',
+ 'WRONG_OPERATION',
+] as const;
+
+/** Additive v2 verifier error surface; the legacy list above remains unchanged. */
+export const NEXUS_VERIFICATION_ERROR_CODES_V2 = [
+ ...NEXUS_VERIFICATION_ERROR_CODES,
+ ...V2_ONLY_VERIFICATION_ERROR_CODES,
+] as const;
+
+export type NexusVerificationErrorCodeV2 = (typeof NEXUS_VERIFICATION_ERROR_CODES_V2)[number];
+
const ERROR_MESSAGES: Readonly> = {
BAD_REQUEST: 'The verification input is malformed.',
UNSUPPORTED_PROTOCOL: 'The protocol version is not supported.',
@@ -66,6 +86,59 @@ const ERROR_MESSAGES: Readonly> = {
INVALID_INCLUSION_PROOF: 'The transparency inclusion proof is invalid.',
};
+const V2_ERROR_MESSAGES: Readonly> = {
+ ...ERROR_MESSAGES,
+ WRONG_CONTEXT: 'The proof context binding does not match.',
+ DEVICE_NOT_FOUND: 'The device authorization was not found in authoritative lifecycle state.',
+ DEVICE_REVOKED: 'The device authorization is revoked.',
+ DEVICE_EXPIRED: 'The device authorization has expired.',
+ WRONG_DEVICE: 'The device identifier does not match.',
+ WRONG_AUTHORIZATION: 'The device authorization does not match.',
+ WRONG_EVENT_ID: 'The registry event identifier does not match.',
+ WRONG_OPERATION: 'The registry operation identifier does not match.',
+};
+
+const V2_LEGACY_CODE: Readonly> = {
+ BAD_REQUEST: 'BAD_REQUEST',
+ UNSUPPORTED_PROTOCOL: 'UNSUPPORTED_PROTOCOL',
+ UNSUPPORTED_SUITE: 'UNSUPPORTED_SUITE',
+ INVALID_SUBJECT: 'INVALID_SUBJECT',
+ INVALID_SIGNATURE: 'INVALID_SIGNATURE',
+ INVALID_REVOCATION_SECRET: 'INVALID_REVOCATION_SECRET',
+ WRONG_AUDIENCE: 'WRONG_AUDIENCE',
+ WRONG_ACTION: 'WRONG_ACTION',
+ WRONG_RESOURCE: 'WRONG_RESOURCE',
+ WRONG_NONCE: 'WRONG_NONCE',
+ SEQUENCE_CONFLICT: 'SEQUENCE_CONFLICT',
+ PROOF_NOT_YET_VALID: 'PROOF_NOT_YET_VALID',
+ PROOF_EXPIRED: 'PROOF_EXPIRED',
+ PROOF_LIFETIME_EXCEEDED: 'PROOF_LIFETIME_EXCEEDED',
+ NONCE_REPLAY_OR_EXPIRED: 'NONCE_REPLAY_OR_EXPIRED',
+ IDENTITY_NOT_FOUND: 'IDENTITY_NOT_FOUND',
+ IDENTITY_REVOKED: 'IDENTITY_REVOKED',
+ KEY_NOT_FOUND: 'KEY_NOT_FOUND',
+ INVALID_KEY: 'INVALID_KEY',
+ KEY_PURPOSE_MISMATCH: 'KEY_PURPOSE_MISMATCH',
+ WRONG_EVENT_TYPE: 'WRONG_EVENT_TYPE',
+ WRONG_STATE: 'WRONG_STATE',
+ WRONG_GENESIS_HASH: 'WRONG_GENESIS_HASH',
+ WRONG_SCOPE: 'WRONG_SCOPE',
+ WRONG_SHARD: 'WRONG_SHARD',
+ WRONG_ROOT: 'WRONG_ROOT',
+ WRONG_TREE_SIZE: 'WRONG_TREE_SIZE',
+ WRONG_MANIFEST: 'WRONG_MANIFEST',
+ WRONG_EVENT_HASH: 'WRONG_EVENT_HASH',
+ INVALID_INCLUSION_PROOF: 'INVALID_INCLUSION_PROOF',
+ WRONG_CONTEXT: 'WRONG_NONCE',
+ DEVICE_NOT_FOUND: 'IDENTITY_NOT_FOUND',
+ DEVICE_REVOKED: 'IDENTITY_REVOKED',
+ DEVICE_EXPIRED: 'PROOF_EXPIRED',
+ WRONG_DEVICE: 'INVALID_SUBJECT',
+ WRONG_AUTHORIZATION: 'BAD_REQUEST',
+ WRONG_EVENT_ID: 'WRONG_EVENT_HASH',
+ WRONG_OPERATION: 'WRONG_EVENT_HASH',
+};
+
export class NexusVerificationError extends Error {
readonly code: NexusVerificationErrorCode;
@@ -76,6 +149,32 @@ export class NexusVerificationError extends Error {
}
}
+/**
+ * Additive v2 error. `codeV2` is exact; inherited `code` is a stable legacy
+ * category so existing `instanceof NexusVerificationError` handlers remain useful.
+ */
+export class NexusVerificationErrorV2 extends NexusVerificationError {
+ readonly codeV2: NexusVerificationErrorCodeV2;
+
+ constructor(codeV2: NexusVerificationErrorCodeV2, message = V2_ERROR_MESSAGES[codeV2]) {
+ super(V2_LEGACY_CODE[codeV2], message);
+ this.name = 'NexusVerificationErrorV2';
+ this.codeV2 = codeV2;
+ }
+}
+
export function verificationError(code: NexusVerificationErrorCode): NexusVerificationError {
return new NexusVerificationError(code);
}
+
+export function verificationErrorV2(code: NexusVerificationErrorCodeV2): NexusVerificationErrorV2 {
+ return new NexusVerificationErrorV2(code);
+}
+
+export function rethrowVerificationErrorV2(error: unknown): never {
+ if (error instanceof NexusVerificationErrorV2) throw error;
+ if (error instanceof NexusVerificationError) {
+ throw new NexusVerificationErrorV2(error.code, error.message);
+ }
+ throw error;
+}
diff --git a/packages/verifier/src/index.ts b/packages/verifier/src/index.ts
index a892520..9cf4392 100644
--- a/packages/verifier/src/index.ts
+++ b/packages/verifier/src/index.ts
@@ -1,16 +1,25 @@
export {
NEXUS_VERIFICATION_ERROR_CODES,
+ NEXUS_VERIFICATION_ERROR_CODES_V2,
NexusVerificationError,
+ NexusVerificationErrorV2,
verificationError,
+ verificationErrorV2,
} from './errors.js';
-export type { NexusVerificationErrorCode } from './errors.js';
+export type { NexusVerificationErrorCode, NexusVerificationErrorCodeV2 } from './errors.js';
export { verifySubject } from './identity.js';
export { verifyOwnershipProof } from './ownership.js';
+export { verifyOwnershipProofAny, verifyOwnershipProofV2 } from './ownership-v2.js';
export { verifyRevocationSecret, verifyRevokeBySignature } from './revocation.js';
-export { verifyRegistryReceipt, verifyStatusStatement } from './service-statements.js';
+export {
+ verifyDeviceRegistryReceipt,
+ verifyDeviceStatusStatement,
+ verifyRegistryReceipt,
+ verifyStatusStatement,
+} from './service-statements.js';
export { verifyContinuityLink } from './continuity.js';
-export { verifyRpOperation } from './orchestrate.js';
+export { verifyRpOperation, verifyRpOperationV2 } from './orchestrate.js';
export { verifyInclusionProof, verifyTransparencyInclusionProof } from './transparency.js';
export type { HashFunction, TransparencyInclusionProofInput } from './transparency.js';
export {
@@ -23,16 +32,22 @@ export {
export { MAX_SIGNED_OBJECT_LIFETIME_SECONDS } from './types.js';
export type {
AuthoritativeLifecycleState,
+ AuthoritativeDeviceLifecycleState,
AuthenticatedInclusionProofExpectation,
ChallengeRecord,
ChallengeStore,
+ DeviceLifecycleProvider,
+ DeviceRegistryReceiptExpectation,
+ DeviceStatusStatementExpectation,
ContinuityLinkExpectation,
GlobalTransparencyCheckpointExpectation,
LifecycleProvider,
+ OwnershipProofAnyOptions,
RegistryReceiptExpectation,
RevokeBySecretExpectation,
RevokeBySignatureExpectation,
StatusStatementExpectation,
TransparencyCheckpointExpectation,
TransparencyFreshnessExpectation,
+ VerifiedDeviceSubject,
} from './types.js';
diff --git a/packages/verifier/src/internal.ts b/packages/verifier/src/internal.ts
index 97f42c3..fe03ed7 100644
--- a/packages/verifier/src/internal.ts
+++ b/packages/verifier/src/internal.ts
@@ -140,7 +140,11 @@ export function validateIssuedAt(
}
export async function importIdentitySigningKey(genesis: IdentityGenesisV1): Promise {
- const raw = decodeBase64UrlExact(genesis.signingKey.publicKey, 32);
+ return importEd25519PublicKey(genesis.signingKey.publicKey);
+}
+
+export async function importEd25519PublicKey(encodedPublicKey: string): Promise {
+ const raw = decodeBase64UrlExact(encodedPublicKey, 32);
try {
return await getDefaultCryptoProvider().importEd25519PublicKey(raw);
diff --git a/packages/verifier/src/orchestrate.ts b/packages/verifier/src/orchestrate.ts
index d15798e..d40c962 100644
--- a/packages/verifier/src/orchestrate.ts
+++ b/packages/verifier/src/orchestrate.ts
@@ -1,8 +1,20 @@
-import type { OwnershipProofV1, VerificationExpectation, VerifiedSubject } from '@nexus/protocol';
+import type {
+ OwnershipProofV1,
+ OwnershipProofV2,
+ VerificationExpectation,
+ VerificationExpectationV2,
+ VerifiedSubject,
+} from '@nexus/protocol';
-import { verificationError } from './errors.js';
+import { rethrowVerificationErrorV2, verificationError, verificationErrorV2 } from './errors.js';
import { verifyOwnershipProof } from './ownership.js';
-import type { ChallengeStore, LifecycleProvider } from './types.js';
+import { verifyOwnershipProofV2 } from './ownership-v2.js';
+import type {
+ ChallengeStore,
+ DeviceLifecycleProvider,
+ LifecycleProvider,
+ VerifiedDeviceSubject,
+} from './types.js';
/**
* Complete online RP authorization verification. Unlike verifyOwnershipProof,
@@ -41,3 +53,80 @@ export async function verifyRpOperation(
if (!consumed) throw verificationError('NONCE_REPLAY_OR_EXPIRED');
return verified;
}
+
+/**
+ * Complete online authorization for a root-authorized v2 device. Both the
+ * identity and the exact device authorization must remain authoritative.
+ */
+async function verifyRpOperationV2Internal(
+ proof: OwnershipProofV2,
+ expected: VerificationExpectationV2,
+ challengeStore: ChallengeStore,
+ lifecycle: LifecycleProvider,
+ deviceLifecycle: DeviceLifecycleProvider,
+): Promise {
+ const verified = await verifyOwnershipProofV2(proof, expected);
+
+ const challenge = await challengeStore.get(expected.nonce);
+ if (
+ challenge === null ||
+ challenge.consumed ||
+ challenge.nonce !== expected.nonce ||
+ challenge.action !== expected.action ||
+ challenge.resource !== expected.resource ||
+ expected.now > challenge.expiresAt
+ ) {
+ 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.
+ if (deviceStatus.identityState !== 'active') throw verificationError('IDENTITY_REVOKED');
+ if (deviceStatus.deviceId !== verified.deviceId) throw verificationErrorV2('WRONG_DEVICE');
+ if (deviceStatus.authorizationId !== verified.authorizationId) {
+ throw verificationErrorV2('WRONG_AUTHORIZATION');
+ }
+ if (deviceStatus.state === 'revoked') throw verificationErrorV2('DEVICE_REVOKED');
+ if (
+ deviceStatus.state === 'expired' ||
+ (deviceStatus.authorizationExpiresAt !== undefined &&
+ expected.now >= deviceStatus.authorizationExpiresAt)
+ ) {
+ throw verificationErrorV2('DEVICE_EXPIRED');
+ }
+
+ const consumed = await challengeStore.consumeAtomically(expected.nonce);
+ if (!consumed) throw verificationError('NONCE_REPLAY_OR_EXPIRED');
+ return verified;
+}
+
+export async function verifyRpOperationV2(
+ proof: OwnershipProofV2,
+ expected: VerificationExpectationV2,
+ challengeStore: ChallengeStore,
+ lifecycle: LifecycleProvider,
+ deviceLifecycle: DeviceLifecycleProvider,
+): Promise {
+ try {
+ return await verifyRpOperationV2Internal(
+ proof,
+ expected,
+ challengeStore,
+ lifecycle,
+ deviceLifecycle,
+ );
+ } catch (error) {
+ rethrowVerificationErrorV2(error);
+ }
+}
diff --git a/packages/verifier/src/ownership-v2.test.ts b/packages/verifier/src/ownership-v2.test.ts
new file mode 100644
index 0000000..1b134b0
--- /dev/null
+++ b/packages/verifier/src/ownership-v2.test.ts
@@ -0,0 +1,634 @@
+import {
+ DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
+ IDENTITY_PROTOCOL_V1,
+ NEXUS_SUITE_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V2,
+ encodeBase64Url,
+} from '@nexus/protocol';
+import type {
+ Base64Url32,
+ Base64Url64,
+ Base64UrlAtLeast16,
+ DeviceAuthorizationPayloadV2,
+ DeviceAuthorizationV2,
+ DeviceStatusStatementPayloadV2,
+ DeviceRegistryReceiptPayloadV2,
+ IdentityGenesisV1,
+ NexusSubject,
+ OwnershipProofPayloadV2,
+ OwnershipProofPayloadV1,
+ OwnershipProofV1,
+ OwnershipProofV2,
+ ServiceKeySet,
+ VerificationExpectationV2,
+} from '@nexus/protocol';
+import {
+ computeRevocationCommitment,
+ deriveDeviceAuthorizationIdV2,
+ deriveDeviceIdV2,
+ deriveGenesisHash,
+ deriveSubject,
+ getDefaultCryptoProvider,
+ signProtocolPayload,
+} from '@nexus/crypto';
+import { describe, expect, it } from 'vitest';
+
+import {
+ NexusVerificationError,
+ NexusVerificationErrorV2,
+ verifyDeviceStatusStatement,
+ verifyDeviceRegistryReceipt,
+ verifyOwnershipProof,
+ verifyOwnershipProofAny,
+ verifyOwnershipProofV2,
+ verifyRpOperationV2,
+} from './index.js';
+import type {
+ ChallengeStore,
+ DeviceLifecycleProvider,
+ LifecycleProvider,
+ NexusVerificationErrorCodeV2,
+} from './index.js';
+
+const NOW = 1_800_000_000;
+
+function encoded32(bytes: Uint8Array): Base64Url32 {
+ return encodeBase64Url(bytes) as Base64Url32;
+}
+
+function encoded64(value: string): Base64Url64 {
+ return value as Base64Url64;
+}
+
+function encodedNonce(bytes: Uint8Array): Base64UrlAtLeast16 {
+ return encodeBase64Url(bytes) as Base64UrlAtLeast16;
+}
+
+const NONCE = encodedNonce(new Uint8Array(16).fill(27));
+
+interface TestV2Identity {
+ genesis: IdentityGenesisV1;
+ subject: NexusSubject;
+ rootPrivateKey: CryptoKey;
+ devicePrivateKey: CryptoKey;
+ authorization: DeviceAuthorizationV2;
+ authorizationId: Awaited>;
+}
+
+async function makeV2Identity(seed: number): Promise {
+ const provider = getDefaultCryptoProvider();
+ const [root, device] = await Promise.all([
+ provider.generateEd25519KeyPair({ privateKeyExtractable: true }),
+ provider.generateEd25519KeyPair({ privateKeyExtractable: true }),
+ ]);
+ const [rootPublic, devicePublic] = await Promise.all([
+ provider.exportEd25519PublicKey(root.publicKey),
+ provider.exportEd25519PublicKey(device.publicKey),
+ ]);
+ const commitment = await computeRevocationCommitment(new Uint8Array(32).fill(seed));
+ const genesis: IdentityGenesisV1 = {
+ protocol: IDENTITY_PROTOCOL_V1,
+ suite: NEXUS_SUITE_V1,
+ signingKey: { alg: 'Ed25519', publicKey: encoded32(rootPublic) },
+ revocationCommitment: encoded32(commitment),
+ };
+ const subject = await deriveSubject(genesis);
+ const signingKey = { alg: 'Ed25519' as const, publicKey: encoded32(devicePublic) };
+ const payload: DeviceAuthorizationPayloadV2 = {
+ protocol: DEVICE_AUTHORIZATION_PROTOCOL_V2,
+ subject,
+ genesisHash: encoded32(await deriveGenesisHash(genesis)),
+ deviceId: await deriveDeviceIdV2({ subject, signingKey }),
+ signingKey,
+ authorizationNonce: encoded32(new Uint8Array(32).fill(seed + 1)),
+ validFrom: NOW - 1_000,
+ activationDeadline: NOW + 1_000,
+ expiresAt: NOW + 10_000,
+ };
+ const authorization: DeviceAuthorizationV2 = {
+ payload,
+ rootSignature: encoded64(await signProtocolPayload(payload, root.privateKey)),
+ };
+ return {
+ genesis,
+ subject,
+ rootPrivateKey: root.privateKey,
+ devicePrivateKey: device.privateKey,
+ authorization,
+ authorizationId: await deriveDeviceAuthorizationIdV2(payload),
+ };
+}
+
+const CONTEXT_HASH = encoded32(new Uint8Array(32).fill(28));
+
+function expectation(
+ overrides: Partial = {},
+): VerificationExpectationV2 {
+ return {
+ audience: 'https://rp.example',
+ action: 'post.edit',
+ resource: 'post:01ABC',
+ nonce: NONCE,
+ now: NOW,
+ maxClockSkewSeconds: 5,
+ contextHash: null,
+ ...overrides,
+ };
+}
+
+async function makeProof(
+ identity: TestV2Identity,
+ overrides: Partial = {},
+): Promise {
+ const payload: OwnershipProofPayloadV2 = {
+ protocol: OWNERSHIP_PROOF_PROTOCOL_V2,
+ subject: identity.subject,
+ genesis: identity.genesis,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ authorization: identity.authorization,
+ aud: 'https://rp.example',
+ act: 'post.edit',
+ resource: 'post:01ABC',
+ nonce: NONCE,
+ iat: NOW - 10,
+ exp: NOW + 50,
+ ...overrides,
+ };
+ return {
+ payload,
+ deviceSignature: encoded64(await signProtocolPayload(payload, identity.devicePrivateKey)),
+ };
+}
+
+async function makeV1Proof(
+ identity: TestV2Identity,
+ contextHash?: Base64Url32,
+): Promise {
+ const payload: OwnershipProofPayloadV1 = {
+ protocol: OWNERSHIP_PROOF_PROTOCOL_V1,
+ subject: identity.subject,
+ genesis: identity.genesis,
+ aud: 'https://rp.example',
+ act: 'post.edit',
+ resource: 'post:01ABC',
+ nonce: NONCE,
+ iat: NOW - 10,
+ exp: NOW + 50,
+ ...(contextHash === undefined ? {} : { contextHash }),
+ };
+ return {
+ payload,
+ signature: encoded64(await signProtocolPayload(payload, identity.rootPrivateKey)),
+ };
+}
+
+async function expectCode(
+ operation: Promise,
+ code: NexusVerificationErrorCodeV2,
+): Promise {
+ try {
+ await operation;
+ throw new Error('Expected verification to fail.');
+ } catch (error) {
+ expect(error).toBeInstanceOf(NexusVerificationError);
+ if (error instanceof NexusVerificationErrorV2) {
+ expect(error.codeV2).toBe(code);
+ return;
+ }
+ if (error instanceof NexusVerificationError) {
+ expect(error.code).toBe(code);
+ return;
+ }
+ throw error;
+ }
+}
+
+describe('v2 ownership verification', () => {
+ it('verifies the root authorization and device proof chain', async () => {
+ const identity = await makeV2Identity(31);
+ const verified = await verifyOwnershipProofV2(await makeProof(identity), expectation());
+ expect(verified).toMatchObject({
+ protocol: OWNERSHIP_PROOF_PROTOCOL_V2,
+ subject: identity.subject,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ });
+ expect(verified.rootSigningPublicKey).toBeInstanceOf(Uint8Array);
+ expect(verified.deviceSigningPublicKey).toBeInstanceOf(Uint8Array);
+ });
+
+ it('keeps the legacy entry point v1-only and dispatches only explicitly accepted versions', async () => {
+ const identity = await makeV2Identity(32);
+ const proof = await makeProof(identity);
+ await expectCode(verifyOwnershipProof(proof, expectation()), 'UNSUPPORTED_PROTOCOL');
+ await expectCode(
+ verifyOwnershipProofAny(proof, expectation(), {
+ acceptedProtocols: [OWNERSHIP_PROOF_PROTOCOL_V1],
+ }),
+ 'UNSUPPORTED_PROTOCOL',
+ );
+ await expect(
+ verifyOwnershipProofAny(proof, expectation(), {
+ acceptedProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2],
+ }),
+ ).resolves.toMatchObject({ subject: identity.subject });
+ await expectCode(
+ verifyOwnershipProofAny(proof, expectation(), {
+ acceptedProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2, OWNERSHIP_PROOF_PROTOCOL_V2],
+ }),
+ 'BAD_REQUEST',
+ );
+ });
+
+ it('rejects root-certificate tampering and device-signature tampering independently', async () => {
+ const identity = await makeV2Identity(33);
+ const proof = await makeProof(identity);
+ await expectCode(
+ verifyOwnershipProofV2(
+ {
+ ...proof,
+ payload: {
+ ...proof.payload,
+ authorization: {
+ ...proof.payload.authorization,
+ rootSignature: proof.deviceSignature,
+ },
+ },
+ },
+ expectation(),
+ ),
+ 'INVALID_SIGNATURE',
+ );
+ await expectCode(
+ verifyOwnershipProofV2(
+ { ...proof, deviceSignature: proof.payload.authorization.rootSignature },
+ expectation(),
+ ),
+ 'INVALID_SIGNATURE',
+ );
+ });
+
+ it('requires exact derived device and authorization identifiers', async () => {
+ const identity = await makeV2Identity(34);
+ const proof = await makeProof(identity);
+ const wrongDevice = `nxd2_${encoded32(new Uint8Array(32).fill(99))}`;
+ await expectCode(
+ verifyOwnershipProofV2(
+ {
+ ...proof,
+ payload: {
+ ...proof.payload,
+ deviceId: wrongDevice,
+ authorization: {
+ ...proof.payload.authorization,
+ payload: { ...proof.payload.authorization.payload, deviceId: wrongDevice },
+ },
+ },
+ },
+ expectation(),
+ ),
+ 'WRONG_DEVICE',
+ );
+ const wrongAuthorization = `nxa2_${encoded32(new Uint8Array(32).fill(98))}`;
+ await expectCode(
+ verifyOwnershipProofV2(
+ { ...proof, payload: { ...proof.payload, authorizationId: wrongAuthorization } },
+ expectation(),
+ ),
+ 'WRONG_AUTHORIZATION',
+ );
+ });
+
+ it('rejects an authorization that reuses the root signing key as its device key', async () => {
+ const identity = await makeV2Identity(38);
+ const signingKey = identity.genesis.signingKey;
+ const payload: DeviceAuthorizationPayloadV2 = {
+ ...identity.authorization.payload,
+ signingKey,
+ deviceId: await deriveDeviceIdV2({ subject: identity.subject, signingKey }),
+ };
+ const authorization: DeviceAuthorizationV2 = {
+ payload,
+ rootSignature: encoded64(await signProtocolPayload(payload, identity.rootPrivateKey)),
+ };
+ const authorizationId = await deriveDeviceAuthorizationIdV2(payload);
+ const proof = await makeProof(identity, {
+ deviceId: payload.deviceId,
+ authorizationId,
+ authorization,
+ });
+ const rootSignedProof = {
+ ...proof,
+ deviceSignature: encoded64(await signProtocolPayload(proof.payload, identity.rootPrivateKey)),
+ };
+ await expectCode(verifyOwnershipProofV2(rootSignedProof, expectation()), 'INVALID_KEY');
+ });
+
+ it('enforces explicit absent or exact context binding', async () => {
+ const identity = await makeV2Identity(39);
+ const unbound = await makeProof(identity);
+ await expect(verifyOwnershipProofV2(unbound, expectation())).resolves.toMatchObject({
+ subject: identity.subject,
+ });
+ await expectCode(
+ verifyOwnershipProofV2(unbound, expectation({ contextHash: CONTEXT_HASH })),
+ 'WRONG_CONTEXT',
+ );
+ await expect(
+ verifyOwnershipProofV2(unbound, expectation({ contextHash: CONTEXT_HASH })),
+ ).rejects.toMatchObject({
+ name: 'NexusVerificationErrorV2',
+ code: 'WRONG_NONCE',
+ codeV2: 'WRONG_CONTEXT',
+ });
+
+ const bound = await makeProof(identity, { contextHash: CONTEXT_HASH });
+ await expect(
+ verifyOwnershipProofV2(bound, expectation({ contextHash: CONTEXT_HASH })),
+ ).resolves.toMatchObject({ subject: identity.subject });
+ await expectCode(verifyOwnershipProofV2(bound, expectation()), 'WRONG_CONTEXT');
+ await expectCode(
+ verifyOwnershipProofV2(bound, {
+ audience: 'https://rp.example',
+ action: 'post.edit',
+ resource: 'post:01ABC',
+ nonce: NONCE,
+ now: NOW,
+ maxClockSkewSeconds: 5,
+ } as VerificationExpectationV2),
+ 'BAD_REQUEST',
+ );
+ });
+
+ it('enforces v2 context policy on v1 fallback to prevent downgrade bypass', async () => {
+ const identity = await makeV2Identity(41);
+ const unbound = await makeV1Proof(identity);
+ const options = {
+ acceptedProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2, OWNERSHIP_PROOF_PROTOCOL_V1],
+ } as const;
+ await expect(verifyOwnershipProofAny(unbound, expectation(), options)).resolves.toMatchObject({
+ subject: identity.subject,
+ });
+ await expectCode(
+ verifyOwnershipProofAny(unbound, expectation({ contextHash: CONTEXT_HASH }), options),
+ 'WRONG_CONTEXT',
+ );
+
+ const bound = await makeV1Proof(identity, CONTEXT_HASH);
+ await expect(
+ verifyOwnershipProofAny(bound, expectation({ contextHash: CONTEXT_HASH }), options),
+ ).resolves.toMatchObject({ subject: identity.subject });
+ await expectCode(verifyOwnershipProofAny(bound, expectation(), options), 'WRONG_CONTEXT');
+
+ // The direct legacy API remains deliberately context-agnostic.
+ const expectedV2 = expectation();
+ const legacyExpectation = {
+ audience: expectedV2.audience,
+ action: expectedV2.action,
+ resource: expectedV2.resource,
+ nonce: expectedV2.nonce,
+ now: expectedV2.now,
+ maxClockSkewSeconds: expectedV2.maxClockSkewSeconds,
+ };
+ await expect(verifyOwnershipProof(bound, legacyExpectation)).resolves.toMatchObject({
+ subject: identity.subject,
+ });
+ });
+
+ it('treats device authorization expiry as an exclusive boundary', async () => {
+ const identity = await makeV2Identity(40);
+ const payload: DeviceAuthorizationPayloadV2 = {
+ ...identity.authorization.payload,
+ activationDeadline: NOW,
+ expiresAt: NOW,
+ };
+ const authorization: DeviceAuthorizationV2 = {
+ payload,
+ rootSignature: encoded64(await signProtocolPayload(payload, identity.rootPrivateKey)),
+ };
+ const authorizationId = await deriveDeviceAuthorizationIdV2(payload);
+ const proof = await makeProof(identity, {
+ authorization,
+ authorizationId,
+ iat: NOW - 10,
+ exp: NOW,
+ });
+ await expectCode(verifyOwnershipProofV2(proof, expectation()), 'DEVICE_EXPIRED');
+ });
+});
+
+describe('v2 RP orchestration', () => {
+ it('requires exact active identity and device state before consuming the nonce', async () => {
+ const identity = await makeV2Identity(35);
+ const proof = await makeProof(identity);
+ let consumed = false;
+ const store: ChallengeStore = {
+ get: () =>
+ Promise.resolve({
+ nonce: NONCE,
+ action: 'post.edit',
+ resource: 'post:01ABC',
+ expiresAt: NOW + 60,
+ consumed,
+ }),
+ consumeAtomically: () => {
+ if (consumed) return Promise.resolve(false);
+ consumed = true;
+ return Promise.resolve(true);
+ },
+ };
+ const lifecycle: LifecycleProvider = {
+ getAuthoritativeStatus: () =>
+ Promise.resolve({ state: 'active', sequence: 0, registeredAt: NOW - 100 }),
+ };
+ const activeDevice: DeviceLifecycleProvider = {
+ getAuthoritativeDeviceStatus: () =>
+ Promise.resolve({
+ state: 'active',
+ identityState: 'active',
+ identitySequence: 0,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ deviceLedgerSequence: 1,
+ activatedAt: NOW - 100,
+ authorizationExpiresAt: NOW + 10_000,
+ }),
+ };
+ await expect(
+ verifyRpOperationV2(proof, expectation(), store, lifecycle, activeDevice),
+ ).resolves.toMatchObject({ subject: identity.subject });
+ expect(consumed).toBe(true);
+
+ consumed = false;
+ const revokedDevice: DeviceLifecycleProvider = {
+ getAuthoritativeDeviceStatus: () =>
+ Promise.resolve({
+ state: 'revoked',
+ identityState: 'active',
+ identitySequence: 0,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ deviceLedgerSequence: 2,
+ revokedAt: NOW - 1,
+ }),
+ };
+ await expectCode(
+ verifyRpOperationV2(proof, expectation(), store, lifecycle, revokedDevice),
+ 'DEVICE_REVOKED',
+ );
+ expect(consumed).toBe(false);
+
+ const expiredAtBoundary: DeviceLifecycleProvider = {
+ getAuthoritativeDeviceStatus: () =>
+ Promise.resolve({
+ state: 'active',
+ identityState: 'active',
+ identitySequence: 0,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ deviceLedgerSequence: 2,
+ activatedAt: NOW - 100,
+ authorizationExpiresAt: NOW,
+ }),
+ };
+ await expectCode(
+ verifyRpOperationV2(proof, expectation(), store, lifecycle, expiredAtBoundary),
+ 'DEVICE_EXPIRED',
+ );
+ expect(consumed).toBe(false);
+ });
+});
+
+describe('v2 signed device status', () => {
+ it('verifies freshness, signature, and exact device bindings', async () => {
+ const identity = await makeV2Identity(36);
+ const provider = getDefaultCryptoProvider();
+ const service = await provider.generateEd25519KeyPair({ privateKeyExtractable: true });
+ const servicePublic = await provider.exportEd25519PublicKey(service.publicKey);
+ const keyset: ServiceKeySet = {
+ keys: [
+ {
+ kty: 'OKP',
+ crv: 'Ed25519',
+ alg: 'EdDSA',
+ kid: 'device-registry-1',
+ x: encoded32(servicePublic),
+ use: 'sig',
+ },
+ ],
+ };
+ const payload: DeviceStatusStatementPayloadV2 = {
+ protocol: DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
+ subject: identity.subject,
+ genesisHash: identity.authorization.payload.genesisHash,
+ identityState: 'active',
+ identitySequence: 0,
+ deviceLedgerSequence: 1,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ deviceState: 'active',
+ activatedAt: NOW - 100,
+ authorizationExpiresAt: identity.authorization.payload.expiresAt,
+ iat: NOW,
+ exp: NOW + 30,
+ signerKid: 'device-registry-1',
+ };
+ const statement = {
+ payload,
+ signature: encoded64(await signProtocolPayload(payload, service.privateKey)),
+ };
+ await expect(
+ verifyDeviceStatusStatement(statement, keyset, NOW, {
+ subject: identity.subject,
+ genesisHash: identity.authorization.payload.genesisHash,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ identityState: 'active',
+ identitySequence: 0,
+ deviceLedgerSequence: 1,
+ deviceState: 'active',
+ authorizationExpiresAt: identity.authorization.payload.expiresAt,
+ }),
+ ).resolves.toEqual(statement);
+ await expectCode(verifyDeviceStatusStatement(statement, keyset, NOW + 31), 'PROOF_EXPIRED');
+
+ const boundaryPayload: DeviceStatusStatementPayloadV2 = {
+ ...payload,
+ authorizationExpiresAt: NOW,
+ };
+ const boundary = {
+ payload: boundaryPayload,
+ signature: encoded64(await signProtocolPayload(boundaryPayload, service.privateKey)),
+ };
+ await expectCode(verifyDeviceStatusStatement(boundary, keyset, NOW), 'DEVICE_EXPIRED');
+ });
+
+ it('verifies device registry receipts and their exact expected transition', async () => {
+ const identity = await makeV2Identity(37);
+ const provider = getDefaultCryptoProvider();
+ const service = await provider.generateEd25519KeyPair({ privateKeyExtractable: true });
+ const servicePublic = await provider.exportEd25519PublicKey(service.publicKey);
+ const keyset: ServiceKeySet = {
+ keys: [
+ {
+ kty: 'OKP',
+ crv: 'Ed25519',
+ alg: 'EdDSA',
+ kid: 'device-registry-2',
+ x: encoded32(servicePublic),
+ use: 'sig',
+ },
+ ],
+ };
+ const payload: DeviceRegistryReceiptPayloadV2 = {
+ protocol: DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ eventId: `nxde2_${encoded32(new Uint8Array(32).fill(3))}`,
+ operationId: `nxo2_${encoded32(new Uint8Array(32).fill(4))}`,
+ eventType: 'activated',
+ subject: identity.subject,
+ genesisHash: identity.authorization.payload.genesisHash,
+ identitySequence: 0,
+ identityState: 'active',
+ deviceLedgerSequence: 1,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ deviceState: 'active',
+ authorizationExpiresAt: identity.authorization.payload.expiresAt,
+ acceptedAt: NOW,
+ signerKid: 'device-registry-2',
+ };
+ const receipt = {
+ payload,
+ signature: encoded64(await signProtocolPayload(payload, service.privateKey)),
+ };
+ await expect(
+ verifyDeviceRegistryReceipt(receipt, keyset, {
+ operationId: payload.operationId,
+ subject: identity.subject,
+ genesisHash: identity.authorization.payload.genesisHash,
+ eventType: 'activated',
+ identityState: 'active',
+ identitySequence: 0,
+ deviceLedgerSequence: 1,
+ deviceId: identity.authorization.payload.deviceId,
+ authorizationId: identity.authorizationId,
+ deviceState: 'active',
+ }),
+ ).resolves.toEqual(receipt);
+ await expectCode(
+ verifyDeviceRegistryReceipt(receipt, keyset, { eventType: 'revoked' }),
+ 'WRONG_EVENT_TYPE',
+ );
+ await expectCode(
+ verifyDeviceRegistryReceipt(
+ { ...receipt, payload: { ...receipt.payload, acceptedAt: NOW + 1 } },
+ keyset,
+ ),
+ 'INVALID_SIGNATURE',
+ );
+ });
+});
diff --git a/packages/verifier/src/ownership-v2.ts b/packages/verifier/src/ownership-v2.ts
new file mode 100644
index 0000000..cdb917e
--- /dev/null
+++ b/packages/verifier/src/ownership-v2.ts
@@ -0,0 +1,227 @@
+import { deriveDeviceAuthorizationIdV2, deriveDeviceIdV2, deriveGenesisHash } from '@nexus/crypto';
+import {
+ IDENTITY_PROTOCOL_V1,
+ NEXUS_SUITE_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V1,
+ OWNERSHIP_PROOF_PROTOCOL_V2,
+ decodeBase64UrlExact,
+ encodeBase64Url,
+ ownershipProofV1Schema,
+ ownershipProofV2Schema,
+ verificationExpectationV2Schema,
+} from '@nexus/protocol';
+import type {
+ OwnershipProofV2,
+ VerificationExpectation,
+ VerificationExpectationV2,
+ VerifiedSubject,
+} from '@nexus/protocol';
+
+import { rethrowVerificationErrorV2, verificationError, verificationErrorV2 } from './errors.js';
+import {
+ importEd25519PublicKey,
+ importIdentitySigningKey,
+ parseExternal,
+ validateExpiringWindow,
+ verifyParsedSubject,
+ verifyPayloadSignature,
+} from './internal.js';
+import { verifyOwnershipProof } from './ownership.js';
+import {
+ MAX_SIGNED_OBJECT_LIFETIME_SECONDS,
+ type OwnershipProofAnyOptions,
+ type VerifiedDeviceSubject,
+} from './types.js';
+
+function parseOwnershipProofV2(value: unknown): OwnershipProofV2 {
+ return parseExternal(ownershipProofV2Schema, value, {
+ protocols: [
+ { path: ['payload', 'protocol'], expected: OWNERSHIP_PROOF_PROTOCOL_V2 },
+ { path: ['payload', 'genesis', 'protocol'], expected: IDENTITY_PROTOCOL_V1 },
+ {
+ path: ['payload', 'authorization', 'payload', 'protocol'],
+ expected: 'nexus.device-authorization.v2',
+ },
+ ],
+ suites: [{ path: ['payload', 'genesis', 'suite'], expected: NEXUS_SUITE_V1 }],
+ });
+}
+
+function parseExpectation(value: unknown): VerificationExpectationV2 {
+ return parseExternal(verificationExpectationV2Schema, value);
+}
+
+function isRecord(value: unknown): value is Readonly> {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function readProofProtocol(value: unknown): unknown {
+ if (!isRecord(value)) return undefined;
+ const payload = value['payload'];
+ if (!isRecord(payload)) return undefined;
+ return payload['protocol'];
+}
+
+function validateDispatcherOptions(value: OwnershipProofAnyOptions): void {
+ const acceptedProtocols =
+ typeof value === 'object' && value !== null ? value.acceptedProtocols : undefined;
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ Object.keys(value).length !== 1 ||
+ !Array.isArray(acceptedProtocols) ||
+ acceptedProtocols.length === 0 ||
+ acceptedProtocols.length > 2 ||
+ new Set(acceptedProtocols).size !== acceptedProtocols.length ||
+ acceptedProtocols.some(
+ (protocol) =>
+ protocol !== OWNERSHIP_PROOF_PROTOCOL_V1 && protocol !== OWNERSHIP_PROOF_PROTOCOL_V2,
+ )
+ ) {
+ throw verificationError('BAD_REQUEST');
+ }
+}
+
+function toV1Expectation(expected: VerificationExpectationV2): VerificationExpectation {
+ return {
+ audience: expected.audience,
+ action: expected.action,
+ resource: expected.resource,
+ nonce: expected.nonce,
+ now: expected.now,
+ maxClockSkewSeconds: expected.maxClockSkewSeconds,
+ };
+}
+
+function verifyContextBinding(
+ actual: string | undefined,
+ expected: VerificationExpectationV2['contextHash'],
+): void {
+ if (expected === null ? actual !== undefined : actual !== expected) {
+ throw verificationErrorV2('WRONG_CONTEXT');
+ }
+}
+
+async function verifyOwnershipProofV1WithContext(
+ proof: unknown,
+ expected: VerificationExpectationV2,
+): Promise {
+ const parsed = parseExternal(ownershipProofV1Schema, proof, {
+ protocols: [
+ { path: ['payload', 'protocol'], expected: OWNERSHIP_PROOF_PROTOCOL_V1 },
+ { path: ['payload', 'genesis', 'protocol'], expected: IDENTITY_PROTOCOL_V1 },
+ ],
+ suites: [{ path: ['payload', 'genesis', 'suite'], expected: NEXUS_SUITE_V1 }],
+ });
+ verifyContextBinding(parsed.payload.contextHash, expected.contextHash);
+ return verifyOwnershipProof(parsed, toV1Expectation(expected));
+}
+
+async function verifyOwnershipProofV2Internal(
+ proof: unknown,
+ expected: VerificationExpectationV2,
+): Promise {
+ const parsed = parseOwnershipProofV2(proof);
+ const expectation = parseExpectation(expected);
+ const payload = parsed.payload;
+ const authorization = payload.authorization.payload;
+
+ const verifiedRoot = await verifyParsedSubject(payload.genesis, payload.subject);
+ if (authorization.subject !== payload.subject) throw verificationError('INVALID_SUBJECT');
+
+ const genesisHash = encodeBase64Url(await deriveGenesisHash(payload.genesis));
+ if (authorization.genesisHash !== genesisHash) throw verificationError('WRONG_GENESIS_HASH');
+
+ const derivedDeviceId = await deriveDeviceIdV2({
+ subject: authorization.subject,
+ signingKey: authorization.signingKey,
+ });
+ if (authorization.deviceId !== derivedDeviceId || payload.deviceId !== derivedDeviceId) {
+ throw verificationErrorV2('WRONG_DEVICE');
+ }
+ if (authorization.signingKey.publicKey === payload.genesis.signingKey.publicKey) {
+ throw verificationError('INVALID_KEY');
+ }
+
+ const derivedAuthorizationId = await deriveDeviceAuthorizationIdV2(authorization);
+ if (payload.authorizationId !== derivedAuthorizationId) {
+ throw verificationErrorV2('WRONG_AUTHORIZATION');
+ }
+
+ const rootPublicKey = await importIdentitySigningKey(payload.genesis);
+ await verifyPayloadSignature(authorization, payload.authorization.rootSignature, rootPublicKey);
+
+ if (payload.iat < authorization.validFrom) throw verificationError('PROOF_NOT_YET_VALID');
+ if (payload.exp > authorization.expiresAt) throw verificationErrorV2('DEVICE_EXPIRED');
+ if (expectation.now < authorization.validFrom - expectation.maxClockSkewSeconds) {
+ throw verificationError('PROOF_NOT_YET_VALID');
+ }
+ if (expectation.now >= authorization.expiresAt) {
+ throw verificationErrorV2('DEVICE_EXPIRED');
+ }
+
+ if (payload.aud !== expectation.audience) throw verificationError('WRONG_AUDIENCE');
+ if (payload.act !== expectation.action) throw verificationError('WRONG_ACTION');
+ if (payload.resource !== expectation.resource) throw verificationError('WRONG_RESOURCE');
+ if (payload.nonce !== expectation.nonce) throw verificationError('WRONG_NONCE');
+ verifyContextBinding(payload.contextHash, expectation.contextHash);
+
+ validateExpiringWindow(
+ payload.iat,
+ payload.exp,
+ expectation.now,
+ expectation.maxClockSkewSeconds,
+ MAX_SIGNED_OBJECT_LIFETIME_SECONDS,
+ );
+
+ const devicePublicKey = await importEd25519PublicKey(authorization.signingKey.publicKey);
+ await verifyPayloadSignature(payload, parsed.deviceSignature, devicePublicKey);
+
+ return {
+ protocol: OWNERSHIP_PROOF_PROTOCOL_V2,
+ subject: verifiedRoot.subject,
+ rootSigningPublicKey: verifiedRoot.signingPublicKey,
+ deviceId: derivedDeviceId,
+ deviceSigningPublicKey: decodeBase64UrlExact(authorization.signingKey.publicKey, 32),
+ authorizationId: derivedAuthorizationId,
+ };
+}
+
+/** Verifies the complete v1-root -> v2-device -> v2-proof signature chain. */
+export async function verifyOwnershipProofV2(
+ proof: unknown,
+ expected: VerificationExpectationV2,
+): Promise {
+ try {
+ return await verifyOwnershipProofV2Internal(proof, expected);
+ } catch (error) {
+ rethrowVerificationErrorV2(error);
+ }
+}
+
+/** Explicit version dispatcher. Callers must name every protocol they accept. */
+export async function verifyOwnershipProofAny(
+ proof: unknown,
+ expected: VerificationExpectationV2,
+ options: OwnershipProofAnyOptions,
+): Promise {
+ try {
+ validateDispatcherOptions(options);
+ const protocol = readProofProtocol(proof);
+ if (typeof protocol !== 'string') throw verificationError('BAD_REQUEST');
+ if (!options.acceptedProtocols.some((accepted) => accepted === protocol)) {
+ throw verificationError('UNSUPPORTED_PROTOCOL');
+ }
+
+ switch (protocol) {
+ case OWNERSHIP_PROOF_PROTOCOL_V1:
+ return await verifyOwnershipProofV1WithContext(proof, parseExpectation(expected));
+ case OWNERSHIP_PROOF_PROTOCOL_V2:
+ return await verifyOwnershipProofV2Internal(proof, expected);
+ default:
+ throw verificationError('UNSUPPORTED_PROTOCOL');
+ }
+ } catch (error) {
+ rethrowVerificationErrorV2(error);
+ }
+}
diff --git a/packages/verifier/src/service-statements.ts b/packages/verifier/src/service-statements.ts
index d792178..9249abd 100644
--- a/packages/verifier/src/service-statements.ts
+++ b/packages/verifier/src/service-statements.ts
@@ -1,22 +1,33 @@
import { getDefaultCryptoProvider } from '@nexus/crypto';
import {
REGISTRY_RECEIPT_PROTOCOL_V1,
+ DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
STATUS_STATEMENT_PROTOCOL_V1,
decodeBase64UrlExact,
registryReceiptV1Schema,
+ deviceRegistryReceiptV2Schema,
+ deviceStatusStatementV2Schema,
serviceKeySetSchema,
statusStatementV1Schema,
} from '@nexus/protocol';
import type {
Ed25519PublicJwk,
+ DeviceRegistryReceiptV2,
+ DeviceStatusStatementV2,
RegistryReceiptV1,
ServiceKeySet,
StatusStatementV1,
} from '@nexus/protocol';
-import { verificationError } from './errors.js';
+import { rethrowVerificationErrorV2, verificationError, verificationErrorV2 } from './errors.js';
import { assertNonNegativeInteger, parseExternal, verifyPayloadSignature } from './internal.js';
-import type { RegistryReceiptExpectation, StatusStatementExpectation } from './types.js';
+import type {
+ DeviceStatusStatementExpectation,
+ DeviceRegistryReceiptExpectation,
+ RegistryReceiptExpectation,
+ StatusStatementExpectation,
+} from './types.js';
export function parseServiceKeyset(value: unknown): ServiceKeySet {
const result = serviceKeySetSchema.safeParse(value);
@@ -116,6 +127,102 @@ export async function verifyRegistryReceipt(
return parsed;
}
+/** Verifies a service-signed v2 device activation or revocation receipt. */
+async function verifyDeviceRegistryReceiptInternal(
+ receipt: unknown,
+ keyset: unknown,
+ expected: DeviceRegistryReceiptExpectation = {},
+): Promise {
+ const parsed = parseExternal(deviceRegistryReceiptV2Schema, receipt, {
+ protocols: [
+ {
+ path: ['payload', 'protocol'],
+ expected: DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2,
+ },
+ ],
+ });
+ const parsedKeyset = parseServiceKeyset(keyset);
+
+ await verifyServiceSignature(
+ parsed.payload,
+ parsed.signature,
+ parsed.payload.signerKid,
+ parsedKeyset,
+ );
+
+ if (expected.eventId !== undefined && parsed.payload.eventId !== expected.eventId) {
+ throw verificationErrorV2('WRONG_EVENT_ID');
+ }
+ if (expected.operationId !== undefined && parsed.payload.operationId !== expected.operationId) {
+ throw verificationErrorV2('WRONG_OPERATION');
+ }
+ if (expected.subject !== undefined && parsed.payload.subject !== expected.subject) {
+ throw verificationError('INVALID_SUBJECT');
+ }
+ if (expected.genesisHash !== undefined && parsed.payload.genesisHash !== expected.genesisHash) {
+ throw verificationError('WRONG_GENESIS_HASH');
+ }
+ if (expected.eventType !== undefined && parsed.payload.eventType !== expected.eventType) {
+ throw verificationError('WRONG_EVENT_TYPE');
+ }
+ if (
+ expected.identityState !== undefined &&
+ parsed.payload.identityState !== expected.identityState
+ ) {
+ throw verificationError('WRONG_STATE');
+ }
+ if (
+ expected.identitySequence !== undefined &&
+ parsed.payload.identitySequence !== expected.identitySequence
+ ) {
+ throw verificationError('SEQUENCE_CONFLICT');
+ }
+ if (
+ expected.deviceLedgerSequence !== undefined &&
+ parsed.payload.deviceLedgerSequence !== expected.deviceLedgerSequence
+ ) {
+ throw verificationError('SEQUENCE_CONFLICT');
+ }
+ if (expected.deviceId !== undefined && parsed.payload.deviceId !== expected.deviceId) {
+ throw verificationErrorV2('WRONG_DEVICE');
+ }
+ if (
+ expected.authorizationId !== undefined &&
+ parsed.payload.authorizationId !== expected.authorizationId
+ ) {
+ throw verificationErrorV2('WRONG_AUTHORIZATION');
+ }
+ if (expected.deviceState !== undefined && parsed.payload.deviceState !== expected.deviceState) {
+ throw verificationError('WRONG_STATE');
+ }
+ if (
+ expected.authorizationExpiresAt !== undefined &&
+ parsed.payload.authorizationExpiresAt !== expected.authorizationExpiresAt
+ ) {
+ throw verificationErrorV2('WRONG_AUTHORIZATION');
+ }
+ if (expected.acceptedAt !== undefined && parsed.payload.acceptedAt !== expected.acceptedAt) {
+ throw verificationError('WRONG_STATE');
+ }
+ if (expected.revokedBy !== undefined && parsed.payload.revokedBy !== expected.revokedBy) {
+ throw verificationError('WRONG_STATE');
+ }
+
+ return parsed;
+}
+
+export async function verifyDeviceRegistryReceipt(
+ receipt: unknown,
+ keyset: unknown,
+ expected: DeviceRegistryReceiptExpectation = {},
+): Promise {
+ try {
+ return await verifyDeviceRegistryReceiptInternal(receipt, keyset, expected);
+ } catch (error) {
+ rethrowVerificationErrorV2(error);
+ }
+}
+
export async function verifyStatusStatement(
statement: unknown,
keyset: unknown,
@@ -155,3 +262,101 @@ export async function verifyStatusStatement(
return parsed;
}
+
+/** Verifies a service-signed combined identity/device lifecycle statement. */
+async function verifyDeviceStatusStatementInternal(
+ statement: unknown,
+ keyset: unknown,
+ now: number,
+ expected: DeviceStatusStatementExpectation = {},
+): Promise {
+ const parsed = parseExternal(deviceStatusStatementV2Schema, statement, {
+ protocols: [
+ {
+ path: ['payload', 'protocol'],
+ expected: DEVICE_STATUS_STATEMENT_PROTOCOL_V2,
+ },
+ ],
+ });
+ const parsedKeyset = parseServiceKeyset(keyset);
+ const maxClockSkewSeconds = expected.maxClockSkewSeconds ?? 0;
+ assertNonNegativeInteger(now);
+ assertNonNegativeInteger(maxClockSkewSeconds);
+
+ await verifyServiceSignature(
+ parsed.payload,
+ parsed.signature,
+ parsed.payload.signerKid,
+ parsedKeyset,
+ );
+
+ if (parsed.payload.iat > now + maxClockSkewSeconds) {
+ throw verificationError('PROOF_NOT_YET_VALID');
+ }
+ if (now > parsed.payload.exp) throw verificationError('PROOF_EXPIRED');
+ if (
+ parsed.payload.deviceState === 'active' &&
+ parsed.payload.authorizationExpiresAt !== undefined &&
+ now >= parsed.payload.authorizationExpiresAt
+ ) {
+ throw verificationErrorV2('DEVICE_EXPIRED');
+ }
+
+ if (expected.subject !== undefined && parsed.payload.subject !== expected.subject) {
+ throw verificationError('INVALID_SUBJECT');
+ }
+ if (expected.genesisHash !== undefined && parsed.payload.genesisHash !== expected.genesisHash) {
+ throw verificationError('WRONG_GENESIS_HASH');
+ }
+ if (expected.deviceId !== undefined && parsed.payload.deviceId !== expected.deviceId) {
+ throw verificationErrorV2('WRONG_DEVICE');
+ }
+ if (
+ expected.authorizationId !== undefined &&
+ parsed.payload.authorizationId !== expected.authorizationId
+ ) {
+ throw verificationErrorV2('WRONG_AUTHORIZATION');
+ }
+ if (
+ expected.identityState !== undefined &&
+ parsed.payload.identityState !== expected.identityState
+ ) {
+ throw verificationError('WRONG_STATE');
+ }
+ if (
+ expected.identitySequence !== undefined &&
+ parsed.payload.identitySequence !== expected.identitySequence
+ ) {
+ throw verificationError('SEQUENCE_CONFLICT');
+ }
+ if (
+ expected.deviceLedgerSequence !== undefined &&
+ parsed.payload.deviceLedgerSequence !== expected.deviceLedgerSequence
+ ) {
+ throw verificationError('SEQUENCE_CONFLICT');
+ }
+ if (expected.deviceState !== undefined && parsed.payload.deviceState !== expected.deviceState) {
+ throw verificationError('WRONG_STATE');
+ }
+ if (
+ expected.authorizationExpiresAt !== undefined &&
+ parsed.payload.authorizationExpiresAt !== expected.authorizationExpiresAt
+ ) {
+ throw verificationErrorV2('WRONG_AUTHORIZATION');
+ }
+
+ return parsed;
+}
+
+export async function verifyDeviceStatusStatement(
+ statement: unknown,
+ keyset: unknown,
+ now: number,
+ expected: DeviceStatusStatementExpectation = {},
+): Promise {
+ try {
+ return await verifyDeviceStatusStatementInternal(statement, keyset, now, expected);
+ } catch (error) {
+ rethrowVerificationErrorV2(error);
+ }
+}
diff --git a/packages/verifier/src/types.ts b/packages/verifier/src/types.ts
index d19ca72..b68fdb3 100644
--- a/packages/verifier/src/types.ts
+++ b/packages/verifier/src/types.ts
@@ -1,8 +1,13 @@
import type {
+ Base64Url32,
+ DeviceAuthorizationIdV2,
+ DeviceRegistryReceiptPayloadV2,
GlobalCheckpointShardV1,
+ NexusDeviceIdV2,
NexusSubject,
RegistryReceiptPayloadV1,
} from '@nexus/protocol';
+import type { OWNERSHIP_PROOF_PROTOCOL_V1, OWNERSHIP_PROOF_PROTOCOL_V2 } from '@nexus/protocol';
export const MAX_SIGNED_OBJECT_LIFETIME_SECONDS = 120;
@@ -96,3 +101,70 @@ export type AuthoritativeLifecycleState =
export interface LifecycleProvider {
getAuthoritativeStatus(subject: NexusSubject): Promise;
}
+
+export interface VerifiedDeviceSubject {
+ protocol: typeof OWNERSHIP_PROOF_PROTOCOL_V2;
+ subject: NexusSubject;
+ rootSigningPublicKey: Uint8Array;
+ deviceId: NexusDeviceIdV2;
+ deviceSigningPublicKey: Uint8Array;
+ authorizationId: DeviceAuthorizationIdV2;
+}
+
+export interface OwnershipProofAnyOptions {
+ acceptedProtocols: readonly (
+ typeof OWNERSHIP_PROOF_PROTOCOL_V1 | typeof OWNERSHIP_PROOF_PROTOCOL_V2
+ )[];
+}
+
+export type AuthoritativeDeviceLifecycleState =
+ | { state: 'not-found' }
+ | {
+ state: 'active' | 'revoked' | 'expired';
+ identityState: 'active' | 'revoked';
+ identitySequence: number;
+ deviceId: NexusDeviceIdV2;
+ authorizationId: DeviceAuthorizationIdV2;
+ deviceLedgerSequence: number;
+ activatedAt?: number;
+ revokedAt?: number;
+ authorizationExpiresAt?: number;
+ };
+
+export interface DeviceLifecycleProvider {
+ getAuthoritativeDeviceStatus(
+ subject: NexusSubject,
+ deviceId: NexusDeviceIdV2,
+ authorizationId: DeviceAuthorizationIdV2,
+ ): Promise;
+}
+
+export interface DeviceStatusStatementExpectation {
+ subject?: NexusSubject;
+ genesisHash?: Base64Url32;
+ deviceId?: NexusDeviceIdV2;
+ authorizationId?: DeviceAuthorizationIdV2;
+ identityState?: 'active' | 'revoked';
+ identitySequence?: number;
+ deviceLedgerSequence?: number;
+ deviceState?: 'active' | 'revoked' | 'expired' | 'unknown';
+ authorizationExpiresAt?: number;
+ maxClockSkewSeconds?: number;
+}
+
+export interface DeviceRegistryReceiptExpectation {
+ eventId?: DeviceRegistryReceiptPayloadV2['eventId'];
+ operationId?: DeviceRegistryReceiptPayloadV2['operationId'];
+ subject?: NexusSubject;
+ genesisHash?: Base64Url32;
+ eventType?: DeviceRegistryReceiptPayloadV2['eventType'];
+ identityState?: DeviceRegistryReceiptPayloadV2['identityState'];
+ identitySequence?: number;
+ deviceLedgerSequence?: number;
+ deviceId?: NexusDeviceIdV2;
+ authorizationId?: DeviceAuthorizationIdV2;
+ deviceState?: DeviceRegistryReceiptPayloadV2['deviceState'];
+ authorizationExpiresAt?: number;
+ acceptedAt?: number;
+ revokedBy?: DeviceRegistryReceiptPayloadV2['revokedBy'];
+}
diff --git a/packages/verifier/src/verifier.test.ts b/packages/verifier/src/verifier.test.ts
index d9b1fc3..d60acc5 100644
--- a/packages/verifier/src/verifier.test.ts
+++ b/packages/verifier/src/verifier.test.ts
@@ -33,7 +33,10 @@ import {
import { describe, expect, it } from 'vitest';
import {
+ NEXUS_VERIFICATION_ERROR_CODES,
+ NEXUS_VERIFICATION_ERROR_CODES_V2,
NexusVerificationError,
+ NexusVerificationErrorV2,
verifyContinuityLink,
verifyOwnershipProof,
verifyRegistryReceipt,
@@ -43,7 +46,12 @@ import {
verifyStatusStatement,
verifySubject,
} from './index.js';
-import type { ChallengeStore, LifecycleProvider, NexusVerificationErrorCode } from './index.js';
+import type {
+ ChallengeStore,
+ LifecycleProvider,
+ NexusVerificationErrorCode,
+ NexusVerificationErrorCodeV2,
+} from './index.js';
const NOW = 1_800_000_000;
@@ -692,4 +700,55 @@ describe('typed verifier errors', () => {
expect(error.code).toBe('INVALID_REVOCATION_SECRET');
expect(error.message).not.toContain('secret');
});
+
+ it('keeps the legacy error union exact and exposes v2 failures additively', () => {
+ expect(NEXUS_VERIFICATION_ERROR_CODES).toEqual([
+ 'BAD_REQUEST',
+ 'UNSUPPORTED_PROTOCOL',
+ 'UNSUPPORTED_SUITE',
+ 'INVALID_SUBJECT',
+ 'INVALID_SIGNATURE',
+ 'INVALID_REVOCATION_SECRET',
+ 'WRONG_AUDIENCE',
+ 'WRONG_ACTION',
+ 'WRONG_RESOURCE',
+ 'WRONG_NONCE',
+ 'SEQUENCE_CONFLICT',
+ 'PROOF_NOT_YET_VALID',
+ 'PROOF_EXPIRED',
+ 'PROOF_LIFETIME_EXCEEDED',
+ 'NONCE_REPLAY_OR_EXPIRED',
+ 'IDENTITY_NOT_FOUND',
+ 'IDENTITY_REVOKED',
+ 'KEY_NOT_FOUND',
+ 'INVALID_KEY',
+ 'KEY_PURPOSE_MISMATCH',
+ 'WRONG_EVENT_TYPE',
+ 'WRONG_STATE',
+ 'WRONG_GENESIS_HASH',
+ 'WRONG_SCOPE',
+ 'WRONG_SHARD',
+ 'WRONG_ROOT',
+ 'WRONG_TREE_SIZE',
+ 'WRONG_MANIFEST',
+ 'WRONG_EVENT_HASH',
+ 'INVALID_INCLUSION_PROOF',
+ ]);
+ expect(NEXUS_VERIFICATION_ERROR_CODES_V2).toContain('DEVICE_REVOKED');
+
+ const legacyCode: NexusVerificationErrorCode = 'IDENTITY_REVOKED';
+ const v2Code: NexusVerificationErrorCodeV2 = 'DEVICE_REVOKED';
+ expect(legacyCode).toBe('IDENTITY_REVOKED');
+ expect(v2Code).toBe('DEVICE_REVOKED');
+
+ // @ts-expect-error Device-only codes must never widen the stable legacy union.
+ const invalidLegacyCode: NexusVerificationErrorCode = 'DEVICE_REVOKED';
+ expect(invalidLegacyCode).toBe('DEVICE_REVOKED');
+
+ const error = new NexusVerificationErrorV2('DEVICE_REVOKED');
+ expect(error).toBeInstanceOf(NexusVerificationErrorV2);
+ expect(error).toBeInstanceOf(NexusVerificationError);
+ expect(error.codeV2).toBe('DEVICE_REVOKED');
+ expect(error.code).toBe('IDENTITY_REVOKED');
+ });
});
diff --git a/packages/wallet-core/src/device-transfer.ts b/packages/wallet-core/src/device-transfer.ts
new file mode 100644
index 0000000..65eff4b
--- /dev/null
+++ b/packages/wallet-core/src/device-transfer.ts
@@ -0,0 +1,270 @@
+import type { CryptoProvider } from '@nexus/crypto';
+import {
+ canonicalizeToBytes,
+ decodeBase64Url,
+ decodeBase64UrlExact,
+ encodeBase64Url,
+ type DeviceAuthorizationV2,
+ type IdentityGenesisV1,
+ type NexusSubject,
+} from '@nexus/protocol';
+
+import { WalletCoreError } from './errors.js';
+import type { DeviceTransferEnvelopeV2 } from './types.js';
+
+const TRANSFER_PROTOCOL = 'nexus.device-transfer.v2' as const;
+const TRANSFER_PLAINTEXT_PROTOCOL = 'nexus.device-transfer-plaintext.v2' as const;
+const TRANSFER_SUITE = 'NX-HKDF-SHA256-AES256GCM-v2' as const;
+const TRANSFER_INFO = new TextEncoder().encode('NEXUS-DEVICE-TRANSFER\0v2\0');
+const textDecoder = new TextDecoder('utf-8', { fatal: true });
+const MAX_TRANSFER_CIPHERTEXT_BYTES = 64 * 1024;
+const MAX_TRANSFER_CIPHERTEXT_CHARACTERS = Math.ceil((MAX_TRANSFER_CIPHERTEXT_BYTES * 4) / 3);
+
+export interface DeviceTransferPlaintextV2 {
+ protocol: typeof TRANSFER_PLAINTEXT_PROTOCOL;
+ subject: NexusSubject;
+ genesis: IdentityGenesisV1;
+ authorization: DeviceAuthorizationV2;
+ devicePrivateKey: {
+ alg: 'Ed25519';
+ format: 'pkcs8';
+ bytes: string;
+ };
+}
+
+interface TransferHeaderV2 {
+ protocol: typeof TRANSFER_PROTOCOL;
+ suite: typeof TRANSFER_SUITE;
+ bundleId: string;
+ salt: string;
+ iv: string;
+}
+
+function invalidTransfer(message: string, cause?: unknown): WalletCoreError {
+ return new WalletCoreError(
+ 'INVALID_REQUEST',
+ message,
+ cause === undefined ? undefined : { cause },
+ );
+}
+
+function header(bundle: DeviceTransferEnvelopeV2): TransferHeaderV2 {
+ return {
+ protocol: bundle.protocol,
+ suite: bundle.suite,
+ bundleId: bundle.bundleId,
+ salt: bundle.salt,
+ iv: bundle.iv,
+ };
+}
+
+function requireExactKeys(
+ value: Record,
+ expected: readonly string[],
+ label: string,
+): void {
+ const actual = Object.keys(value).sort();
+ const wanted = [...expected].sort();
+ if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
+ throw invalidTransfer(`${label} contains missing or unknown fields.`);
+ }
+}
+
+function asObject(value: unknown, label: string): Record {
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+ throw invalidTransfer(`${label} must be an object.`);
+ }
+ return value as Record;
+}
+
+export function validateDeviceTransferEnvelope(
+ value: DeviceTransferEnvelopeV2,
+): DeviceTransferEnvelopeV2 {
+ const input = asObject(value, 'The device transfer bundle');
+ requireExactKeys(
+ input,
+ ['protocol', 'suite', 'bundleId', 'salt', 'iv', 'ciphertext'],
+ 'The device transfer bundle',
+ );
+ if (value.protocol !== TRANSFER_PROTOCOL || value.suite !== TRANSFER_SUITE) {
+ throw invalidTransfer('The device transfer protocol or encryption suite is unsupported.');
+ }
+ if (
+ typeof value.ciphertext !== 'string' ||
+ value.ciphertext.length > MAX_TRANSFER_CIPHERTEXT_CHARACTERS
+ ) {
+ throw invalidTransfer('The device transfer ciphertext is too large or invalid.');
+ }
+ try {
+ decodeBase64UrlExact(value.bundleId, 32);
+ decodeBase64UrlExact(value.salt, 32);
+ decodeBase64UrlExact(value.iv, 12);
+ const ciphertext = decodeBase64Url(value.ciphertext);
+ if (ciphertext.byteLength < 16 || ciphertext.byteLength > MAX_TRANSFER_CIPHERTEXT_BYTES) {
+ throw new Error('invalid ciphertext length');
+ }
+ } catch (error) {
+ throw invalidTransfer('The device transfer bundle encoding is invalid.', error);
+ }
+ return structuredClone(value);
+}
+
+export async function encryptDeviceTransfer(
+ plaintext: Omit,
+ crypto: CryptoProvider,
+): Promise<{ bundle: DeviceTransferEnvelopeV2; transferKey: Uint8Array }> {
+ const transferKey = crypto.randomBytes(32);
+ const bundleId = crypto.randomBytes(32);
+ const salt = crypto.randomBytes(32);
+ const iv = crypto.randomBytes(12);
+ if (
+ transferKey.byteLength !== 32 ||
+ bundleId.byteLength !== 32 ||
+ salt.byteLength !== 32 ||
+ iv.byteLength !== 12
+ ) {
+ throw new WalletCoreError('STORAGE_ERROR', 'The CSPRNG returned invalid transfer material.');
+ }
+ const transferHeader: TransferHeaderV2 = {
+ protocol: TRANSFER_PROTOCOL,
+ suite: TRANSFER_SUITE,
+ bundleId: encodeBase64Url(bundleId),
+ salt: encodeBase64Url(salt),
+ iv: encodeBase64Url(iv),
+ };
+ let contentKey: Uint8Array | undefined;
+ let plaintextBytes: Uint8Array | undefined;
+ try {
+ contentKey = await crypto.hkdfSha256({
+ inputKeyMaterial: transferKey,
+ salt,
+ info: TRANSFER_INFO,
+ length: 32,
+ });
+ plaintextBytes = canonicalizeToBytes({
+ protocol: TRANSFER_PLAINTEXT_PROTOCOL,
+ ...plaintext,
+ });
+ const ciphertext = await crypto.encryptAes256Gcm({
+ key: contentKey,
+ iv,
+ additionalData: canonicalizeToBytes(transferHeader),
+ tagLength: 128,
+ plaintext: plaintextBytes,
+ });
+ const returnedTransferKey = Uint8Array.from(transferKey);
+ return {
+ bundle: { ...transferHeader, ciphertext: encodeBase64Url(ciphertext) },
+ transferKey: returnedTransferKey,
+ };
+ } finally {
+ transferKey.fill(0);
+ contentKey?.fill(0);
+ plaintextBytes?.fill(0);
+ }
+}
+
+export async function decryptDeviceTransfer(
+ input: DeviceTransferEnvelopeV2,
+ transferKey: Uint8Array,
+ crypto: CryptoProvider,
+): Promise<{
+ subject: NexusSubject;
+ genesis: IdentityGenesisV1;
+ authorization: unknown;
+ devicePrivateKeyPkcs8: Uint8Array;
+}> {
+ const bundle = validateDeviceTransferEnvelope(input);
+ if (transferKey.byteLength !== 32) {
+ throw invalidTransfer('The transfer key must contain exactly 32 bytes.');
+ }
+ const salt = decodeBase64UrlExact(bundle.salt, 32);
+ const iv = decodeBase64UrlExact(bundle.iv, 12);
+ const inputKeyMaterial = Uint8Array.from(transferKey);
+ let contentKey: Uint8Array;
+ try {
+ contentKey = await crypto.hkdfSha256({
+ inputKeyMaterial,
+ salt,
+ info: TRANSFER_INFO,
+ length: 32,
+ });
+ } finally {
+ inputKeyMaterial.fill(0);
+ }
+ let plaintextBytes: Uint8Array;
+ try {
+ plaintextBytes = await crypto.decryptAes256Gcm({
+ key: contentKey,
+ iv,
+ additionalData: canonicalizeToBytes(header(bundle)),
+ tagLength: 128,
+ ciphertext: decodeBase64Url(bundle.ciphertext),
+ });
+ } catch (error) {
+ throw invalidTransfer('The transfer key or encrypted bundle is invalid.', error);
+ } finally {
+ contentKey.fill(0);
+ }
+
+ try {
+ const parsed = JSON.parse(textDecoder.decode(plaintextBytes)) as unknown;
+ const object = asObject(parsed, 'The decrypted device transfer');
+ requireExactKeys(
+ object,
+ ['protocol', 'subject', 'genesis', 'authorization', 'devicePrivateKey'],
+ 'The decrypted device transfer',
+ );
+ if (object.protocol !== TRANSFER_PLAINTEXT_PROTOCOL || typeof object.subject !== 'string') {
+ throw invalidTransfer('The decrypted device transfer protocol is invalid.');
+ }
+ const privateKey = asObject(object.devicePrivateKey, 'The transferred device private key');
+ requireExactKeys(privateKey, ['alg', 'format', 'bytes'], 'The transferred device private key');
+ if (
+ privateKey.alg !== 'Ed25519' ||
+ privateKey.format !== 'pkcs8' ||
+ typeof privateKey.bytes !== 'string'
+ ) {
+ throw invalidTransfer('The transferred device private key format is invalid.');
+ }
+ const keyBytes = decodeBase64Url(privateKey.bytes);
+ if (keyBytes.byteLength < 32 || keyBytes.byteLength > 512) {
+ throw invalidTransfer('The transferred device private key length is invalid.');
+ }
+ return {
+ subject: object.subject as NexusSubject,
+ genesis: object.genesis as IdentityGenesisV1,
+ authorization: object.authorization,
+ devicePrivateKeyPkcs8: keyBytes,
+ };
+ } catch (error) {
+ if (error instanceof WalletCoreError) throw error;
+ throw invalidTransfer('The decrypted device transfer is malformed.', error);
+ } finally {
+ plaintextBytes.fill(0);
+ }
+}
+
+/** Internal test/debug primitive; never exposes root private material. */
+export async function decryptDeviceTransferPlaintext(
+ input: DeviceTransferEnvelopeV2,
+ transferKey: Uint8Array,
+ crypto: CryptoProvider,
+): Promise {
+ const decrypted = await decryptDeviceTransfer(input, transferKey, crypto);
+ try {
+ return {
+ protocol: TRANSFER_PLAINTEXT_PROTOCOL,
+ subject: decrypted.subject,
+ genesis: decrypted.genesis,
+ authorization: decrypted.authorization as DeviceAuthorizationV2,
+ devicePrivateKey: {
+ alg: 'Ed25519',
+ format: 'pkcs8',
+ bytes: encodeBase64Url(decrypted.devicePrivateKeyPkcs8),
+ },
+ };
+ } finally {
+ decrypted.devicePrivateKeyPkcs8.fill(0);
+ }
+}
diff --git a/packages/wallet-core/src/identity-store.ts b/packages/wallet-core/src/identity-store.ts
index 925d41c..6de0326 100644
--- a/packages/wallet-core/src/identity-store.ts
+++ b/packages/wallet-core/src/identity-store.ts
@@ -6,7 +6,8 @@ import {
requireIndexedDb,
transactionComplete,
} from './indexed-db.js';
-import type { IdentityStore, LocalIdentityRecordV1 } from './types.js';
+import { WalletCoreError } from './errors.js';
+import type { IdentityStore, LocalIdentityRecordV1, LocalIssuedDeviceRecordV2 } from './types.js';
export interface IndexedDbIdentityStoreOptions {
databaseName?: string;
@@ -17,6 +18,8 @@ function cloneRecord(record: LocalIdentityRecordV1): LocalIdentityRecordV1 {
return structuredClone(record);
}
+const MAX_ISSUED_DEVICE_CATALOGUE_ENTRIES = 256;
+
export class IndexedDbIdentityStore implements IdentityStore {
readonly #databaseName: string;
readonly #factory: IDBFactory;
@@ -61,6 +64,72 @@ export class IndexedDbIdentityStore implements IdentityStore {
await completion;
}
+ public async appendIssuedDevice(
+ localId: string,
+ device: LocalIssuedDeviceRecordV2,
+ ): Promise {
+ await this.update(localId, (current) => {
+ if (current.localState !== 'active' || current.deviceV2 !== undefined) {
+ throw new WalletCoreError(
+ 'INVALID_REQUEST',
+ 'Only an active root identity can record an issued device.',
+ );
+ }
+ if ((current.issuedDevicesV2 ?? []).some((entry) => entry.deviceId === device.deviceId)) {
+ throw new WalletCoreError(
+ 'REGISTRY_CONFLICT',
+ 'This device is already in the root catalogue.',
+ );
+ }
+ if ((current.issuedDevicesV2 ?? []).length >= MAX_ISSUED_DEVICE_CATALOGUE_ENTRIES) {
+ throw new WalletCoreError(
+ 'STORAGE_ERROR',
+ 'The issued-device catalogue reached its 256-entry safety limit.',
+ );
+ }
+ return {
+ ...current,
+ issuedDevicesV2: [...(current.issuedDevicesV2 ?? []), structuredClone(device)],
+ };
+ });
+ }
+
+ public async update(
+ localId: string,
+ mutate: (record: LocalIdentityRecordV1) => LocalIdentityRecordV1,
+ ): Promise {
+ const database = await this.#open();
+ const transaction = database.transaction(IDENTITY_RECORD_STORE, 'readwrite');
+ const completion = transactionComplete(transaction);
+ const store = transaction.objectStore(IDENTITY_RECORD_STORE);
+ const current = await requestResult(
+ // IndexedDB's legacy DOM declaration returns IDBRequest at this boundary.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ store.get(localId),
+ );
+ if (current === undefined) {
+ transaction.abort();
+ await completion.catch(() => undefined);
+ throw new WalletCoreError('IDENTITY_NOT_FOUND', `Local identity ${localId} does not exist.`);
+ }
+ let updated: LocalIdentityRecordV1;
+ try {
+ updated = cloneRecord(mutate(cloneRecord(current)));
+ } catch (error) {
+ transaction.abort();
+ await completion.catch(() => undefined);
+ throw error;
+ }
+ if (updated.localId !== localId) {
+ transaction.abort();
+ await completion.catch(() => undefined);
+ throw new WalletCoreError('STORAGE_ERROR', 'An identity update cannot change its local ID.');
+ }
+ await requestResult(store.put(updated));
+ await completion;
+ return cloneRecord(updated);
+ }
+
public async delete(localId: string): Promise {
const database = await this.#open();
const transaction = database.transaction(IDENTITY_RECORD_STORE, 'readwrite');
@@ -69,6 +138,34 @@ export class IndexedDbIdentityStore implements IdentityStore {
await completion;
}
+ public async reserveDeviceRecord(record: LocalIdentityRecordV1): Promise {
+ if (record.deviceV2 === undefined) {
+ throw new WalletCoreError(
+ 'INVALID_REQUEST',
+ 'A reserved device record requires v2 metadata.',
+ );
+ }
+ const database = await this.#open();
+ const transaction = database.transaction(IDENTITY_RECORD_STORE, 'readwrite');
+ const completion = transactionComplete(transaction);
+ const store = transaction.objectStore(IDENTITY_RECORD_STORE);
+ const records = await requestResult(
+ // IndexedDB's legacy DOM declaration returns IDBRequest at this boundary.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ store.getAll(),
+ );
+ if (records.some((existing) => existing.deviceV2?.deviceId === record.deviceV2?.deviceId)) {
+ transaction.abort();
+ await completion.catch(() => undefined);
+ throw new WalletCoreError(
+ 'REGISTRY_CONFLICT',
+ 'This device key is already installed in the wallet.',
+ );
+ }
+ await requestResult(store.add(cloneRecord(record)));
+ await completion;
+ }
+
public async close(): Promise {
if (this.#database === undefined) return;
const database = await this.#database;
@@ -99,8 +196,82 @@ export class InMemoryIdentityStore implements IdentityStore {
return Promise.resolve();
}
+ public appendIssuedDevice(localId: string, device: LocalIssuedDeviceRecordV2): Promise {
+ return this.update(localId, (current) => {
+ if (current.localState !== 'active' || current.deviceV2 !== undefined) {
+ throw new WalletCoreError(
+ 'INVALID_REQUEST',
+ 'Only an active root identity can record an issued device.',
+ );
+ }
+ if ((current.issuedDevicesV2 ?? []).some((entry) => entry.deviceId === device.deviceId)) {
+ throw new WalletCoreError(
+ 'REGISTRY_CONFLICT',
+ 'This device is already in the root catalogue.',
+ );
+ }
+ if ((current.issuedDevicesV2 ?? []).length >= MAX_ISSUED_DEVICE_CATALOGUE_ENTRIES) {
+ throw new WalletCoreError(
+ 'STORAGE_ERROR',
+ 'The issued-device catalogue reached its 256-entry safety limit.',
+ );
+ }
+ return {
+ ...current,
+ issuedDevicesV2: [...(current.issuedDevicesV2 ?? []), structuredClone(device)],
+ };
+ }).then(() => undefined);
+ }
+
+ public update(
+ localId: string,
+ mutate: (record: LocalIdentityRecordV1) => LocalIdentityRecordV1,
+ ): Promise {
+ const current = this.#records.get(localId);
+ if (current === undefined) {
+ return Promise.reject(
+ new WalletCoreError('IDENTITY_NOT_FOUND', `Local identity ${localId} does not exist.`),
+ );
+ }
+ let updated: LocalIdentityRecordV1;
+ try {
+ updated = cloneRecord(mutate(cloneRecord(current)));
+ } catch (error) {
+ return Promise.reject(error instanceof Error ? error : new Error('Identity update failed.'));
+ }
+ if (updated.localId !== localId) {
+ return Promise.reject(
+ new WalletCoreError('STORAGE_ERROR', 'An identity update cannot change its local ID.'),
+ );
+ }
+ this.#records.set(localId, cloneRecord(updated));
+ return Promise.resolve(cloneRecord(updated));
+ }
+
public delete(localId: string): Promise {
this.#records.delete(localId);
return Promise.resolve();
}
+
+ public reserveDeviceRecord(record: LocalIdentityRecordV1): Promise {
+ if (record.deviceV2 === undefined) {
+ return Promise.reject(
+ new WalletCoreError('INVALID_REQUEST', 'A reserved device record requires v2 metadata.'),
+ );
+ }
+ if (
+ [...this.#records.values()].some(
+ (existing) => existing.deviceV2?.deviceId === record.deviceV2?.deviceId,
+ )
+ ) {
+ return Promise.reject(
+ new WalletCoreError(
+ 'REGISTRY_CONFLICT',
+ 'This device key is already installed in the wallet.',
+ ),
+ );
+ }
+ this.#records.set(record.localId, cloneRecord(record));
+ return Promise.resolve();
+ }
}
diff --git a/packages/wallet-core/src/index.ts b/packages/wallet-core/src/index.ts
index 60b5064..4258859 100644
--- a/packages/wallet-core/src/index.ts
+++ b/packages/wallet-core/src/index.ts
@@ -23,12 +23,22 @@ export type {
ContinuityLinkOptions,
CreatedLocalIdentity,
CreateIdentityOptions,
+ DeviceRegistryReceiptVerifier,
+ DeviceRequestOptions,
+ DeviceRevocationOptions,
+ DeviceTransferEnvelopeV2,
DisposeIdentityOptions,
IdentityStore,
+ ImportedDeviceV2,
+ IssueDeviceTransferOptions,
+ IssuedDeviceTransferV2,
KeyRef,
KeyVault,
+ KeyVaultStorage,
LocalIdentityRecordV1,
LocalIdentitySummary,
+ LocalDeviceRecordV2,
+ LocalIssuedDeviceRecordV2,
RegistryClient,
RegistryIdentityStatus,
RegistryReceiptVerifier,
diff --git a/packages/wallet-core/src/key-vault.ts b/packages/wallet-core/src/key-vault.ts
index bd6a1b6..318e70a 100644
--- a/packages/wallet-core/src/key-vault.ts
+++ b/packages/wallet-core/src/key-vault.ts
@@ -1,3 +1,4 @@
+import { createProtocolSignaturePreimage } from '@nexus/crypto';
import { encodeBase64Url } from '@nexus/protocol';
import { WalletCoreError } from './errors.js';
@@ -10,9 +11,9 @@ import {
requireWebCrypto,
transactionComplete,
} from './indexed-db.js';
-import type { KeyRef, KeyVault, SecretRef } from './types.js';
+import type { KeyRef, KeyVaultStorage, SecretRef } from './types.js';
-type StoredKeyKind = 'signing-key' | 'agreement-key';
+type StoredKeyKind = 'signing-key' | 'agreement-key' | 'device-signing-key';
interface StoredKeyRecord {
ref: KeyRef;
@@ -27,12 +28,70 @@ interface StoredSecretRecord {
secret: ArrayBuffer;
}
-type StoredVaultRecord = StoredKeyRecord | StoredSecretRecord;
+interface StoredCancelledDeviceKeyRefRecord {
+ ref: KeyRef;
+ kind: 'cancelled-device-key-ref';
+}
+
+type StoredVaultRecord = StoredKeyRecord | StoredSecretRecord | StoredCancelledDeviceKeyRefRecord;
+
+export interface ManagedProtocolPayload {
+ protocol: string;
+}
+
+interface ManagedKeyVaultCapabilities {
+ createDeviceSigningKeyRef(): KeyRef;
+ importDeviceSigningKeyAtRef(
+ ref: KeyRef,
+ privateKeyPkcs8: Uint8Array,
+ expectedPublicKey: Uint8Array,
+ ): Promise;
+ cancelDeviceSigningKeyRef(ref: KeyRef): Promise;
+ signProtocolPayload(
+ ref: KeyRef,
+ payload: T,
+ ): Promise;
+}
+
+const managedCapabilities = new WeakMap