diff --git a/NEXUS_SPEC.md b/NEXUS_SPEC.md index a9d3926..4583f7e 100644 --- a/NEXUS_SPEC.md +++ b/NEXUS_SPEC.md @@ -2,7 +2,7 @@ **Status:** Implementation Specification / Codex Build Plan **Version:** `0.1.0-draft` -**Last validated:** 2026-08-11 +**Last validated:** 2026-08-12 **Primary runtime:** Cloudflare Workers **Primary language:** TypeScript **Repository:** `nexus` (standalone from any consuming anonymous platform) @@ -27,6 +27,8 @@ The central privacy invariant is: This document specifies the protocol, cryptographic objects, Cloudflare deployment, data models, repository architecture, browser wallet, relying-party integration, lifecycle semantics, threat model, implementation phases, and acceptance criteria. +The additive [root/device delegation v2 profile](./docs/protocol/device-delegation-v2.md) is incorporated by reference and is normative for v2 objects and operations. It keeps the `nexus.identity.v1` genesis, `nx1_` subject, v1 endpoints, and v1 terminal lifecycle unchanged. Where the profile grants a narrowly scoped v2 exception (for example, a subject-bound device-key identifier in a v2 proof), that exception applies only to the explicitly versioned v2 object and MUST NOT broaden a v1 schema or behavior. + --- ## 1. Normative language @@ -35,6 +37,8 @@ The words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are no When implementation convenience conflicts with a privacy or cryptographic invariant in this document, the invariant wins. +V1 consumers MUST continue to reject unknown v2 protocols. Supporting v2 never silently changes the interpretation of a v1 object. + --- ## 2. Goals @@ -100,6 +104,8 @@ Version 1 does NOT attempt to provide: - End-to-end chat encryption itself. Chat protocols such as MLS belong to consuming applications; Nexus supplies identity/control primitives. - Perfect physical secure deletion from browser/OS storage. Nexus enforces disposal through irreversible revocation even if old key material survives elsewhere. +The v2 profile does not add identity/root-key backup or recovery. It permits only an independently generated device key to enter an authenticated encrypted offline transfer bundle; see the normative v2 profile for its cloning and root-loss limitations. + --- # Part I — Security and Privacy Model @@ -166,12 +172,16 @@ The implementation MUST enforce all of the following. The wallet MUST NOT send a stable controller ID, vault ID, installation ID, device ID, account ID, or master public key to relying parties. +The only v2 exception is the `nxd2_` identifier required to verify an explicitly negotiated `nexus.ownership-proof.v2`. It is derived from one subject and one device public key, MUST NOT be reused across subjects, and MUST NOT encode or be treated as a physical-device, installation, wallet, controller, or account identifier. V1 objects remain unchanged. + ### P2 — Independent identities Each disposable identity MUST use independently generated random private key material. Disposable identities MUST NOT be deterministically derived from a surviving master seed. Reason: if identity `A` can be regenerated from a master seed, deleting `A` is not meaningful cryptographic disposal. +V2 device keys are not new identities. They MUST nevertheless be independently CSPRNG-generated and MUST NOT be derived from the identity root or a sibling device key. + ### P3 — Scope relationships are local The mapping: @@ -199,6 +209,7 @@ The wallet SDK MUST default to one or more identities selected for the requestin Application logs MUST NOT contain: - private keys; +- device private keys or decrypted device-transfer contents; - revocation secrets; - raw signed proof bodies; - identity Genesis Documents unless explicitly running a local debug build; @@ -870,10 +881,11 @@ If D1 disagrees with the subject Durable Object, the Durable Object wins. ### 19.5 Cloudflare Queues — asynchronous event distribution -Use a queue such as: +Use separate queues such as: ```text nexus-registry-events +nexus-registry-device-events ``` for: @@ -887,6 +899,19 @@ Consumers MUST be idempotent because delivery can be retried. Every event has a deterministic `eventId`. +V1 identity events and v2 device events MUST use separate producer bindings, queues, dead-letter +queues, strict event schemas, and projector dispatch paths: + +```text +REGISTRY_EVENTS -> nexus-registry-events -> nexus-registry-events-dlq +REGISTRY_DEVICE_EVENTS -> nexus-registry-device-events -> nexus-registry-device-events-dlq +``` + +The queues are independently at-least-once and unordered relative to each other. A v2 device event +may reach the projector before its v1 registration event. The projector therefore persists and +deduplicates the signed event but materializes device state only after the matching v1 subject and +genesis anchor exist. Neither queue is an authorization source. + ### 19.6 R2 — audit and operational artifacts Use a dedicated transparency bucket: @@ -1170,10 +1195,11 @@ D1 data is operational data, not a user directory. Admin APIs to search it SHOUL ## 25. API conventions -Base: +Versioned bases: ```text -https://api.nexus.example/v1 +https://api.nexus.example/v1 # identity lifecycle +https://api.nexus.example/v2/device # root-authorized device lifecycle ``` Content type: @@ -1198,7 +1224,7 @@ Rules: ### `GET /.well-known/nexus.json` -Returns: +This is the immutable v1 discovery contract. It returns exactly: ```json { @@ -1210,6 +1236,38 @@ Returns: } ``` +V2 support MUST NOT add fields or protocols to this response. Existing v1 clients may use strict +object validation and must continue to receive the exact v1 shape. + +### `GET /.well-known/nexus-v2.json` + +This additive versioned endpoint advertises the root/device profile and links back to v1 discovery: + +```json +{ + "protocols": [ + "nexus.identity.v1", + "nexus.ownership-proof.v1", + "nexus.device-authorization.v2", + "nexus.ownership-proof.v2" + ], + "suites": ["NX-25519-SHA256-JCS-v1"], + "registry": "https://api.nexus.example/v1", + "deviceRegistry": "https://api.nexus.example/v2/device", + "popupChannels": ["nexus.popup.v2", "nexus.popup.v1"], + "v1Discovery": "https://api.nexus.example/.well-known/nexus.json", + "jwks": "https://api.nexus.example/.well-known/jwks.json", + "wallet": "https://wallet.nexus.example" +} +``` + +The `protocols` array names signed identity/ownership primitives relevant to capability discovery; +the device-operation protocol identifiers and exact endpoints are defined by the v2 profile under +`deviceRegistry`. Array order expresses the deployment's preference only. It never authorizes a +proof protocol: popup negotiation and the RP's backend-bound challenge policy remain authoritative. +A v2-aware client fetches the versioned document directly and MAY follow `v1Discovery`; a v1 client +need not parse, fetch, or know about the v2 document. + ### `GET /.well-known/jwks.json` Publishes Nexus service receipt/status public signing keys using standard Ed25519 JWK representation. @@ -1405,6 +1463,31 @@ BODY_TOO_LARGE INTERNAL_ERROR ``` +That closed set remains the v1 `NEXUS_ERROR_CODES` contract. V2 device endpoints use the additive +`NEXUS_DEVICE_ERROR_CODES` set: all v1 codes plus: + +```text +METHOD_NOT_ALLOWED +UNSUPPORTED_MEDIA_TYPE +ORIGIN_NOT_ALLOWED +HTTPS_REQUIRED +SERVICE_UNAVAILABLE +DEVICE_NOT_FOUND +DEVICE_REVOKED +DEVICE_AUTHORIZATION_CONFLICT +``` + +None of these additive codes are added to the v1 protocol union or accepted by the strict v1 +`NexusError` schema. `METHOD_NOT_ALLOWED`, `UNSUPPORTED_MEDIA_TYPE`, `ORIGIN_NOT_ALLOWED`, +`HTTPS_REQUIRED`, and `SERVICE_UNAVAILABLE` are HTTP/Edge transport faults; the Edge emitted them +before v2 outside the strict v1 protocol-domain schema. Their inclusion in `NexusDeviceError` makes +the v2 device transport contract complete and does not reinterpret or widen a v1 protocol object. + +The v2 `NexusDeviceError` type/schema is used only by `/v2/device` endpoints and device batch +entries. Library-local verification errors such as `DEVICE_EXPIRED`, `WRONG_DEVICE`, +`WRONG_AUTHORIZATION`, and `WRONG_CONTEXT` describe verifier outcomes and are not registry wire +error codes. + Error messages MUST NOT reveal whether a guessed revocation secret was close/correct in any partial sense. --- @@ -1554,6 +1637,12 @@ Prefer non-extractable `CryptoKey` objects for normal local operation where brow Private identity keys remain non-extractable in v1. +The v2 identity root is that same non-extractable key. A v2 root MAY create a temporarily +extractable, independently generated device key solely for immediate authenticated encrypted +offline transfer. It MUST NOT export the root, and the destination MUST import the device key as +non-extractable. The complete requirements and residual cloning risk are defined in +[`docs/protocol/device-delegation-v2.md`](./docs/protocol/device-delegation-v2.md). + ### 37.4 XSS statement Non-extractable keys do not make XSS harmless: malicious wallet-origin JavaScript may still ask the browser to sign using a key. Therefore CSP/build integrity and user consent remain mandatory. @@ -2078,7 +2167,11 @@ Conceptually: }, "queues": { "producers": [ - { "binding": "REGISTRY_EVENTS", "queue": "nexus-registry-events" } + { "binding": "REGISTRY_EVENTS", "queue": "nexus-registry-events" }, + { + "binding": "REGISTRY_DEVICE_EVENTS", + "queue": "nexus-registry-device-events" + } ] }, "migrations": [ @@ -2107,7 +2200,8 @@ Conceptually: ], "queues": { "consumers": [ - { "queue": "nexus-registry-events", "max_batch_size": 100 } + { "queue": "nexus-registry-events", "max_batch_size": 100 }, + { "queue": "nexus-registry-device-events", "max_batch_size": 100 } ] }, "services": [ @@ -2116,6 +2210,12 @@ Conceptually: } ``` +Production configuration assigns distinct dead-letter queues to both consumers. Apply the +version-controlled additive D1 device projection migration before enabling the device consumer. It +creates device-event, device-state, and per-subject device-ledger-head projection tables without +changing the v1 identity/event tables. Because cross-queue delivery is unordered, migration and +projector rollout MUST support deferred reconciliation when a device event precedes its v1 anchor. + --- # Part XI — Service Signing Keys @@ -2794,6 +2894,11 @@ Self-signed `iat` cannot establish historical time after key leakage. Avoid creating a public chronological pseudonym directory. +### ADR-0011: Root-authorized device keys + +The unchanged v1 signing key may authorize independent v2 device keys; each device is separately +revocable, the identity root remains non-extractable, and v1 wire behavior remains unchanged. + --- # Part XXI — Definition of Done @@ -3012,5 +3117,10 @@ The reviewer must answer at least: 16. Can a Queue retry duplicate a transparency leaf or D1 logical event? 17. Do public transparency artifacts avoid exposing a chronological subject directory or plaintext identity inventory? 18. Does the product clearly distinguish pseudonymity from network anonymity? +19. Can any v2 device authorize or revoke a sibling, replace the root, or bypass terminal subject revocation? The answer must be no. +20. Does every v2 proof check the exact active device authorization as well as subject status, rather than relying on v1 status alone? +21. Can the root private key, revocation secret, RP scopes, or another identity enter a device-transfer envelope? The answer must be no. +22. Does product language acknowledge that exported device bundles can be cloned and that physical copies cannot be individually distinguished? +23. Can an existing v1 identity adopt v2 without changing genesis, subject, v1 proofs, receipts, or terminal lifecycle semantics? If any answer is uncertain, production release is blocked pending review. diff --git a/README.md b/README.md index 719f37a..a686724 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,14 @@ Worker memory, and transparency data cannot authorize current control. 8. [ADR-0008: Default rotation is unlinkable](./docs/adr/0008-default-rotation-unlinkable.md) 9. [ADR-0009: Historical timestamps require trusted receipts](./docs/adr/0009-trusted-historical-timestamps.md) 10. [ADR-0010: Hash-only transparency](./docs/adr/0010-hash-only-transparency.md) +11. [ADR-0011: Root-authorized device keys](./docs/adr/0011-root-authorized-device-keys.md) Nexus v1 intentionally provides no identity-key backup, portable private-key export/import, or cloud -recovery service. Identity keys remain local to the wallet; losing local key material is permanent, -and a disposed identity cannot be recovered or reactivated. +recovery service. The additive [v2 root/device profile](./docs/protocol/device-delegation-v2.md) +preserves the same `nx1_` identity and non-extractable root while allowing that root to authorize +independently revocable device signing keys. Only a device key may be copied through an encrypted +offline transfer bundle; the identity/root key is never exported. Existing v1 proofs and endpoints +remain unchanged. ## Prerequisites diff --git a/SECURITY.md b/SECURITY.md index 90a269b..21b8d10 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,6 +36,10 @@ Changes must preserve the normative requirements in `NEXUS_SPEC.md`, especially: - no global controller/user identifier and no civil-identity mapping; - independent CSPRNG-generated identity keys, not deterministic children of a recoverable master seed; +- non-extractable per-identity roots and independently generated v2 device keys, with no root-key + export or device-key derivation from the root; +- root-signed device authorization, possession-proven activation, exact per-device status checks, + irreversible device tombstones, and terminal identity revocation dominating all device state; - no user private keys, revocation secrets, or plaintext vault contents on Cloudflare; - exact audience, action, resource, nonce, and time binding for proofs; - audience derived from `MessageEvent.origin`, with exact-origin proof responses; @@ -46,6 +50,12 @@ Changes must preserve the normative requirements in `NEXUS_SPEC.md`, especially: - no sensitive values or stable identity/network dimensions in application logs; - no third-party runtime JavaScript on the wallet origin. +The additive v2 profile permits encrypted offline transfer of a newly generated device private key, +not the identity/root key. Reports involving transfer-envelope plaintext, weak key establishment, +clone handling, device-status staleness, sibling-device authority, root loss, or v1 downgrade are +security-sensitive. See +[`docs/protocol/device-delegation-v2.md`](./docs/protocol/device-delegation-v2.md). + Nexus provides pseudonymous cryptographic identity, not network anonymity. A report showing correlation through network or timing metadata may still be valuable, but the protocol alone cannot guarantee protection from Cloudflare, ISPs, relying parties, or a broadly observing operator. diff --git a/apps/reference-rp/.env.example b/apps/reference-rp/.env.example index e90d095..c7afbce 100644 --- a/apps/reference-rp/.env.example +++ b/apps/reference-rp/.env.example @@ -5,6 +5,8 @@ VITE_NEXUS_WALLET_URL=http://127.0.0.1:5173 NEXUS_RP_ORIGIN=https://127.0.0.1:4174 NEXUS_API_URL=https://127.0.0.1:8787 +# Required outside unsafe local mode. Pin this to the registry's status-signing JWKS. +NEXUS_SERVICE_JWKS_JSON={"keys":[{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"rowo-status-2026-08","x":"p_0wFlB-khNLDc_66RYGHG98JY_lrhXtzj5tNEuy4SE","use":"sig"}]} # Optional isolated UI mode. Never enable this in a deployment; it replaces the # authoritative registry lifecycle check with an in-memory local authority. diff --git a/apps/reference-rp/src/App.tsx b/apps/reference-rp/src/App.tsx index 1ad6ab7..f836978 100644 --- a/apps/reference-rp/src/App.tsx +++ b/apps/reference-rp/src/App.tsx @@ -211,17 +211,30 @@ export function App(): React.ReactElement { title: 'Approve Notes session', message: 'Review session.start and the Nexus Notes session resource in your wallet.', }); - const result = await nexus.requestProof(request, { signal: controller.signal }); + const result = await nexus.requestProofV2(request, { + signal: controller.signal, + acceptedProofProtocols: challenge.acceptedProofProtocols, + }); setProof({ stage: 'verifying', title: 'Starting secure session', message: 'Checking the proof and authoritative identity lifecycle.', }); - const started = await startSession({ - challengeId: challenge.challengeId, - proof: result.proof, - operation, - }); + const started = await startSession( + result.proofProtocol === 'nexus.ownership-proof.v2' + ? { + challengeId: challenge.challengeId, + proofProtocol: result.proofProtocol, + proof: result.proof, + operation, + } + : { + challengeId: challenge.challengeId, + proofProtocol: result.proofProtocol, + proof: result.proof, + operation, + }, + ); setSession(started.session); setReceipts((current) => [started.receipt, ...current].slice(0, 6)); setProof({ diff --git a/apps/reference-rp/src/backend/challenges.ts b/apps/reference-rp/src/backend/challenges.ts index 0e003bf..be9ee90 100644 --- a/apps/reference-rp/src/backend/challenges.ts +++ b/apps/reference-rp/src/backend/challenges.ts @@ -1,7 +1,7 @@ import type { Base64UrlAtLeast16 } from '@nexus/protocol'; import type { ChallengeRecord, ChallengeStore } from '@nexus/verifier'; -import type { IssuedChallenge, ProofAction } from '../shared/contracts.js'; +import type { AcceptedProofProtocols, IssuedChallenge, ProofAction } from '../shared/contracts.js'; import { sha256Base64Url } from './digests.js'; interface StoredChallenge { @@ -12,6 +12,7 @@ interface StoredChallenge { contextHash: string; expiresAt: number; consumedAt: number | null; + acceptedProofProtocols: AcceptedProofProtocols; } export class HashOnlyChallengeStore implements ChallengeStore { @@ -27,6 +28,7 @@ export class HashOnlyChallengeStore implements ChallengeStore { contextHash: challenge.contextHash ?? '', expiresAt: challenge.expiresAt, consumedAt: null, + acceptedProofProtocols: challenge.acceptedProofProtocols, }; this.#byId.set(record.challengeId, record); this.#byNonceHash.set(record.nonceHash, record); diff --git a/apps/reference-rp/src/backend/errors.ts b/apps/reference-rp/src/backend/errors.ts index a4a3a03..c78852f 100644 --- a/apps/reference-rp/src/backend/errors.ts +++ b/apps/reference-rp/src/backend/errors.ts @@ -10,6 +10,8 @@ export type RpErrorCode = | 'NAME_TAKEN' | 'VERSION_CONFLICT' | 'SESSION_INVALID' + | 'UNSUPPORTED_PROOF_PROTOCOL' + | 'SERVICE_UNAVAILABLE' | 'INTERNAL_ERROR'; export class RpError extends Error { diff --git a/apps/reference-rp/src/backend/lifecycle.test.ts b/apps/reference-rp/src/backend/lifecycle.test.ts index 7946551..4048b6f 100644 --- a/apps/reference-rp/src/backend/lifecycle.test.ts +++ b/apps/reference-rp/src/backend/lifecycle.test.ts @@ -1,8 +1,39 @@ -import type { NexusSubject } from '@nexus/protocol'; +import { signProtocolPayload, WebCryptoProvider } from '@nexus/crypto'; +import { DEVICE_STATUS_STATEMENT_PROTOCOL_V2, decodeBase64Url } from '@nexus/protocol'; +import type { + Base64Url32, + Base64Url64, + DeviceRegistryStatusV2, + DeviceStatusStatementPayloadV2, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, + NexusSubject, + ServiceKeySet, +} from '@nexus/protocol'; import { describe, expect, it } from 'vitest'; import { AuthoritativeRegistryLifecycleProvider } from './lifecycle.js'; +const TEST_KEYSET: ServiceKeySet = { + keys: [ + { + kty: 'OKP', + crv: 'Ed25519', + alg: 'EdDSA', + kid: 'test-status-key', + x: '_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU' as Base64Url32, + use: 'sig', + }, + ], +}; +const TEST_PRIVATE_KEY = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; // gitleaks:allow -- fixed test-only Ed25519 fixture +const NOW = 1_800_000_000; +const SUBJECT: NexusSubject = `nx1_${'A'.repeat(43)}`; +const DEVICE_ID: NexusDeviceIdV2 = `nxd2_${'B'.repeat(42)}A`; +const AUTHORIZATION_ID: NexusDeviceAuthorizationIdV2 = `nxa2_${'C'.repeat(42)}A`; +const GENESIS_HASH = `${'D'.repeat(42)}A` as Base64Url32; +const cryptoProvider = new WebCryptoProvider(); + describe('AuthoritativeRegistryLifecycleProvider', () => { it('uses the Nexus protocol media type for the fixed status endpoint', async () => { const subject: NexusSubject = `nx1_${'A'.repeat(43)}`; @@ -10,6 +41,7 @@ describe('AuthoritativeRegistryLifecycleProvider', () => { let requestedInit: RequestInit | undefined; const provider = new AuthoritativeRegistryLifecycleProvider( 'https://nexus.rowo.link', + TEST_KEYSET, (input, init) => { requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; @@ -24,4 +56,121 @@ describe('AuthoritativeRegistryLifecycleProvider', () => { expect(requestedInit?.method).toBe('POST'); expect(requestedInit?.body).toBe(JSON.stringify({ subject })); }); + + it.each(['stale', 'forged', 'wrong-tuple'] as const)( + 'rejects a %s signed device status response', + async (mode) => { + const response = await deviceStatusResponse(mode); + const provider = new AuthoritativeRegistryLifecycleProvider( + 'https://nexus.rowo.link', + TEST_KEYSET, + () => Promise.resolve(response.clone()), + () => NOW, + ); + await expect( + provider.getAuthoritativeDeviceStatus(SUBJECT, DEVICE_ID, AUTHORIZATION_ID), + ).rejects.toThrow(); + }, + ); + + it('returns a cryptographically verified revoked-identity device status', async () => { + const response = await deviceStatusResponse('revoked-identity'); + const provider = new AuthoritativeRegistryLifecycleProvider( + 'https://nexus.rowo.link', + TEST_KEYSET, + () => Promise.resolve(response.clone()), + () => NOW, + ); + await expect( + provider.getAuthoritativeDeviceStatus(SUBJECT, DEVICE_ID, AUTHORIZATION_ID), + ).resolves.toMatchObject({ state: 'revoked', identityState: 'revoked', identitySequence: 1 }); + }); + + it('treats an authorization as expired at its exact expiry second', async () => { + const response = await deviceStatusResponse('expiry-boundary'); + const provider = new AuthoritativeRegistryLifecycleProvider( + 'https://nexus.rowo.link', + TEST_KEYSET, + () => Promise.resolve(response.clone()), + () => NOW, + ); + await expect( + provider.getAuthoritativeDeviceStatus(SUBJECT, DEVICE_ID, AUTHORIZATION_ID), + ).resolves.toMatchObject({ state: 'expired', authorizationExpiresAt: NOW }); + }); + + it('rejects an active status at the exact authorization expiry second', async () => { + const response = await deviceStatusResponse('active-expiry-boundary'); + const provider = new AuthoritativeRegistryLifecycleProvider( + 'https://nexus.rowo.link', + TEST_KEYSET, + () => Promise.resolve(response.clone()), + () => NOW, + ); + await expect( + provider.getAuthoritativeDeviceStatus(SUBJECT, DEVICE_ID, AUTHORIZATION_ID), + ).rejects.toThrow(); + }); }); + +async function deviceStatusResponse( + mode: + | 'stale' + | 'forged' + | 'wrong-tuple' + | 'revoked-identity' + | 'expiry-boundary' + | 'active-expiry-boundary', +): Promise { + const authorizationId: NexusDeviceAuthorizationIdV2 = + mode === 'wrong-tuple' ? `nxa2_${'E'.repeat(42)}A` : AUTHORIZATION_ID; + const identityState = mode === 'revoked-identity' ? 'revoked' : 'active'; + const deviceState = + mode === 'revoked-identity' ? 'revoked' : mode === 'expiry-boundary' ? 'expired' : 'active'; + const issuedAt = mode === 'stale' ? NOW - 120 : NOW; + const payload: DeviceStatusStatementPayloadV2 = { + protocol: DEVICE_STATUS_STATEMENT_PROTOCOL_V2, + subject: SUBJECT, + genesisHash: GENESIS_HASH, + identityState, + identitySequence: identityState === 'active' ? 0 : 1, + deviceLedgerSequence: 1, + deviceId: DEVICE_ID, + authorizationId, + deviceState, + activatedAt: NOW - 300, + ...(deviceState === 'revoked' ? { revokedAt: NOW - 1 } : {}), + authorizationExpiresAt: + mode === 'expiry-boundary' || mode === 'active-expiry-boundary' ? NOW : NOW + 3_600, + iat: issuedAt, + exp: issuedAt + 60, + signerKid: 'test-status-key', + }; + const privateKey = await cryptoProvider.importEd25519PrivateKey( + decodeBase64Url(TEST_PRIVATE_KEY), + ); + const validSignature = (await signProtocolPayload( + payload, + privateKey, + cryptoProvider, + )) as Base64Url64; + const signature: Base64Url64 = + mode === 'forged' + ? (`${validSignature[0] === 'A' ? 'B' : 'A'}${validSignature.slice(1)}` as Base64Url64) + : validSignature; + const status: DeviceRegistryStatusV2 = { + subject: SUBJECT, + genesisHash: GENESIS_HASH, + identityState, + identitySequence: payload.identitySequence, + deviceLedgerSequence: payload.deviceLedgerSequence, + deviceId: DEVICE_ID, + authorizationId, + deviceState, + activatedAt: payload.activatedAt ?? null, + revokedAt: payload.revokedAt ?? null, + authorizationExpiresAt: payload.authorizationExpiresAt ?? null, + statusStatement: { payload, signature }, + }; + return Response.json(status); +} diff --git a/apps/reference-rp/src/backend/lifecycle.ts b/apps/reference-rp/src/backend/lifecycle.ts index f301034..d7d0ecc 100644 --- a/apps/reference-rp/src/backend/lifecycle.ts +++ b/apps/reference-rp/src/backend/lifecycle.ts @@ -1,19 +1,40 @@ -import { registryStatusV1Schema } from '@nexus/protocol'; -import type { NexusSubject } from '@nexus/protocol'; -import type { AuthoritativeLifecycleState, LifecycleProvider } from '@nexus/verifier'; +import { + deviceRegistryStatusV2Schema, + registryStatusV1Schema, + serviceKeySetSchema, +} from '@nexus/protocol'; +import type { + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, + NexusSubject, + ServiceKeySet, +} from '@nexus/protocol'; +import { verifyDeviceStatusStatement } from '@nexus/verifier'; +import type { + AuthoritativeDeviceLifecycleState, + AuthoritativeLifecycleState, + DeviceLifecycleProvider, + LifecycleProvider, +} from '@nexus/verifier'; const STATUS_PATH = '/v1/identity/status'; +const DEVICE_STATUS_PATH = '/v2/device/status'; const STATUS_TIMEOUT_MS = 5_000; type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise; /** Fail-closed adapter for the edge endpoint backed by the subject Durable Object. */ -export class AuthoritativeRegistryLifecycleProvider implements LifecycleProvider { +export class AuthoritativeRegistryLifecycleProvider + implements LifecycleProvider, DeviceLifecycleProvider +{ readonly #apiOrigin: string; + readonly #serviceKeyset: ServiceKeySet; public constructor( apiUrl: string, + serviceKeyset: unknown, private readonly fetcher: FetchLike = globalThis.fetch, + private readonly now: () => number = () => Math.floor(Date.now() / 1_000), ) { let parsed: URL; try { @@ -32,6 +53,11 @@ export class AuthoritativeRegistryLifecycleProvider implements LifecycleProvider throw new TypeError('Nexus API URL must be one absolute HTTPS origin.'); } this.#apiOrigin = parsed.origin; + const parsedKeyset = serviceKeySetSchema.safeParse(serviceKeyset); + if (!parsedKeyset.success) { + throw new TypeError('Nexus service verification keyset is invalid.'); + } + this.#serviceKeyset = parsedKeyset.data; } public async getAuthoritativeStatus(subject: NexusSubject): Promise { @@ -73,13 +99,95 @@ export class AuthoritativeRegistryLifecycleProvider implements LifecycleProvider ...(status.data.revokedAt === null ? {} : { revokedAt: status.data.revokedAt }), }; } + + public async getAuthoritativeDeviceStatus( + subject: NexusSubject, + deviceId: NexusDeviceIdV2, + authorizationId: NexusDeviceAuthorizationIdV2, + ): Promise { + let response: Response; + try { + response = await this.fetcher(new URL(DEVICE_STATUS_PATH, this.#apiOrigin), { + method: 'POST', + headers: { + Accept: 'application/nexus+json, application/json', + 'Content-Type': 'application/nexus+json', + }, + body: JSON.stringify({ subject, deviceId, authorizationId }), + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + signal: AbortSignal.timeout(STATUS_TIMEOUT_MS), + }); + } catch (error) { + throw new Error('Authoritative Nexus device status is unavailable.', { cause: error }); + } + if (response.status === 404) return { state: 'not-found' }; + if (!response.ok) throw new Error('Authoritative Nexus device status was rejected.'); + let body: unknown; + try { + body = (await response.json()) as unknown; + } catch (error) { + throw new Error('Authoritative Nexus device status was unreadable.', { cause: error }); + } + const status = deviceRegistryStatusV2Schema.safeParse(body); + if ( + !status.success || + status.data.subject !== subject || + status.data.deviceId !== deviceId || + status.data.authorizationId !== authorizationId + ) { + throw new Error('Authoritative Nexus device status failed validation.'); + } + const now = this.now(); + try { + await verifyDeviceStatusStatement(status.data.statusStatement, this.#serviceKeyset, now, { + subject, + genesisHash: status.data.genesisHash, + deviceId, + authorizationId, + identityState: status.data.identityState, + identitySequence: status.data.identitySequence, + deviceLedgerSequence: status.data.deviceLedgerSequence, + deviceState: status.data.deviceState, + ...(status.data.authorizationExpiresAt === null + ? {} + : { authorizationExpiresAt: status.data.authorizationExpiresAt }), + }); + } catch (error) { + throw new Error('Authoritative Nexus device status signature is invalid or stale.', { + cause: error, + }); + } + const payload = status.data.statusStatement.payload; + if (payload.deviceState === 'unknown') return { state: 'not-found' }; + return { + state: + payload.deviceState === 'active' && + payload.authorizationExpiresAt !== undefined && + now >= payload.authorizationExpiresAt + ? 'expired' + : payload.deviceState, + identityState: payload.identityState, + identitySequence: payload.identitySequence, + deviceId, + authorizationId, + deviceLedgerSequence: payload.deviceLedgerSequence, + ...(payload.activatedAt === undefined ? {} : { activatedAt: payload.activatedAt }), + ...(payload.revokedAt === undefined ? {} : { revokedAt: payload.revokedAt }), + ...(payload.authorizationExpiresAt === undefined + ? {} + : { authorizationExpiresAt: payload.authorizationExpiresAt }), + }; + } } /** * Explicitly unsafe local-test authority. Never wire this into a deployed RP. */ -export class LocalLifecycleAuthority implements LifecycleProvider { +export class LocalLifecycleAuthority implements LifecycleProvider, DeviceLifecycleProvider { readonly #states = new Map(); + readonly #deviceStates = new Map(); public constructor(private readonly now: () => number = () => Math.floor(Date.now() / 1000)) {} @@ -110,4 +218,27 @@ export class LocalLifecycleAuthority implements LifecycleProvider { public set(subject: NexusSubject, state: AuthoritativeLifecycleState): void { this.#states.set(subject, state); } + + public getAuthoritativeDeviceStatus( + _subject: NexusSubject, + deviceId: NexusDeviceIdV2, + authorizationId: NexusDeviceAuthorizationIdV2, + ): Promise { + return Promise.resolve( + this.#deviceStates.get(deviceId) ?? { + state: 'active', + identityState: 'active', + identitySequence: 0, + deviceId, + authorizationId, + deviceLedgerSequence: 0, + }, + ); + } + + public setDevice( + state: Exclude, + ): void { + this.#deviceStates.set(state.deviceId, state); + } } diff --git a/apps/reference-rp/src/backend/repository.test.ts b/apps/reference-rp/src/backend/repository.test.ts index e51118b..281c78c 100644 --- a/apps/reference-rp/src/backend/repository.test.ts +++ b/apps/reference-rp/src/backend/repository.test.ts @@ -9,6 +9,7 @@ import { IDENTITY_PROTOCOL_V1, NEXUS_SUITE_V1, OWNERSHIP_PROOF_PROTOCOL_V1, + OWNERSHIP_PROOF_PROTOCOL_V2, encodeBase64Url, } from '@nexus/protocol'; import type { @@ -21,6 +22,7 @@ import type { import { describe, expect, it } from 'vitest'; import type { IssuedChallenge } from '../shared/contracts.js'; +import { canonicalSha256 } from './digests.js'; import { LocalLifecycleAuthority } from './lifecycle.js'; import { ReferenceRpRepository } from './repository.js'; @@ -78,6 +80,33 @@ describe('ReferenceRpRepository', () => { ]); }); + it('rejects a valid v1 proof when the stored challenge policy is v2-only', async () => { + const repository = createRepository(); + const identity = await createIdentity(); + const issued = repository.issueChallenge({ action: 'session.start' }); + const contextHash = v2OnlyContextHash(); + const stored = repository.challenges.getById(issued.challengeId); + if (stored === null) throw new Error('Expected the stored challenge.'); + Object.assign(stored, { + acceptedProofProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2], + contextHash, + }); + const challenge: IssuedChallenge = { + ...issued, + contextHash: contextHash as Base64Url32, + acceptedProofProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2], + }; + + await expect( + repository.startSession({ + challengeId: challenge.challengeId, + operation: { action: 'session.start' }, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1, + proof: await createProof(identity, challenge), + }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_PROOF_PROTOCOL' }); + }); + it('publishes one unique friendly name across existing notes and replies', async () => { const repository = createRepository(); const owner = await login(repository, await createIdentity()); @@ -306,9 +335,11 @@ function createRepository( lifecycle?: LocalLifecycleAuthority, now: () => number = () => NOW, ): ReferenceRpRepository { + const authority = lifecycle ?? new LocalLifecycleAuthority(now); return new ReferenceRpRepository({ audience: AUDIENCE, - lifecycle: lifecycle ?? new LocalLifecycleAuthority(now), + lifecycle: authority, + deviceLifecycle: authority, now, seed: false, }); @@ -382,3 +413,24 @@ async function createProof( signature: (await signProtocolPayload(payload, identity.privateKey, provider)) as Base64Url64, }; } + +function v2OnlyContextHash(): string { + return canonicalSha256({ + action: 'session.start', + policy: { + actions: [ + 'note.create', + 'note.edit', + 'note.delete', + 'reply.create', + 'reply.delete', + 'note.like', + 'note.unlike', + 'profile.set-name', + ], + resourcePolicy: 'public-notes-and-private-notes-owned-by-session-subject', + ttlSeconds: 300, + }, + acceptedProofProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2], + }); +} diff --git a/apps/reference-rp/src/backend/repository.ts b/apps/reference-rp/src/backend/repository.ts index 2edaed4..ba07e54 100644 --- a/apps/reference-rp/src/backend/repository.ts +++ b/apps/reference-rp/src/backend/repository.ts @@ -1,15 +1,25 @@ import type { Base64Url32, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, NexusSubject, - OwnershipProofV1, VerificationExpectation, + VerificationExpectationV2, } from '@nexus/protocol'; -import { audienceOriginSchema } from '@nexus/protocol'; -import { verifyRpOperation } from '@nexus/verifier'; -import type { LifecycleProvider } from '@nexus/verifier'; +import { + OWNERSHIP_PROOF_PROTOCOL_V1, + OWNERSHIP_PROOF_PROTOCOL_V2, + audienceOriginSchema, + ownershipProofV1Schema, + ownershipProofV2Schema, +} from '@nexus/protocol'; +import { verifyRpOperation, verifyRpOperationV2 } from '@nexus/verifier'; +import type { DeviceLifecycleProvider, LifecycleProvider } from '@nexus/verifier'; import type { ApplicationReceipt, + AcceptedProofProtocol, + AcceptedProofProtocols, AuthorizationMethod, IssuedChallenge, NoteDraft, @@ -44,6 +54,10 @@ const SESSION_POLICY = Object.freeze({ resourcePolicy: 'public-notes-and-private-notes-owned-by-session-subject', ttlSeconds: SESSION_TTL_SECONDS, }); +const ACCEPTED_PROOF_PROTOCOLS: AcceptedProofProtocols = Object.freeze([ + OWNERSHIP_PROOF_PROTOCOL_V2, + OWNERSHIP_PROOF_PROTOCOL_V1, +]); interface StoredNote { readonly id: string; @@ -72,6 +86,9 @@ interface StoredSession { readonly subject: NexusSubject; readonly expiresAt: number; readonly proofHash: string; + readonly proofProtocol?: AcceptedProofProtocol; + readonly deviceId?: NexusDeviceIdV2; + readonly authorizationId?: NexusDeviceAuthorizationIdV2; } interface StoredProfile { @@ -94,12 +111,14 @@ export interface StartedLocalSession { export interface ReferenceRpRepositoryOptions { audience: string; lifecycle: LifecycleProvider; + deviceLifecycle?: DeviceLifecycleProvider; now?: () => number; seed?: boolean; } export class ReferenceRpRepository { public readonly lifecycle: LifecycleProvider; + public readonly deviceLifecycle: DeviceLifecycleProvider | undefined; public readonly challenges = new HashOnlyChallengeStore(); readonly #audience: string; @@ -120,6 +139,7 @@ export class ReferenceRpRepository { this.#audience = audience.data; this.#now = options.now ?? (() => Math.floor(Date.now() / 1000)); this.lifecycle = options.lifecycle; + this.deviceLifecycle = options.deviceLifecycle; if (options.seed !== false) this.#seedNotes(); } @@ -139,7 +159,11 @@ export class ReferenceRpRepository { public issueChallenge(value: unknown): IssuedChallenge { const operation = parseStartSessionOperation(value); const now = this.#now(); - const contextHash = canonicalSha256({ action: operation.action, policy: SESSION_POLICY }); + const contextHash = canonicalSha256({ + action: operation.action, + policy: SESSION_POLICY, + acceptedProofProtocols: ACCEPTED_PROOF_PROTOCOLS, + }); const challenge: IssuedChallenge = { challengeId: secureToken(16), action: operation.action, @@ -147,6 +171,7 @@ export class ReferenceRpRepository { nonce: asNonce(secureToken(32)), expiresAt: now + CHALLENGE_TTL_SECONDS, contextHash: contextHash as Base64Url32, + acceptedProofProtocols: ACCEPTED_PROOF_PROTOCOLS, }; this.challenges.insert(challenge); return challenge; @@ -206,7 +231,12 @@ export class ReferenceRpRepository { ); } - const contextHash = canonicalSha256({ action: input.operation.action, policy: SESSION_POLICY }); + const acceptedProofProtocols = challenge.acceptedProofProtocols; + const contextHash = canonicalSha256({ + action: input.operation.action, + policy: SESSION_POLICY, + acceptedProofProtocols, + }); if ( challenge.action !== input.operation.action || challenge.resource !== SESSION_RESOURCE || @@ -214,6 +244,13 @@ export class ReferenceRpRepository { ) { throw new RpError('CHALLENGE_MISMATCH', 'The session policy has changed.', 409); } + if (!acceptedProofProtocols.includes(input.proofProtocol)) { + throw new RpError( + 'UNSUPPORTED_PROOF_PROTOCOL', + 'This challenge did not authorize the submitted proof protocol.', + 403, + ); + } const payload = input.proof.payload; if ( sha256Base64Url(payload.nonce) !== challenge.nonceHash || @@ -235,22 +272,52 @@ export class ReferenceRpRepository { now: this.#now(), maxClockSkewSeconds: MAX_CLOCK_SKEW_SECONDS, }; - const verified = await verifyRpOperation( - input.proof, - expected, - this.challenges, - this.lifecycle, - ); + let subject: NexusSubject; + let deviceBinding: + | { + proofProtocol: typeof OWNERSHIP_PROOF_PROTOCOL_V2; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + } + | undefined; + if (input.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) { + const expectedV2: VerificationExpectationV2 = { + ...expected, + contextHash: challenge.contextHash as Base64Url32, + }; + const verified = await verifyRpOperationV2( + input.proof, + expectedV2, + this.challenges, + this.lifecycle, + this.#requireDeviceLifecycle(), + ); + subject = verified.subject; + deviceBinding = { + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + deviceId: verified.deviceId, + authorizationId: verified.authorizationId, + }; + } else { + const verified = await verifyRpOperation( + input.proof, + expected, + this.challenges, + this.lifecycle, + ); + subject = verified.subject; + } const proofHash = canonicalSha256(input.proof); const token = secureToken(32); const stored: StoredSession = { tokenHash: sha256Base64Url(token), - subject: verified.subject, + subject, expiresAt: this.#now() + SESSION_TTL_SECONDS, proofHash, + ...(deviceBinding ?? {}), }; this.#sessions.set(stored.tokenHash, stored); - const lifecycle = await this.lifecycle.getAuthoritativeStatus(verified.subject); + const lifecycle = await this.lifecycle.getAuthoritativeStatus(subject); if (lifecycle.state !== 'active') { this.#sessions.delete(stored.tokenHash); throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401); @@ -258,7 +325,7 @@ export class ReferenceRpRepository { const receipt = this.#recordReceipt({ operation: 'session.start', resource: SESSION_RESOURCE, - subject: verified.subject, + subject, authorization: 'wallet-proof', proofHash, resultingVersion: null, @@ -268,12 +335,19 @@ export class ReferenceRpRepository { result: { receipt, session: { - subject: verified.subject, - friendlyName: this.#profiles.get(verified.subject)?.friendlyName ?? null, + subject, + friendlyName: this.#profiles.get(subject)?.friendlyName ?? null, state: 'active', sequence: lifecycle.sequence, expiresAt: stored.expiresAt, checkedAt: this.#now(), + ...(stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 + ? { + proofProtocol: stored.proofProtocol, + deviceId: stored.deviceId, + authorizationId: stored.authorizationId, + } + : {}), }, }, }; @@ -292,6 +366,24 @@ export class ReferenceRpRepository { if (lifecycle.state !== 'active') { throw new RpError('SESSION_INVALID', 'The identity is no longer active.', 401); } + if ( + stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 && + stored.deviceId !== undefined && + stored.authorizationId !== undefined + ) { + const device = await this.#requireDeviceLifecycle().getAuthoritativeDeviceStatus( + stored.subject, + stored.deviceId, + stored.authorizationId, + ); + if ( + device.state !== 'active' || + device.identityState !== 'active' || + (device.authorizationExpiresAt !== undefined && now >= device.authorizationExpiresAt) + ) { + throw new RpError('SESSION_INVALID', 'The Nexus device is no longer active.', 401); + } + } return { stored, status: { @@ -301,10 +393,28 @@ export class ReferenceRpRepository { sequence: lifecycle.sequence, expiresAt: stored.expiresAt, checkedAt: now, + ...(stored.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 + ? { + proofProtocol: stored.proofProtocol, + deviceId: stored.deviceId, + authorizationId: stored.authorizationId, + } + : {}), }, }; } + #requireDeviceLifecycle(): DeviceLifecycleProvider { + if (this.deviceLifecycle === undefined) { + throw new RpError( + 'SERVICE_UNAVAILABLE', + 'Authoritative Nexus device status is unavailable.', + 503, + ); + } + return this.deviceLifecycle; + } + async #optionalSubject(token?: string): Promise { if (token === undefined || token.length === 0) return undefined; try { @@ -582,16 +692,32 @@ export class ReferenceRpRepository { } function parseSubmitProof(value: unknown): SubmitProofInput { - const record = requireRecord(value, ['challengeId', 'operation', 'proof']); + if (!isRecord(value)) throw new RpError('BAD_REQUEST', 'Request body must be an object.', 400); + const legacyV1 = !Object.hasOwn(value, 'proofProtocol'); + const record = requireRecord( + value, + legacyV1 + ? ['challengeId', 'operation', 'proof'] + : ['challengeId', 'operation', 'proof', 'proofProtocol'], + ); if (typeof record['challengeId'] !== 'string' || record['challengeId'].length < 20) { throw new RpError('BAD_REQUEST', 'challengeId is invalid.', 400); } - if (!isRecord(record['proof'])) throw new RpError('BAD_REQUEST', 'proof is required.', 400); - return { + const common = { challengeId: record['challengeId'], operation: parseStartSessionOperation(record['operation']), - proof: record['proof'] as unknown as OwnershipProofV1, }; + if (record['proofProtocol'] === OWNERSHIP_PROOF_PROTOCOL_V1 || legacyV1) { + const proof = ownershipProofV1Schema.safeParse(record['proof']); + if (!proof.success) throw new RpError('BAD_REQUEST', 'proof is invalid.', 400); + return { ...common, proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1, proof: proof.data }; + } + if (record['proofProtocol'] === OWNERSHIP_PROOF_PROTOCOL_V2) { + const proof = ownershipProofV2Schema.safeParse(record['proof']); + if (!proof.success) throw new RpError('BAD_REQUEST', 'proof is invalid.', 400); + return { ...common, proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, proof: proof.data }; + } + throw new RpError('BAD_REQUEST', 'proofProtocol is not supported.', 400); } function parseStartSessionOperation(value: unknown): StartSessionOperation { diff --git a/apps/reference-rp/src/backend/vite-plugin.ts b/apps/reference-rp/src/backend/vite-plugin.ts index 30c1827..280f8b9 100644 --- a/apps/reference-rp/src/backend/vite-plugin.ts +++ b/apps/reference-rp/src/backend/vite-plugin.ts @@ -22,6 +22,7 @@ const SECURITY_HEADERS: Readonly> = { export interface ReferenceRpApiPluginOptions { audience: string; nexusApiUrl: string; + serviceKeyset: unknown; unsafeLocalLifecycle?: boolean; } @@ -29,8 +30,12 @@ export function createReferenceRpApiPlugin(options: ReferenceRpApiPluginOptions) const lifecycle = options.unsafeLocalLifecycle === true ? new LocalLifecycleAuthority() - : new AuthoritativeRegistryLifecycleProvider(options.nexusApiUrl); - const repository = new ReferenceRpRepository({ audience: options.audience, lifecycle }); + : new AuthoritativeRegistryLifecycleProvider(options.nexusApiUrl, options.serviceKeyset); + const repository = new ReferenceRpRepository({ + audience: options.audience, + lifecycle, + deviceLifecycle: lifecycle, + }); const attach = (server: ViteDevServer | PreviewServer): void => { server.middlewares.use((request, response, next) => { for (const [name, value] of Object.entries(SECURITY_HEADERS)) response.setHeader(name, value); diff --git a/apps/reference-rp/src/shared/contracts.ts b/apps/reference-rp/src/shared/contracts.ts index 96b3830..dcc37a7 100644 --- a/apps/reference-rp/src/shared/contracts.ts +++ b/apps/reference-rp/src/shared/contracts.ts @@ -1,4 +1,11 @@ -import type { NexusSubject, OwnershipProofV1, ProofRequest } from '@nexus/protocol'; +import type { + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, + NexusSubject, + OwnershipProofV1, + OwnershipProofV2, + ProofRequest, +} from '@nexus/protocol'; export type NoteVisibility = 'public' | 'private'; export type AuthorizationMethod = 'wallet-proof' | 'rp-session'; @@ -14,6 +21,8 @@ export type SessionAction = | 'note.unlike' | 'profile.set-name'; export type NotesAction = ProofAction | SessionAction; +export type AcceptedProofProtocol = 'nexus.ownership-proof.v2' | 'nexus.ownership-proof.v1'; +export type AcceptedProofProtocols = readonly [AcceptedProofProtocol, ...AcceptedProofProtocol[]]; export interface NoteDraft { title: string; @@ -55,14 +64,25 @@ export interface StartSessionOperation { export interface IssuedChallenge extends ProofRequest { challengeId: string; expiresAt: number; + /** Backend-authoritative proof negotiation, ordered by RP preference. */ + acceptedProofProtocols: AcceptedProofProtocols; } -export interface SubmitProofInput { +interface SubmitProofInputBase { challengeId: string; - proof: OwnershipProofV1; operation: StartSessionOperation; } +export type SubmitProofInput = + | (SubmitProofInputBase & { + proofProtocol: 'nexus.ownership-proof.v1'; + proof: OwnershipProofV1; + }) + | (SubmitProofInputBase & { + proofProtocol: 'nexus.ownership-proof.v2'; + proof: OwnershipProofV2; + }); + export type SessionOperationInput = | { action: 'note.create'; draft: NoteDraft } | { action: 'note.edit'; noteId: string; expectedVersion: number; draft: NoteDraft } @@ -91,6 +111,9 @@ export interface SessionStatus { sequence: number; expiresAt: number; checkedAt: number; + proofProtocol?: AcceptedProofProtocol | undefined; + deviceId?: NexusDeviceIdV2 | undefined; + authorizationId?: NexusDeviceAuthorizationIdV2 | undefined; } export interface SessionStartResult { diff --git a/apps/reference-rp/vite.config.ts b/apps/reference-rp/vite.config.ts index e66157e..b23fd5c 100644 --- a/apps/reference-rp/vite.config.ts +++ b/apps/reference-rp/vite.config.ts @@ -5,6 +5,18 @@ import { defineConfig } from 'vite'; import { createReferenceRpApiPlugin } from './src/backend/vite-plugin.js'; +const DEFAULT_SERVICE_JWKS_JSON = + '{"keys":[{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"rowo-status-2026-08","x":"p_0wFlB-khNLDc_66RYGHG98JY_lrhXtzj5tNEuy4SE","use":"sig"}]}'; + +function serviceKeyset(): unknown { + const raw = process.env['NEXUS_SERVICE_JWKS_JSON'] ?? DEFAULT_SERVICE_JWKS_JSON; + try { + return JSON.parse(raw) as unknown; + } catch (error) { + throw new TypeError('NEXUS_SERVICE_JWKS_JSON must contain valid JSON.', { cause: error }); + } +} + export default defineConfig(({ command }) => ({ plugins: [ ...(command === 'serve' ? [basicSsl()] : []), @@ -16,6 +28,7 @@ export default defineConfig(({ command }) => ({ process.env['NEXUS_API_URL'] ?? process.env['VITE_NEXUS_API_URL'] ?? 'https://nexus.rowo.link', + serviceKeyset: serviceKeyset(), unsafeLocalLifecycle: process.env['NEXUS_LOCAL_LIFECYCLE_MODE'] === 'unsafe-local-test', }), ], diff --git a/apps/reference-rp/worker/errors.ts b/apps/reference-rp/worker/errors.ts index 16dd964..437dd29 100644 --- a/apps/reference-rp/worker/errors.ts +++ b/apps/reference-rp/worker/errors.ts @@ -12,6 +12,8 @@ export type RpWorkerErrorCode = | 'SESSION_INVALID' | 'IDENTITY_NOT_FOUND' | 'IDENTITY_REVOKED' + | 'DEVICE_INACTIVE' + | 'UNSUPPORTED_PROOF_PROTOCOL' | 'RATE_LIMITED' | 'SERVICE_UNAVAILABLE' | 'INTERNAL_ERROR'; diff --git a/apps/reference-rp/worker/lifecycle.ts b/apps/reference-rp/worker/lifecycle.ts index f24faf5..bbb54fb 100644 --- a/apps/reference-rp/worker/lifecycle.ts +++ b/apps/reference-rp/worker/lifecycle.ts @@ -1,15 +1,151 @@ -import { audienceOriginSchema, registryStatusV1Schema } from '@nexus/protocol'; -import type { NexusSubject } from '@nexus/protocol'; +import { + audienceOriginSchema, + deviceRegistryStatusV2Schema, + registryStatusV1Schema, + serviceKeySetSchema, +} from '@nexus/protocol'; +import type { + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, + NexusSubject, + ServiceKeySet, +} from '@nexus/protocol'; +import { verifyDeviceStatusStatement } from '@nexus/verifier'; import type { AuthoritativeLifecycleState } from '@nexus/verifier'; import { RpWorkerError } from './errors'; const STATUS_PATH = '/v1/identity/status'; +const DEVICE_STATUS_PATH = '/v2/device/status'; const STATUS_TIMEOUT_MS = 5_000; export interface LifecycleEnv { readonly NEXUS_API?: Fetcher; readonly NEXUS_API_ORIGIN?: string; + readonly SERVICE_JWKS_JSON: string; +} + +let cachedServiceKeyset: { readonly raw: string; readonly value: ServiceKeySet } | undefined; + +export interface AuthoritativeDeviceStatusV2 { + readonly identityState: 'active' | 'revoked'; + readonly identitySequence: number; + readonly deviceLedgerSequence: number; + readonly deviceState: 'active' | 'revoked' | 'expired' | 'unknown'; + readonly authorizationExpiresAt?: number | undefined; + readonly activatedAt?: number | undefined; + readonly revokedAt?: number | undefined; +} + +export async function getAuthoritativeDeviceStatus( + env: LifecycleEnv, + subject: NexusSubject, + deviceId: NexusDeviceIdV2, + authorizationId: NexusDeviceAuthorizationIdV2, +): Promise { + const binding = env.NEXUS_API; + const origin = binding === undefined ? validatedOrigin(env.NEXUS_API_ORIGIN) : null; + const endpoint = new URL(DEVICE_STATUS_PATH, origin ?? 'https://nexus-api.internal'); + let response: Response; + try { + const request = new Request(endpoint, { + method: 'POST', + headers: { + Accept: 'application/nexus+json, application/json', + 'Content-Type': 'application/nexus+json', + }, + body: JSON.stringify({ subject, deviceId, authorizationId }), + redirect: 'manual', + ...(binding === undefined ? { signal: AbortSignal.timeout(STATUS_TIMEOUT_MS) } : {}), + }); + response = binding === undefined ? await fetch(request) : await binding.fetch(request); + } catch (error) { + console.error( + JSON.stringify({ + event: 'reference_rp_device_status_fetch_failed', + errorType: error instanceof Error ? error.name : 'UnknownError', + }), + ); + throw deviceStatusUnavailable(); + } + if (!response.ok) throw deviceStatusUnavailable(); + let body: unknown; + try { + body = await response.json(); + } catch { + throw deviceStatusUnavailable(); + } + const status = deviceRegistryStatusV2Schema.safeParse(body); + if ( + !status.success || + status.data.subject !== subject || + status.data.deviceId !== deviceId || + status.data.authorizationId !== authorizationId + ) { + throw deviceStatusUnavailable(); + } + const now = Math.floor(Date.now() / 1_000); + try { + await verifyDeviceStatusStatement( + status.data.statusStatement, + serviceKeyset(env.SERVICE_JWKS_JSON), + now, + { + subject, + genesisHash: status.data.genesisHash, + deviceId, + authorizationId, + identityState: status.data.identityState, + identitySequence: status.data.identitySequence, + deviceLedgerSequence: status.data.deviceLedgerSequence, + deviceState: status.data.deviceState, + ...(status.data.authorizationExpiresAt === null + ? {} + : { authorizationExpiresAt: status.data.authorizationExpiresAt }), + }, + ); + } catch { + throw deviceStatusUnavailable(); + } + const payload = status.data.statusStatement.payload; + return { + identityState: payload.identityState, + identitySequence: payload.identitySequence, + deviceLedgerSequence: payload.deviceLedgerSequence, + deviceState: + payload.deviceState === 'active' && + payload.authorizationExpiresAt !== undefined && + now >= payload.authorizationExpiresAt + ? 'expired' + : payload.deviceState, + ...(payload.authorizationExpiresAt === undefined + ? {} + : { authorizationExpiresAt: payload.authorizationExpiresAt }), + ...(payload.activatedAt === undefined ? {} : { activatedAt: payload.activatedAt }), + ...(payload.revokedAt === undefined ? {} : { revokedAt: payload.revokedAt }), + }; +} + +function serviceKeyset(raw: string): ServiceKeySet { + if (cachedServiceKeyset?.raw === raw) return cachedServiceKeyset.value; + let value: unknown; + try { + value = JSON.parse(raw) as unknown; + } catch { + throw deviceStatusUnavailable(); + } + const parsed = serviceKeySetSchema.safeParse(value); + if (!parsed.success) throw deviceStatusUnavailable(); + cachedServiceKeyset = { raw, value: parsed.data }; + return parsed.data; +} + +function deviceStatusUnavailable(): RpWorkerError { + return new RpWorkerError( + 'SERVICE_UNAVAILABLE', + 'Authoritative Nexus device status is unavailable.', + 503, + ); } export async function getAuthoritativeLifecycle( diff --git a/apps/reference-rp/worker/reference-rp-state.ts b/apps/reference-rp/worker/reference-rp-state.ts index 7ebf629..850d5f6 100644 --- a/apps/reference-rp/worker/reference-rp-state.ts +++ b/apps/reference-rp/worker/reference-rp-state.ts @@ -1,11 +1,24 @@ import { DurableObject } from 'cloudflare:workers'; -import { audienceOriginSchema } from '@nexus/protocol'; -import type { Base64Url32, NexusSubject, VerificationExpectation } from '@nexus/protocol'; -import { verifyOwnershipProof } from '@nexus/verifier'; +import { + OWNERSHIP_PROOF_PROTOCOL_V1, + OWNERSHIP_PROOF_PROTOCOL_V2, + audienceOriginSchema, +} from '@nexus/protocol'; +import type { + Base64Url32, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, + NexusSubject, + VerificationExpectation, + VerificationExpectationV2, +} from '@nexus/protocol'; +import { verifyOwnershipProof, verifyOwnershipProofV2 } from '@nexus/verifier'; import type { ApplicationReceipt, + AcceptedProofProtocol, + AcceptedProofProtocols, AuthorizationMethod, IssuedChallenge, NoteVisibility, @@ -20,7 +33,11 @@ import type { } from '../src/shared/contracts'; import { canonicalSha256, constantTimeTextEqual, secureToken, sha256Base64Url } from './crypto'; import { RpWorkerError, toSafeWorkerError } from './errors'; -import { getAuthoritativeLifecycle, type LifecycleEnv } from './lifecycle'; +import { + getAuthoritativeDeviceStatus, + getAuthoritativeLifecycle, + type LifecycleEnv, +} from './lifecycle'; import { parseNoteId, parseSessionOperation, @@ -50,6 +67,10 @@ const SESSION_POLICY = Object.freeze({ resourcePolicy: 'public-notes-and-private-notes-owned-by-session-subject', ttlSeconds: SESSION_TTL_SECONDS, }); +const ACCEPTED_PROOF_PROTOCOLS: AcceptedProofProtocols = Object.freeze([ + OWNERSHIP_PROOF_PROTOCOL_V2, + OWNERSHIP_PROOF_PROTOCOL_V1, +]); export interface ReferenceRpEnv extends LifecycleEnv { readonly RP_AUDIENCE: string; @@ -63,6 +84,7 @@ interface ChallengeRow extends Record { context_hash: string; expires_at: number; consumed_at: number | null; + accepted_proof_protocols: string; } interface NoteRow extends Record { @@ -84,6 +106,9 @@ interface SessionRow extends Record { subject: string; expires_at: number; proof_hash: string; + proof_protocol: string | null; + device_id: string | null; + authorization_id: string | null; } interface ReplyRow extends Record { @@ -112,6 +137,13 @@ interface ActiveSession { readonly status: SessionStatus; } +interface VerifiedSessionSigner { + readonly subject: NexusSubject; + readonly proofProtocol: AcceptedProofProtocol; + readonly deviceId?: NexusDeviceIdV2 | undefined; + readonly authorizationId?: NexusDeviceAuthorizationIdV2 | undefined; +} + export class ReferenceRpState extends DurableObject { public constructor(ctx: DurableObjectState, env: ReferenceRpEnv) { super(ctx, env); @@ -176,7 +208,11 @@ export class ReferenceRpState extends DurableObject { async #issueChallenge(operation: StartSessionOperation): Promise { const now = nowSeconds(); - const contextHash = await canonicalSha256({ action: operation.action, policy: SESSION_POLICY }); + const contextHash = await canonicalSha256({ + action: operation.action, + policy: SESSION_POLICY, + acceptedProofProtocols: ACCEPTED_PROOF_PROTOCOLS, + }); const nonce = secureToken(32); const challenge: IssuedChallenge = { challengeId: secureToken(16), @@ -185,6 +221,7 @@ export class ReferenceRpState extends DurableObject { nonce: nonce as IssuedChallenge['nonce'], expiresAt: now + CHALLENGE_TTL_SECONDS, contextHash: contextHash as Base64Url32, + acceptedProofProtocols: ACCEPTED_PROOF_PROTOCOLS, }; const nonceHash = await sha256Base64Url(nonce); @@ -209,14 +246,16 @@ export class ReferenceRpState extends DurableObject { } this.ctx.storage.sql.exec( `INSERT INTO challenges - (challenge_id, nonce_hash, action, resource, context_hash, expires_at, consumed_at) - VALUES (?, ?, ?, ?, ?, ?, NULL)`, + (challenge_id, nonce_hash, action, resource, context_hash, expires_at, consumed_at, + accepted_proof_protocols) + VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, challenge.challengeId, nonceHash, challenge.action, challenge.resource, contextHash, challenge.expiresAt, + JSON.stringify(challenge.acceptedProofProtocols), ); }); return challenge; @@ -226,9 +265,11 @@ export class ReferenceRpState extends DurableObject { const initial = this.#readChallenge(input.challengeId); const initialNow = nowSeconds(); this.#assertChallengeUsable(initial, initialNow); + const acceptedProofProtocols = parseStoredProofProtocols(initial.accepted_proof_protocols); const contextHash = await canonicalSha256({ action: input.operation.action, policy: SESSION_POLICY, + acceptedProofProtocols, }); if ( initial.action !== input.operation.action || @@ -241,6 +282,13 @@ export class ReferenceRpState extends DurableObject { 409, ); } + if (!acceptedProofProtocols.includes(input.proofProtocol)) { + throw new RpWorkerError( + 'UNSUPPORTED_PROOF_PROTOCOL', + 'This challenge did not authorize the submitted proof protocol.', + 403, + ); + } const payload = input.proof.payload; const nonceHash = await sha256Base64Url(payload.nonce); @@ -265,8 +313,27 @@ export class ReferenceRpState extends DurableObject { now: initialNow, maxClockSkewSeconds: MAX_CLOCK_SKEW_SECONDS, }; - const verified = await verifyOwnershipProof(input.proof, expected); - const lifecycle = await getAuthoritativeLifecycle(this.env, verified.subject); + let normalizedVerified: VerifiedSessionSigner; + if (input.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2) { + const expectedV2: VerificationExpectationV2 = { + ...expected, + contextHash: initial.context_hash as Base64Url32, + }; + const verified = await verifyOwnershipProofV2(input.proof, expectedV2); + normalizedVerified = { + subject: verified.subject, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + deviceId: verified.deviceId, + authorizationId: verified.authorizationId, + }; + } else { + const verified = await verifyOwnershipProof(input.proof, expected); + normalizedVerified = { + subject: verified.subject, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1, + }; + } + const lifecycle = await getAuthoritativeLifecycle(this.env, normalizedVerified.subject); if (lifecycle.state === 'not-found') { throw new RpWorkerError( 'IDENTITY_NOT_FOUND', @@ -281,6 +348,25 @@ export class ReferenceRpState extends DurableObject { 403, ); } + if ( + normalizedVerified.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 && + normalizedVerified.deviceId !== undefined && + normalizedVerified.authorizationId !== undefined + ) { + const device = await getAuthoritativeDeviceStatus( + this.env, + normalizedVerified.subject, + normalizedVerified.deviceId, + normalizedVerified.authorizationId, + ); + if (device.identityState !== 'active' || device.deviceState !== 'active') { + throw new RpWorkerError( + 'DEVICE_INACTIVE', + 'This Nexus device is not active and cannot start a session.', + 403, + ); + } + } const proofHash = await canonicalSha256(input.proof); const token = secureToken(32); @@ -289,7 +375,14 @@ export class ReferenceRpState extends DurableObject { return this.#commitStartedSession({ input, expectedChallenge: initial, - subject: verified.subject, + subject: normalizedVerified.subject, + proofProtocol: normalizedVerified.proofProtocol, + ...(normalizedVerified.deviceId === undefined + ? {} + : { deviceId: normalizedVerified.deviceId }), + ...(normalizedVerified.authorizationId === undefined + ? {} + : { authorizationId: normalizedVerified.authorizationId }), proofHash, token, tokenHash, @@ -307,6 +400,9 @@ export class ReferenceRpState extends DurableObject { tokenHash: string; receiptId: string; sequence: number; + proofProtocol: AcceptedProofProtocol; + deviceId?: NexusDeviceIdV2 | undefined; + authorizationId?: NexusDeviceAuthorizationIdV2 | undefined; }): StartedSession { return this.ctx.storage.transactionSync(() => { const now = nowSeconds(); @@ -316,7 +412,9 @@ export class ReferenceRpState extends DurableObject { currentChallenge.nonce_hash !== prepared.expectedChallenge.nonce_hash || currentChallenge.action !== prepared.expectedChallenge.action || currentChallenge.resource !== prepared.expectedChallenge.resource || - currentChallenge.context_hash !== prepared.expectedChallenge.context_hash + currentChallenge.context_hash !== prepared.expectedChallenge.context_hash || + currentChallenge.accepted_proof_protocols !== + prepared.expectedChallenge.accepted_proof_protocols ) { throw new RpWorkerError( 'CHALLENGE_MISMATCH', @@ -362,15 +460,26 @@ export class ReferenceRpState extends DurableObject { sequence: prepared.sequence, expiresAt: now + SESSION_TTL_SECONDS, checkedAt: now, + ...(prepared.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 + ? { + proofProtocol: prepared.proofProtocol, + deviceId: prepared.deviceId, + authorizationId: prepared.authorizationId, + } + : {}), }; this.ctx.storage.sql.exec('DELETE FROM sessions WHERE expires_at <= ?', now); this.ctx.storage.sql.exec( - `INSERT INTO sessions (token_hash, subject, expires_at, proof_hash) - VALUES (?, ?, ?, ?)`, + `INSERT INTO sessions + (token_hash, subject, expires_at, proof_hash, proof_protocol, device_id, authorization_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, prepared.tokenHash, prepared.subject, session.expiresAt, prepared.proofHash, + prepared.proofProtocol === OWNERSHIP_PROOF_PROTOCOL_V2 ? prepared.proofProtocol : null, + prepared.deviceId ?? null, + prepared.authorizationId ?? null, ); return { token: prepared.token, result: { receipt, session } }; }); @@ -383,7 +492,9 @@ export class ReferenceRpState extends DurableObject { const tokenHash = await sha256Base64Url(token); const row = this.ctx.storage.sql .exec( - 'SELECT token_hash, subject, expires_at, proof_hash FROM sessions WHERE token_hash = ?', + `SELECT token_hash, subject, expires_at, proof_hash, proof_protocol, device_id, + authorization_id + FROM sessions WHERE token_hash = ?`, tokenHash, ) .toArray()[0]; @@ -399,6 +510,29 @@ export class ReferenceRpState extends DurableObject { if (lifecycle.state !== 'active') { throw new RpWorkerError('SESSION_INVALID', 'The Nexus identity is revoked.', 401); } + const v2Session = row.proof_protocol === OWNERSHIP_PROOF_PROTOCOL_V2; + if ( + v2Session && + (row.device_id === null || + row.authorization_id === null || + !row.device_id.startsWith('nxd2_') || + !row.authorization_id.startsWith('nxa2_')) + ) { + throw new RpWorkerError('SESSION_INVALID', 'The RP session device binding is invalid.', 401); + } + if (v2Session && row.device_id !== null && row.authorization_id !== null) { + const deviceId = row.device_id as NexusDeviceIdV2; + const authorizationId = row.authorization_id as NexusDeviceAuthorizationIdV2; + const device = await getAuthoritativeDeviceStatus( + this.env, + subject, + deviceId, + authorizationId, + ); + if (device.identityState !== 'active' || device.deviceState !== 'active') { + throw new RpWorkerError('SESSION_INVALID', 'The Nexus device is no longer active.', 401); + } + } return { tokenHash, proofHash: row.proof_hash, @@ -409,6 +543,13 @@ export class ReferenceRpState extends DurableObject { sequence: lifecycle.sequence, expiresAt: row.expires_at, checkedAt: now, + ...(v2Session + ? { + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + deviceId: row.device_id as NexusDeviceIdV2, + authorizationId: row.authorization_id as NexusDeviceAuthorizationIdV2, + } + : {}), }, }; } @@ -783,7 +924,8 @@ export class ReferenceRpState extends DurableObject { #readChallenge(challengeId: string): ChallengeRow { const challenge = this.ctx.storage.sql .exec( - `SELECT challenge_id, nonce_hash, action, resource, context_hash, expires_at, consumed_at + `SELECT challenge_id, nonce_hash, action, resource, context_hash, expires_at, consumed_at, + accepted_proof_protocols FROM challenges WHERE challenge_id = ?`, challengeId, ) @@ -815,7 +957,7 @@ export class ReferenceRpState extends DurableObject { version INTEGER NOT NULL, seeded INTEGER NOT NULL CHECK (seeded IN (0, 1)) ); - INSERT OR IGNORE INTO schema_meta (singleton, version, seeded) VALUES (1, 3, 0); + INSERT OR IGNORE INTO schema_meta (singleton, version, seeded) VALUES (1, 4, 0); CREATE TABLE IF NOT EXISTS challenges ( challenge_id TEXT PRIMARY KEY, nonce_hash TEXT NOT NULL UNIQUE, @@ -823,7 +965,9 @@ export class ReferenceRpState extends DurableObject { resource TEXT NOT NULL, context_hash TEXT NOT NULL, expires_at INTEGER NOT NULL, - consumed_at INTEGER + consumed_at INTEGER, + accepted_proof_protocols TEXT NOT NULL + DEFAULT '["nexus.ownership-proof.v1"]' ); CREATE INDEX IF NOT EXISTS challenges_expiry ON challenges (expires_at); CREATE TABLE IF NOT EXISTS notes ( @@ -849,7 +993,15 @@ export class ReferenceRpState extends DurableObject { token_hash TEXT PRIMARY KEY, subject TEXT NOT NULL, expires_at INTEGER NOT NULL, - proof_hash TEXT NOT NULL + proof_hash TEXT NOT NULL, + proof_protocol TEXT, + device_id TEXT, + authorization_id TEXT, + CHECK ( + (device_id IS NULL AND authorization_id IS NULL) OR + (proof_protocol = 'nexus.ownership-proof.v2' AND + device_id IS NOT NULL AND authorization_id IS NOT NULL) + ) ); CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions (expires_at); CREATE TABLE IF NOT EXISTS receipts ( @@ -911,7 +1063,18 @@ export class ReferenceRpState extends DurableObject { this.ctx.storage.sql.exec('UPDATE schema_meta SET version = 3 WHERE singleton = 1'); version = 3; } - if (version !== 3) throw new Error('Reference RP schema version is unsupported.'); + if (version === 3) { + this.ctx.storage.sql.exec(` + ALTER TABLE challenges ADD COLUMN accepted_proof_protocols TEXT NOT NULL + DEFAULT '["nexus.ownership-proof.v1"]'; + ALTER TABLE sessions ADD COLUMN proof_protocol TEXT; + ALTER TABLE sessions ADD COLUMN device_id TEXT; + ALTER TABLE sessions ADD COLUMN authorization_id TEXT; + UPDATE schema_meta SET version = 4 WHERE singleton = 1; + `); + version = 4; + } + if (version !== 4) throw new Error('Reference RP schema version is unsupported.'); } #seedNotes(): void { @@ -1014,6 +1177,28 @@ function clearSessionCookie(): string { return `${SESSION_COOKIE_NAME}=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict`; } +function parseStoredProofProtocols(value: string): AcceptedProofProtocols { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error('Stored challenge proof protocols are invalid.'); + } + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + parsed.length > 2 || + parsed.some( + (protocol) => + protocol !== OWNERSHIP_PROOF_PROTOCOL_V1 && protocol !== OWNERSHIP_PROOF_PROTOCOL_V2, + ) || + new Set(parsed).size !== parsed.length + ) { + throw new Error('Stored challenge proof protocols are invalid.'); + } + return parsed as unknown as AcceptedProofProtocols; +} + function assertSessionMutationRequest(request: Request): void { if (request.headers.get('X-Nexus-Notes-Session') !== '1') { throw new RpWorkerError('BAD_REQUEST', 'The session request marker is missing.', 400); diff --git a/apps/reference-rp/worker/reference-rp.worker.test.ts b/apps/reference-rp/worker/reference-rp.worker.test.ts index 763ec0e..fd81a0f 100644 --- a/apps/reference-rp/worker/reference-rp.worker.test.ts +++ b/apps/reference-rp/worker/reference-rp.worker.test.ts @@ -8,23 +8,32 @@ import { import { computeRevocationCommitment, + deriveDeviceAuthorizationIdV2, + deriveDeviceIdV2, + deriveGenesisHash, deriveSubject, signProtocolPayload, WebCryptoProvider, } from '@nexus/crypto'; import { ED25519_ALGORITHM, + DEVICE_AUTHORIZATION_PROTOCOL_V2, IDENTITY_PROTOCOL_V1, NEXUS_SUITE_V1, OWNERSHIP_PROOF_PROTOCOL_V1, + OWNERSHIP_PROOF_PROTOCOL_V2, encodeBase64Url, } from '@nexus/protocol'; import type { Base64Url32, Base64Url64, IdentityGenesisV1, + DeviceAuthorizationV2, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, NexusSubject, OwnershipProofV1, + OwnershipProofV2, } from '@nexus/protocol'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -35,6 +44,7 @@ import type { SessionOperationResult, SessionStartResult, } from '../src/shared/contracts'; +import { canonicalSha256 } from './crypto'; import type { ReferenceRpState } from './reference-rp-state'; const AUDIENCE = 'https://notes.example.test'; @@ -53,6 +63,15 @@ interface LoggedInIdentity { readonly result: SessionStartResult; } +interface TestDevice { + readonly identity: TestIdentity; + readonly privateKey: CryptoKey; + readonly deviceId: NexusDeviceIdV2; + readonly authorizationId: NexusDeviceAuthorizationIdV2; + readonly authorization: DeviceAuthorizationV2; + readonly genesisHash: Base64Url32; +} + interface TestRateLimiter { setMode(value: 'allow' | 'deny' | 'fail'): Promise; reset(): Promise; @@ -172,6 +191,10 @@ describe('deployed reference RP Worker', () => { it('starts one hash-only five-minute session and rejects proof replay', async () => { const identity = await createRegisteredIdentity(); const challenge = await issueChallenge(); + expect(challenge.acceptedProofProtocols).toEqual([ + OWNERSHIP_PROOF_PROTOCOL_V2, + OWNERSHIP_PROOF_PROTOCOL_V1, + ]); const input = { challengeId: challenge.challengeId, operation: { action: 'session.start' as const }, @@ -225,6 +248,140 @@ describe('deployed reference RP Worker', () => { }); }); + it('binds v2 device authority to the session and rejects it after device revocation', async () => { + const identity = await createRegisteredIdentity(); + const device = await createRegisteredDevice(identity); + const challenge = await issueChallenge(); + const proof = await createDeviceProof(device, challenge); + const response = await postJson('/api/operations', { + challengeId: challenge.challengeId, + operation: { action: 'session.start' }, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + proof, + }); + expect(response.status, await response.clone().text()).toBe(200); + const result: SessionStartResult = await response.clone().json(); + expect(result.session).toMatchObject({ + subject: identity.subject, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + }); + const cookie = cookiePair(response.headers.get('Set-Cookie')); + const stored = await runInDurableObject( + env.RP_STATE.getByName('reference-rp-primary'), + (_instance, state) => + state.storage.sql + .exec<{ proof_protocol: string; device_id: string; authorization_id: string }>( + `SELECT proof_protocol, device_id, authorization_id + FROM sessions WHERE subject = ?`, + identity.subject, + ) + .one(), + ); + expect(stored).toEqual({ + proof_protocol: OWNERSHIP_PROOF_PROTOCOL_V2, + device_id: device.deviceId, + authorization_id: device.authorizationId, + }); + + await deviceLifecycleControl('/__test/revoke-device', device); + const denied = await sessionOperation(cookie, { + action: 'profile.set-name', + friendlyName: 'revoked_device', + }); + expect(denied.status).toBe(401); + }); + + it.each(['stale', 'forged', 'wrong-tuple'] as const)( + 'fails closed when the v2 device status statement is %s', + async (mode) => { + const identity = await createRegisteredIdentity(); + const device = await createRegisteredDevice(identity); + const { cookie } = await loginDevice(device); + await setDeviceStatusMode(mode); + + const denied = await fetchWithCookie('/api/session', cookie); + expect(denied.status).toBe(503); + }, + ); + + it('rejects a v2 session when its signed device status reports a revoked identity', async () => { + const identity = await createRegisteredIdentity(); + const device = await createRegisteredDevice(identity); + const { cookie } = await loginDevice(device); + await lifecycleControl('/__test/revoke', identity); + + const denied = await sessionOperation(cookie, { + action: 'profile.set-name', + friendlyName: 'revoked_identity', + }); + expect(denied.status).toBe(401); + }); + + it('rejects a v2 session at the exact device authorization expiry second', async () => { + const identity = await createRegisteredIdentity(); + const device = await createRegisteredDevice(identity); + const { cookie } = await loginDevice(device); + await setDeviceStatusMode('expiry-boundary'); + + const denied = await fetchWithCookie('/api/session', cookie); + expect(denied.status).toBe(401); + }); + + it('fails closed on an active device status at the exact authorization expiry second', async () => { + const identity = await createRegisteredIdentity(); + const device = await createRegisteredDevice(identity); + const { cookie } = await loginDevice(device); + await setDeviceStatusMode('active-expiry-boundary'); + + const denied = await fetchWithCookie('/api/session', cookie); + expect(denied.status).toBe(503); + }); + + it('does not accept a protocol label that disagrees with the returned proof', async () => { + const identity = await createRegisteredIdentity(); + const challenge = await issueChallenge(); + const response = await postJson('/api/operations', { + challengeId: challenge.challengeId, + operation: { action: 'session.start' }, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + proof: await createProof(identity, challenge), + }); + expect(response.status).toBe(400); + }); + + it('rejects a valid v1 proof when the stored challenge policy is v2-only', async () => { + const identity = await createRegisteredIdentity(); + const issued = await issueChallenge(); + const contextHash = await v2OnlyContextHash(); + await runInDurableObject(env.RP_STATE.getByName('reference-rp-primary'), (_instance, state) => { + state.storage.sql.exec( + `UPDATE challenges + SET accepted_proof_protocols = ?, context_hash = ? + WHERE challenge_id = ?`, + JSON.stringify([OWNERSHIP_PROOF_PROTOCOL_V2]), + contextHash, + issued.challengeId, + ); + }); + const challenge: IssuedChallenge = { + ...issued, + contextHash: contextHash as Base64Url32, + acceptedProofProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2], + }; + const response = await postJson('/api/operations', { + challengeId: challenge.challengeId, + operation: { action: 'session.start' }, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1, + proof: await createProof(identity, challenge), + }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'UNSUPPORTED_PROOF_PROTOCOL' }, + }); + }); + it('keeps private notes creator-only and exposes edit/delete authority only through that session', async () => { const owner = await login(await createRegisteredIdentity()); const other = await login(await createRegisteredIdentity()); @@ -422,7 +579,7 @@ describe('deployed reference RP Worker', () => { ) .one().count, })); - expect(schema).toEqual({ version: 3, replyTable: 1, likesTable: 1, profileTable: 1 }); + expect(schema).toEqual({ version: 4, replyTable: 1, likesTable: 1, profileTable: 1 }); }); }); @@ -460,6 +617,79 @@ async function lifecycleControl( expect(response.ok).toBe(true); } +async function createRegisteredDevice(identity: TestIdentity): Promise { + const pair = await provider.generateEd25519KeyPair({ privateKeyExtractable: true }); + const publicKey = await provider.exportEd25519PublicKey(pair.publicKey); + const signingKey = { + alg: ED25519_ALGORITHM, + publicKey: encodeBase64Url(publicKey) as Base64Url32, + }; + const deviceId = await deriveDeviceIdV2({ subject: identity.subject, signingKey }, provider); + const now = Math.floor(Date.now() / 1_000); + const genesisHash = encodeBase64Url( + await deriveGenesisHash(identity.genesis, provider), + ) as Base64Url32; + const authorizationPayload: DeviceAuthorizationV2['payload'] = { + protocol: DEVICE_AUTHORIZATION_PROTOCOL_V2, + subject: identity.subject, + genesisHash, + deviceId, + signingKey, + authorizationNonce: encodeBase64Url(provider.randomBytes(32)) as Base64Url32, + validFrom: now - 30, + activationDeadline: now + 600, + expiresAt: now + 3_600, + }; + const authorization: DeviceAuthorizationV2 = { + payload: authorizationPayload, + rootSignature: (await signProtocolPayload( + authorizationPayload, + identity.privateKey, + provider, + )) as Base64Url64, + }; + const device: TestDevice = { + identity, + privateKey: pair.privateKey, + deviceId, + authorizationId: await deriveDeviceAuthorizationIdV2(authorizationPayload, provider), + authorization, + genesisHash, + }; + await deviceLifecycleControl('/__test/device', device); + return device; +} + +async function deviceLifecycleControl(path: string, device: TestDevice): Promise { + const response = await env.TEST_LIFECYCLE.fetch(`https://lifecycle.test${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subject: device.identity.subject, + genesisHash: device.genesisHash, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + activatedAt: Math.floor(Date.now() / 1_000) - 5, + authorizationExpiresAt: device.authorization.payload.expiresAt, + }), + }); + expect(response.ok).toBe(true); +} + +async function setDeviceStatusMode( + mode: 'stale' | 'forged' | 'wrong-tuple' | 'expiry-boundary' | 'active-expiry-boundary', +): Promise { + const response = await env.TEST_LIFECYCLE.fetch( + 'https://lifecycle.test/__test/device-status-mode', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }, + ); + expect(response.ok).toBe(true); +} + async function issueChallenge(): Promise { const response = await postJson('/api/challenges', { action: 'session.start' }); expect(response.status, await response.clone().text()).toBe(201); @@ -482,6 +712,23 @@ async function login(identity: TestIdentity): Promise { }; } +async function loginDevice( + device: TestDevice, +): Promise<{ cookie: string; result: SessionStartResult }> { + const challenge = await issueChallenge(); + const response = await postJson('/api/operations', { + challengeId: challenge.challengeId, + operation: { action: 'session.start' }, + proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, + proof: await createDeviceProof(device, challenge), + }); + expect(response.status, await response.clone().text()).toBe(200); + return { + cookie: cookiePair(response.headers.get('Set-Cookie')), + result: await response.json(), + }; +} + async function createNote(cookie: string, visibility: 'public' | 'private'): Promise { const result = await expectSessionSuccess( await sessionOperation(cookie, { @@ -556,6 +803,36 @@ async function createProof( }; } +async function createDeviceProof( + device: TestDevice, + challenge: IssuedChallenge, +): Promise { + const now = Math.floor(Date.now() / 1_000); + const payload: OwnershipProofV2['payload'] = { + protocol: OWNERSHIP_PROOF_PROTOCOL_V2, + subject: device.identity.subject, + genesis: device.identity.genesis, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + authorization: device.authorization, + aud: AUDIENCE, + act: challenge.action, + resource: challenge.resource, + nonce: challenge.nonce, + iat: now, + exp: challenge.expiresAt, + ...(challenge.contextHash === undefined ? {} : { contextHash: challenge.contextHash }), + }; + return { + payload, + deviceSignature: (await signProtocolPayload( + payload, + device.privateKey, + provider, + )) as Base64Url64, + }; +} + function cookiePair(setCookie: string | null): string { if (setCookie === null) throw new Error('Expected the session cookie.'); const pair = setCookie.split(';', 1)[0]; @@ -572,3 +849,24 @@ function postJson(path: string, value: unknown): Promise { body: JSON.stringify(value), }); } + +async function v2OnlyContextHash(): Promise { + return await canonicalSha256({ + action: 'session.start', + policy: { + actions: [ + 'note.create', + 'note.edit', + 'note.delete', + 'reply.create', + 'reply.delete', + 'note.like', + 'note.unlike', + 'profile.set-name', + ], + resourcePolicy: 'public-notes-and-private-notes-owned-by-session-subject', + ttlSeconds: 300, + }, + acceptedProofProtocols: [OWNERSHIP_PROOF_PROTOCOL_V2], + }); +} diff --git a/apps/reference-rp/worker/validation.ts b/apps/reference-rp/worker/validation.ts index d9ea238..339bd09 100644 --- a/apps/reference-rp/worker/validation.ts +++ b/apps/reference-rp/worker/validation.ts @@ -1,4 +1,9 @@ -import { ownershipProofV1Schema } from '@nexus/protocol'; +import { + OWNERSHIP_PROOF_PROTOCOL_V1, + OWNERSHIP_PROOF_PROTOCOL_V2, + ownershipProofV1Schema, + ownershipProofV2Schema, +} from '@nexus/protocol'; import type { NoteDraft, @@ -10,17 +15,32 @@ import type { import { RpWorkerError } from './errors'; export function parseSubmitProof(value: unknown): SubmitProofInput { - const record = requireRecord(value, ['challengeId', 'operation', 'proof']); + if (!isRecord(value)) throw badRequest('Request body must be an object.'); + const legacyV1 = !Object.hasOwn(value, 'proofProtocol'); + const record = requireRecord( + value, + legacyV1 + ? ['challengeId', 'operation', 'proof'] + : ['challengeId', 'operation', 'proof', 'proofProtocol'], + ); if (typeof record['challengeId'] !== 'string' || record['challengeId'].length < 20) { throw badRequest('challengeId is invalid.'); } - const proof = ownershipProofV1Schema.safeParse(record['proof']); - if (!proof.success) throw badRequest('proof is invalid.'); - return { + const common = { challengeId: record['challengeId'], operation: parseStartSessionOperation(record['operation']), - proof: proof.data, }; + if (record['proofProtocol'] === OWNERSHIP_PROOF_PROTOCOL_V1 || legacyV1) { + const proof = ownershipProofV1Schema.safeParse(record['proof']); + if (!proof.success) throw badRequest('proof is invalid.'); + return { ...common, proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V1, proof: proof.data }; + } + if (record['proofProtocol'] === OWNERSHIP_PROOF_PROTOCOL_V2) { + const proof = ownershipProofV2Schema.safeParse(record['proof']); + if (!proof.success) throw badRequest('proof is invalid.'); + return { ...common, proofProtocol: OWNERSHIP_PROOF_PROTOCOL_V2, proof: proof.data }; + } + throw badRequest('proofProtocol is not supported.'); } export function parseStartSessionOperation(value: unknown): StartSessionOperation { diff --git a/apps/reference-rp/worker/vitest.config.ts b/apps/reference-rp/worker/vitest.config.ts index 67bff9a..9b82731 100644 --- a/apps/reference-rp/worker/vitest.config.ts +++ b/apps/reference-rp/worker/vitest.config.ts @@ -2,7 +2,23 @@ import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; const lifecycleService = ` const identities = new Map(); + const devices = new Map(); const signature = 'A'.repeat(86); + const statusPrivateKey = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; // gitleaks:allow -- fixed test-only Ed25519 fixture + let deviceStatusMode = 'fresh'; + + const decode = (value) => Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - value.length % 4) % 4)), (character) => character.charCodeAt(0)); + const encode = (value) => btoa(String.fromCharCode(...value)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, ''); + const canonical = (value) => { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']'; + return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}'; + }; + const signStatus = async (payload) => { + const privateKey = await crypto.subtle.importKey('pkcs8', decode(statusPrivateKey), { name: 'Ed25519' }, false, ['sign']); + const preimage = new TextEncoder().encode('NEXUS-SIGNATURE\\0' + payload.protocol + '\\0' + canonical(payload)); + return encode(new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, preimage))); + }; export default { async fetch(request) { @@ -11,6 +27,8 @@ const lifecycleService = ` const body = text.length === 0 ? {} : JSON.parse(text); if (url.pathname === '/__test/reset') { identities.clear(); + devices.clear(); + deviceStatusMode = 'fresh'; return Response.json({ ok: true }); } if (url.pathname === '/__test/register') { @@ -22,6 +40,67 @@ const lifecycleService = ` if (current) identities.set(body.subject, { ...current, state: 'revoked' }); return Response.json({ ok: true }); } + if (url.pathname === '/__test/device') { + devices.set(body.deviceId, { ...body, state: 'active' }); + return Response.json({ ok: true }); + } + if (url.pathname === '/__test/revoke-device') { + const current = devices.get(body.deviceId); + if (current) devices.set(body.deviceId, { ...current, state: 'revoked' }); + return Response.json({ ok: true }); + } + if (url.pathname === '/__test/device-status-mode') { + deviceStatusMode = body.mode; + return Response.json({ ok: true }); + } + if (url.pathname === '/v2/device/status') { + const device = devices.get(body.deviceId); + if (!device || device.subject !== body.subject || device.authorizationId !== body.authorizationId) { + return Response.json({ error: { code: 'DEVICE_NOT_FOUND', message: 'Not found.' } }, { status: 404 }); + } + const now = Math.floor(Date.now() / 1000); + const identity = identities.get(body.subject); + const identityState = identity?.state === 'active' ? 'active' : 'revoked'; + const deviceState = deviceStatusMode === 'expiry-boundary' ? 'expired' : device.state; + const statementAuthorizationId = deviceStatusMode === 'wrong-tuple' ? 'nxa2_' + 'A'.repeat(43) : body.authorizationId; + const issuedAt = deviceStatusMode === 'stale' ? now - 120 : now; + const authorizationExpiresAt = deviceStatusMode === 'expiry-boundary' || deviceStatusMode === 'active-expiry-boundary' ? now : device.authorizationExpiresAt; + const payload = { + protocol: 'nexus.device-status-statement.v2', + subject: body.subject, + genesisHash: device.genesisHash, + identityState, + identitySequence: identityState === 'active' ? 0 : 1, + deviceLedgerSequence: deviceState === 'revoked' ? 2 : 1, + deviceId: body.deviceId, + authorizationId: statementAuthorizationId, + deviceState, + activatedAt: device.activatedAt, + ...(deviceState === 'revoked' ? { revokedAt: now } : {}), + authorizationExpiresAt, + iat: issuedAt, + exp: issuedAt + 60, + signerKid: 'test-status-key' + }; + const validSignature = await signStatus(payload); + const deviceSignature = deviceStatusMode === 'forged' + ? (validSignature[0] === 'A' ? 'B' : 'A') + validSignature.slice(1) + : validSignature; + return Response.json({ + subject: body.subject, + genesisHash: device.genesisHash, + identityState: payload.identityState, + identitySequence: payload.identitySequence, + deviceLedgerSequence: payload.deviceLedgerSequence, + deviceId: body.deviceId, + authorizationId: statementAuthorizationId, + deviceState, + activatedAt: device.activatedAt, + revokedAt: deviceState === 'revoked' ? now : null, + authorizationExpiresAt, + statusStatement: { payload, signature: deviceSignature } + }); + } if (url.pathname !== '/v1/identity/status') return new Response('Not Found', { status: 404 }); const identity = identities.get(body.subject); if (!identity) return Response.json({ error: { code: 'IDENTITY_NOT_FOUND', message: 'Not found.' } }, { status: 404 }); @@ -78,7 +157,21 @@ export default { wrangler: { configPath: './worker/wrangler.test.jsonc' }, miniflare: { compatibilityDate: '2026-07-29', - bindings: { RP_AUDIENCE: 'https://notes.example.test' }, + bindings: { + RP_AUDIENCE: 'https://notes.example.test', + SERVICE_JWKS_JSON: JSON.stringify({ + keys: [ + { + kty: 'OKP', + crv: 'Ed25519', + alg: 'EdDSA', + kid: 'test-status-key', + x: '_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU', + use: 'sig', + }, + ], + }), + }, serviceBindings: { NEXUS_API: 'reference-rp-lifecycle-test', RP_API_RATE_LIMITER: 'reference-rp-rate-limit-test', diff --git a/apps/reference-rp/wrangler.jsonc b/apps/reference-rp/wrangler.jsonc index 4ce5634..c9f390f 100644 --- a/apps/reference-rp/wrangler.jsonc +++ b/apps/reference-rp/wrangler.jsonc @@ -32,6 +32,8 @@ // Replace both placeholders with the exact production HTTPS origins before deployment. "RP_AUDIENCE": "https://notes.rowo.link", "NEXUS_API_ORIGIN": "https://nexus.rowo.link", + // Pin the registry status verification keys. Rotate this alongside edge-api SERVICE_JWKS_JSON. + "SERVICE_JWKS_JSON": "{\"keys\":[{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"alg\":\"EdDSA\",\"kid\":\"rowo-receipt-2026-08\",\"x\":\"hSyFSUMtVI92_SAlsuDiDFsjh4UnhSuqhUigecztMU0\",\"use\":\"sig\"},{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"alg\":\"EdDSA\",\"kid\":\"rowo-status-2026-08\",\"x\":\"p_0wFlB-khNLDc_66RYGHG98JY_lrhXtzj5tNEuy4SE\",\"use\":\"sig\"}]}", }, "observability": { "enabled": true, diff --git a/apps/wallet/src/App.tsx b/apps/wallet/src/App.tsx index 820c811..5855899 100644 --- a/apps/wallet/src/App.tsx +++ b/apps/wallet/src/App.tsx @@ -1,5 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { LocalIdentitySummary } from '@nexus/wallet-core'; +import type { + DeviceTransferEnvelopeV2, + IssueDeviceTransferOptions, + IssuedDeviceTransferV2, + LocalIdentitySummary, +} from '@nexus/wallet-core'; import { AlertTriangle, Fingerprint, @@ -9,10 +14,16 @@ import { Plus, ShieldCheck, Sparkles, + Upload, } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { IdentityCard } from './components/IdentityCard'; +import { + InstallDeviceModal, + IssueDeviceModal, + RevokeDeviceModal, +} from './components/DeviceManagement'; import { IdentityDetail } from './components/IdentityDetail'; import { ProofConsentScreen } from './components/ProofConsentScreen'; import { WalletLayout } from './components/WalletLayout'; @@ -24,11 +35,14 @@ import { } from './components/flows'; import { announceWalletReady, + announceWalletReadyV2, parseProofRequestMessage, postProofError, postProofResult, type PendingProofRequest, + type NexusOwnershipProofProtocol, } from './lib/popup-protocol'; +import { installAndActivateDevice, type DeviceInstallResult } from './lib/device-management'; import { walletAdapter, type CreateIdentityInput, @@ -37,6 +51,14 @@ import { type Flow = | { type: 'create' } + | { type: 'install-device' } + | { type: 'issue-device'; identity: LocalIdentitySummary } + | { type: 'self-revoke-device'; identity: LocalIdentitySummary } + | { + type: 'root-revoke-device'; + identity: LocalIdentitySummary; + device: LocalIdentitySummary['issuedDevices'][number]; + } | { type: 'dispose'; identity: LocalIdentitySummary } | { type: 'rotate'; identity: LocalIdentitySummary } | { type: 'continuity'; identity: LocalIdentitySummary }; @@ -56,6 +78,7 @@ function App() { const [pending, setPending] = useState(); const [retryingRegistrationId, setRetryingRegistrationId] = useState(); const [clearingHistoryId, setClearingHistoryId] = useState(); + const [deviceActionId, setDeviceActionId] = useState(); const pendingRef = useRef(undefined); const refresh = useCallback(async () => { @@ -88,6 +111,7 @@ function App() { }; window.addEventListener('message', onMessage); announceWalletReady(opener); + announceWalletReadyV2(opener); return () => window.removeEventListener('message', onMessage); }, []); @@ -171,15 +195,95 @@ function App() { } }; - const approveProof = async (localId: string, rememberScope: boolean) => { + const issueDevice = async ( + identity: LocalIdentitySummary, + options: IssueDeviceTransferOptions, + ): Promise => { + const issued = await walletAdapter.issueDeviceTransfer(identity.localId, options); + await refresh(); + return issued; + }; + + const installDevice = async ( + bundle: DeviceTransferEnvelopeV2, + transferKey: Uint8Array, + ): Promise => { + const result = await installAndActivateDevice(walletAdapter, bundle, transferKey); + await refresh(); + setSelectedId(result.imported.localId); + setFlow(undefined); + setNotice( + result.state === 'active' + ? { kind: 'success', message: 'Device installed and activated for this identity.' } + : { + kind: 'error', + message: `Device installed safely, but activation is still pending. Open it and retry. ${result.activationError}`, + }, + ); + return result; + }; + + const activateDevice = async (identity: LocalIdentitySummary) => { + if (deviceActionId !== undefined) return; + setDeviceActionId(identity.localId); + try { + await walletAdapter.activateDevice(identity.localId); + await refresh(); + setNotice({ kind: 'success', message: 'Device activation confirmed by the registry.' }); + } catch (error) { + setNotice({ + kind: 'error', + message: + error instanceof Error + ? error.message + : 'Activation failed. The installed device key was retained for retry.', + }); + } finally { + setDeviceActionId(undefined); + } + }; + + const selfRevokeDevice = async (identity: LocalIdentitySummary) => { + setDeviceActionId(identity.localId); + try { + await walletAdapter.revokeDeviceSelf(identity.localId); + await refresh(); + setFlow(undefined); + setNotice({ kind: 'success', message: 'This device was removed from the identity.' }); + } finally { + setDeviceActionId(undefined); + } + }; + + const rootRevokeDevice = async ( + identity: LocalIdentitySummary, + device: LocalIdentitySummary['issuedDevices'][number], + ) => { + setDeviceActionId(device.deviceId); + try { + await walletAdapter.revokeDeviceRoot(identity.localId, device.deviceId); + await refresh(); + setFlow(undefined); + setNotice({ kind: 'success', message: 'The root identity removed that device.' }); + } finally { + setDeviceActionId(undefined); + } + }; + + const approveProof = async ( + localId: string, + rememberScope: boolean, + proofProtocol: NexusOwnershipProofProtocol, + ) => { if (pending === undefined) return; - const proof = await walletAdapter.prove( + const proof = await walletAdapter.proveForProtocol( localId, pending.boundary, pending.request, rememberScope, + proofProtocol, ); - postProofResult(pending, proof); + postProofResult(pending, { proofProtocol, proof } as Parameters[1]); window.close(); pendingRef.current = undefined; setPending(undefined); @@ -259,13 +363,22 @@ function App() { dispose of any identity permanently.

- +
+ + +
@@ -372,6 +485,15 @@ function App() { onRetryRegistration={() => void retryRegistration(selectedIdentity)} clearingHistory={clearingHistoryId === selectedIdentity.localId} onClearHistory={() => clearAuthorizationHistory(selectedIdentity)} + onAddDevice={() => setFlow({ type: 'issue-device', identity: selectedIdentity })} + onActivateDevice={() => activateDevice(selectedIdentity)} + onSelfRevokeDevice={() => + setFlow({ type: 'self-revoke-device', identity: selectedIdentity }) + } + onRootRevokeDevice={(device) => + setFlow({ type: 'root-revoke-device', identity: selectedIdentity, device }) + } + deviceActionBusy={deviceActionId !== undefined} /> )} @@ -399,6 +521,32 @@ function App() { {flow?.type === 'create' && ( setFlow(undefined)} onCreate={createIdentity} /> )} + {flow?.type === 'install-device' && ( + setFlow(undefined)} onInstall={installDevice} /> + )} + {flow?.type === 'issue-device' && ( + setFlow(undefined)} + onIssue={(options) => issueDevice(flow.identity, options)} + /> + )} + {flow?.type === 'self-revoke-device' && ( + setFlow(undefined)} + onRevoke={() => selfRevokeDevice(flow.identity)} + /> + )} + {flow?.type === 'root-revoke-device' && ( + setFlow(undefined)} + onRevoke={() => rootRevokeDevice(flow.identity, flow.device)} + /> + )} {flow?.type === 'dispose' && ( void; + onIssue: (options: IssueDeviceTransferOptions) => Promise; +} + +export function IssueDeviceModal({ identity, onClose, onIssue }: IssueDeviceModalProps) { + const [label, setLabel] = useState(''); + const [understood, setUnderstood] = useState(false); + const [state, setState] = useState(idle); + const [issued, setIssued] = useState<{ + bundle: DeviceTransferEnvelopeV2; + transferKey: string; + deviceId: string; + }>(); + const [copied, setCopied] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!understood) return; + setState({ busy: true }); + try { + const normalized = label.trim(); + const result = await onIssue(normalized === '' ? {} : { label: normalized }); + let transferKey = ''; + try { + transferKey = encodeDeviceTransferKey(result.transferKey); + } finally { + result.transferKey.fill(0); + } + setIssued({ + bundle: result.bundle, + transferKey, + deviceId: result.authorization.payload.deviceId, + }); + setState({ busy: false }); + } catch (error) { + setState({ busy: false, error: errorMessage(error) }); + } + }; + + const close = () => { + setIssued(undefined); + setCopied(false); + onClose(); + }; + + const copyKey = async () => { + if (issued === undefined) return; + try { + await navigator.clipboard.writeText(issued.transferKey); + setCopied(true); + window.setTimeout(() => setCopied(false), 1_500); + } catch (error) { + setState({ busy: false, error: errorMessage(error) }); + } + }; + + return ( + + ); +} + +interface InstallDeviceModalProps { + onClose: () => void; + onInstall: ( + bundle: DeviceTransferEnvelopeV2, + transferKey: Uint8Array, + ) => Promise; +} + +export function InstallDeviceModal({ onClose, onInstall }: InstallDeviceModalProps) { + const [bundle, setBundle] = useState(); + const [fileName, setFileName] = useState(''); + const [transferKey, setTransferKey] = useState(''); + const [state, setState] = useState(idle); + + const selectFile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + setBundle(undefined); + setFileName(''); + setState(idle); + if (file === undefined) return; + if (file.size > MAX_DEVICE_TRANSFER_FILE_BYTES) { + setState({ busy: false, error: 'The device transfer file is larger than 96 KiB.' }); + event.target.value = ''; + return; + } + try { + setBundle(parseDeviceTransferBundleJson(await file.text(), file.size)); + setFileName(file.name); + } catch (error) { + setState({ busy: false, error: errorMessage(error) }); + event.target.value = ''; + } + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (bundle === undefined) return; + let keyBytes: Uint8Array; + try { + keyBytes = decodeDeviceTransferKey(transferKey); + } catch (error) { + setState({ busy: false, error: errorMessage(error) }); + return; + } + setState({ busy: true }); + try { + await onInstall(bundle, keyBytes); + setTransferKey(''); + } catch (error) { + // Keep both user inputs available for a corrected retry when import itself failed. + setState({ busy: false, error: errorMessage(error) }); + } finally { + keyBytes.fill(0); + } + }; + + return ( + + ); +} + +interface RevokeDeviceModalProps { + role: 'root' | 'device'; + deviceLabel?: string; + onClose: () => void; + onRevoke: () => Promise; +} + +export function RevokeDeviceModal({ + role, + deviceLabel, + onClose, + onRevoke, +}: RevokeDeviceModalProps) { + const [confirmed, setConfirmed] = useState(false); + const [state, setState] = useState(idle); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!confirmed) return; + setState({ busy: true }); + try { + await onRevoke(); + } catch (error) { + // A failed or unverifiable response leaves the local signing key/catalogue intact for retry. + setState({ busy: false, error: errorMessage(error) }); + } + }; + + return ( + + ); +} + +function ErrorNotice({ message }: { message: string }) { + return ( +
+
+ ); +} + +const secondaryButton = + 'flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50'; +const primaryButton = + 'flex flex-1 items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-indigo-400'; +const compactButton = + 'inline-flex shrink-0 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50'; diff --git a/apps/wallet/src/components/IdentityCard.tsx b/apps/wallet/src/components/IdentityCard.tsx index fa3bc98..91ce889 100644 --- a/apps/wallet/src/components/IdentityCard.tsx +++ b/apps/wallet/src/components/IdentityCard.tsx @@ -31,6 +31,20 @@ export function IdentityCard({ }: IdentityCardProps) { const [copied, setCopied] = useState(false); const active = identity.localState === 'active'; + const deviceState = identity.device?.localState; + const statusLabel = + deviceState === 'pending-activation' + ? 'Pending activation' + : deviceState === 'active' + ? 'Active device' + : deviceState === 'revoked' + ? 'Revoked device' + : active + ? identity.registered + ? 'Active' + : 'Unregistered' + : 'Disposed'; + const ready = identity.proofReady; const copySubject = async () => { await navigator.clipboard.writeText(identity.subject); @@ -71,7 +85,7 @@ export function IdentityCard({ - {active ? (identity.registered ? 'Active' : 'Unregistered') : 'Disposed'} + {statusLabel}
diff --git a/apps/wallet/src/components/IdentityDetail.tsx b/apps/wallet/src/components/IdentityDetail.tsx index ab23e5e..5892dba 100644 --- a/apps/wallet/src/components/IdentityDetail.tsx +++ b/apps/wallet/src/components/IdentityDetail.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { AlertTriangle, ArrowRightLeft, @@ -9,7 +9,11 @@ import { KeySquare, Link2, Loader2, + Plus, + RefreshCw, ShieldCheck, + ShieldOff, + Smartphone, Trash2, X, } from 'lucide-react'; @@ -20,6 +24,7 @@ import { canUseRegisteredIdentityActions, needsRegistrationRecovery, } from '../lib/identity-capabilities'; +import { deviceManagementCapabilities } from '../lib/device-management'; interface IdentityDetailProps { identity: LocalIdentitySummary; @@ -31,6 +36,11 @@ interface IdentityDetailProps { onRetryRegistration: () => void; clearingHistory: boolean; onClearHistory: () => Promise; + onAddDevice: () => void; + onActivateDevice: () => Promise; + onSelfRevokeDevice: () => void; + onRootRevokeDevice: (device: LocalIdentitySummary['issuedDevices'][number]) => void; + deviceActionBusy: boolean; } export function IdentityDetail({ @@ -43,10 +53,26 @@ export function IdentityDetail({ onRetryRegistration, clearingHistory, onClearHistory, + onAddDevice, + onActivateDevice, + onSelfRevokeDevice, + onRootRevokeDevice, + deviceActionBusy, }: IdentityDetailProps) { const active = identity.localState === 'active'; const registeredActions = canUseRegisteredIdentityActions(identity); const [confirmClearHistory, setConfirmClearHistory] = useState(false); + const [now, setNow] = useState(() => Math.floor(Date.now() / 1_000)); + const deviceCapabilities = deviceManagementCapabilities(identity, now); + + useEffect(() => { + if (identity.device?.localState !== 'pending-activation') return; + const cutoff = Math.min(identity.device.activationDeadline, identity.device.expiresAt); + if (now >= cutoff) return; + const delay = Math.min((cutoff - now) * 1_000 + 25, 2_147_483_647); + const handle = window.setTimeout(() => setNow(Math.floor(Date.now() / 1_000)), delay); + return () => window.clearTimeout(handle); + }, [identity.device, now]); const clearHistory = async () => { if (!confirmClearHistory) { @@ -106,11 +132,17 @@ export function IdentityDetail({
@@ -151,6 +183,138 @@ export function IdentityDetail({
)} + {(identity.device !== undefined || registeredActions) && ( +
+
+
+
+ {deviceCapabilities.issueDevice && ( + + )} +
+ + {identity.device !== undefined ? ( +
+
+
+

+ {identity.device.localState === 'pending-activation' + ? 'Pending activation' + : identity.device.localState === 'active' + ? 'Active delegated key' + : 'Revoked delegated key'} +

+ + {identity.device.deviceId} + +

+ Authorized until{' '} + {new Date(identity.device.expiresAt * 1_000).toLocaleString()}. This key + cannot add or remove other devices. +

+
+ {deviceCapabilities.activateDevice && ( + + )} + {deviceCapabilities.selfRevokeDevice && ( + + )} +
+ {identity.device.localState === 'pending-activation' && ( +
+ {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. +
+ ) : ( +
    + {identity.issuedDevices.map((device) => ( +
  • +
    +
    +
    +

    + {device.label ?? 'Authorized device'} +

    + + {device.localState === 'revoked' ? 'Revoked' : 'Issued'} + +
    + + {device.deviceId} + +

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

    +
    + {device.localState !== 'revoked' && deviceCapabilities.rootRevokeDevice && ( + + )} +
    +
  • + ))} +
+ )} +
+ )} +
)} + {legacyRootProof && ( +
+
+ +
+
Legacy root-key proof
+

+ 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(); +const ROOT_SIGNING_PROTOCOLS = new Set([ + 'nexus.ownership-proof.v1', + 'nexus.continuity-link.v1', + 'nexus.revoke.v1', + 'nexus.device-authorization.v2', + 'nexus.device-root-revoke.v2', +]); +const DEVICE_SIGNING_PROTOCOLS = new Set([ + 'nexus.device-activation.v2', + 'nexus.ownership-proof.v2', + 'nexus.device-self-revoke.v2', +]); + +export function getManagedKeyVaultCapabilities( + vault: KeyVaultStorage, +): ManagedKeyVaultCapabilities | undefined { + return managedCapabilities.get(vault); +} + +/** Package-internal test seam; not exported from the package entry point. */ +export function signManagedProtocolPayload( + vault: KeyVaultStorage, + ref: KeyRef, + payload: T, +): Promise { + const capability = managedCapabilities.get(vault); + if (capability === undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'This is not a managed key vault.'); + } + return capability.signProtocolPayload(ref, payload); +} export interface WebCryptoIndexedDbKeyVaultOptions { databaseName?: string; indexedDB?: IDBFactory; crypto?: Crypto; + /** @internal Deterministic import-race test hook. */ + beforeDeviceKeyImport?: () => Promise; } function copyBuffer(bytes: Uint8Array): ArrayBuffer { @@ -43,6 +102,15 @@ function isCryptoKeyPair(value: CryptoKey | CryptoKeyPair): value is CryptoKeyPa return 'privateKey' in value && 'publicKey' in value; } +function isConstraintError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'ConstraintError') return true; + return ( + error instanceof Error && + error.cause instanceof DOMException && + error.cause.name === 'ConstraintError' + ); +} + function asKeyRef(value: string): KeyRef { return value as KeyRef; } @@ -58,12 +126,67 @@ function newReference(cryptoApi: Crypto, prefix: 'key' | 'secret'): string { } function requireKey(record: StoredVaultRecord | undefined, ref: KeyRef): StoredKeyRecord { - if (record === undefined || record.kind === 'revocation-secret') { + if ( + record === undefined || + record.kind === 'revocation-secret' || + record.kind === 'cancelled-device-key-ref' + ) { throw new WalletCoreError('STORAGE_ERROR', `Key reference ${String(ref)} does not exist.`); } return record; } +async function importDeviceSigningKey( + cryptoApi: Crypto, + ref: KeyRef, + privateKeyPkcs8: Uint8Array, + expectedPublicKey: Uint8Array, +): Promise { + if (expectedPublicKey.byteLength !== 32) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'An Ed25519 device public key must contain exactly 32 bytes.', + ); + } + let privateKey: CryptoKey; + let publicKey: CryptoKey; + const privateKeyImportBytes = copyBuffer(privateKeyPkcs8); + try { + [privateKey, publicKey] = await Promise.all([ + cryptoApi.subtle.importKey('pkcs8', privateKeyImportBytes, { name: 'Ed25519' }, false, [ + 'sign', + ]), + cryptoApi.subtle.importKey('raw', copyBuffer(expectedPublicKey), { name: 'Ed25519' }, false, [ + 'verify', + ]), + ]); + } catch (error) { + throw new WalletCoreError('INVALID_REQUEST', 'The device private key is invalid.', { + cause: error, + }); + } finally { + new Uint8Array(privateKeyImportBytes).fill(0); + } + + const challenge = new Uint8Array(32); + cryptoApi.getRandomValues(challenge); + const signature = await cryptoApi.subtle.sign('Ed25519', privateKey, challenge); + const matches = await cryptoApi.subtle.verify('Ed25519', publicKey, signature, challenge); + challenge.fill(0); + if (!matches) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The device private key does not match its authorized public key.', + ); + } + return { + ref, + kind: 'device-signing-key', + privateKey, + publicKey: copyBuffer(expectedPublicKey), + }; +} + function requireSecret(record: StoredVaultRecord | undefined, ref: SecretRef): StoredSecretRecord { if (record === undefined || record.kind !== 'revocation-secret') { throw new WalletCoreError('STORAGE_ERROR', `Secret reference ${String(ref)} does not exist.`); @@ -76,8 +199,8 @@ async function generateKeyRecord( kind: StoredKeyKind, ref: KeyRef, ): Promise { - const algorithm = kind === 'signing-key' ? 'Ed25519' : 'X25519'; - const usages: KeyUsage[] = kind === 'signing-key' ? ['sign', 'verify'] : ['deriveBits']; + const algorithm = kind === 'agreement-key' ? 'X25519' : 'Ed25519'; + const usages: KeyUsage[] = kind === 'agreement-key' ? ['deriveBits'] : ['sign', 'verify']; const generated = await cryptoApi.subtle.generateKey({ name: algorithm }, false, usages); if (!isCryptoKeyPair(generated)) { throw new WalletCoreError( @@ -91,40 +214,103 @@ async function generateKeyRecord( /** * Stores non-extractable private CryptoKey objects and revocation secrets in - * IndexedDB. Private-key export/import and recovery are intentionally unsupported. + * IndexedDB. Public raw private-key export/import and root recovery are + * intentionally unsupported; WalletCore alone receives the private capability + * for certified device-key import and canceled-reference journaling. */ -export class WebCryptoIndexedDbKeyVault implements KeyVault { +export class WebCryptoIndexedDbKeyVault implements KeyVaultStorage { readonly #databaseName: string; readonly #factory: IDBFactory; readonly #crypto: Crypto; + readonly #beforeDeviceKeyImport: (() => Promise) | undefined; #database: Promise | undefined; public constructor(options: WebCryptoIndexedDbKeyVaultOptions = {}) { this.#databaseName = options.databaseName ?? DEFAULT_WALLET_DATABASE_NAME; this.#factory = requireIndexedDb(options.indexedDB); this.#crypto = requireWebCrypto(options.crypto); + this.#beforeDeviceKeyImport = options.beforeDeviceKeyImport; + managedCapabilities.set(this, { + createDeviceSigningKeyRef: () => asKeyRef(newReference(this.#crypto, 'key')), + importDeviceSigningKeyAtRef: (ref, privateKeyPkcs8, expectedPublicKey) => + this.#importDeviceSigningKeyAtRef(ref, privateKeyPkcs8, expectedPublicKey), + cancelDeviceSigningKeyRef: (ref) => this.#cancelDeviceSigningKeyRef(ref), + signProtocolPayload: (ref, payload) => this.#signProtocolPayload(ref, payload), + }); } public async createSigningKey(): Promise { return this.#createKey('signing-key'); } + async #importDeviceSigningKeyAtRef( + ref: KeyRef, + privateKeyPkcs8: Uint8Array, + expectedPublicKey: Uint8Array, + ): Promise { + await this.#beforeDeviceKeyImport?.(); + const record = await importDeviceSigningKey( + this.#crypto, + ref, + privateKeyPkcs8, + expectedPublicKey, + ); + await this.#add(record); + } + + async #cancelDeviceSigningKeyRef(ref: KeyRef): Promise { + const database = await this.#open(); + const transaction = database.transaction(KEY_MATERIAL_STORE, 'readwrite'); + const completion = transactionComplete(transaction); + const store = transaction.objectStore(KEY_MATERIAL_STORE); + const existing = await requestResult( + // IndexedDB's legacy DOM declaration returns IDBRequest at this boundary. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + store.get(ref), + ); + if ( + existing !== undefined && + existing.kind !== 'device-signing-key' && + existing.kind !== 'cancelled-device-key-ref' + ) { + transaction.abort(); + await completion.catch(() => undefined); + throw new WalletCoreError( + 'STORAGE_ERROR', + 'A device-key cancellation cannot replace another key role.', + ); + } + await requestResult( + store.put({ + ref, + kind: 'cancelled-device-key-ref', + } satisfies StoredCancelledDeviceKeyRefRecord), + ); + await completion; + } + public async createAgreementKey(): Promise { return this.#createKey('agreement-key'); } - public async sign(ref: KeyRef, data: Uint8Array): Promise { + async #signProtocolPayload(ref: KeyRef, payload: ManagedProtocolPayload): Promise { const record = requireKey(await this.#get(ref), ref); - if (record.kind !== 'signing-key') { + const allowed = + record.kind === 'signing-key' + ? ROOT_SIGNING_PROTOCOLS + : record.kind === 'device-signing-key' + ? DEVICE_SIGNING_PROTOCOLS + : undefined; + if (allowed === undefined || !allowed.has(payload.protocol)) { throw new WalletCoreError( 'INVALID_REQUEST', - 'An agreement key cannot sign protocol payloads.', + 'This key role is not authorized to sign the requested protocol payload.', ); } const signature = await this.#crypto.subtle.sign( 'Ed25519', record.privateKey, - Uint8Array.from(data), + copyBuffer(createProtocolSignaturePreimage(payload)), ); return new Uint8Array(signature); } @@ -135,7 +321,7 @@ export class WebCryptoIndexedDbKeyVault implements KeyVault { } public async deleteKey(ref: KeyRef): Promise { - await this.#delete(ref); + await this.#deleteUnlessCancelled(ref); } public async storeRevocationSecret(secret: Uint8Array): Promise { @@ -156,12 +342,16 @@ export class WebCryptoIndexedDbKeyVault implements KeyVault { } public async deleteSecret(ref: SecretRef): Promise { - await this.#delete(ref); + await this.#deleteUnlessCancelled(ref); } public async hasKey(ref: KeyRef): Promise { const record = await this.#get(ref); - return record !== undefined && record.kind !== 'revocation-secret'; + return ( + record !== undefined && + record.kind !== 'revocation-secret' && + record.kind !== 'cancelled-device-key-ref' + ); } public async hasSecret(ref: SecretRef): Promise { @@ -192,8 +382,20 @@ export class WebCryptoIndexedDbKeyVault implements KeyVault { const database = await this.#open(); const transaction = database.transaction(KEY_MATERIAL_STORE, 'readwrite'); const completion = transactionComplete(transaction); - await requestResult(transaction.objectStore(KEY_MATERIAL_STORE).add(record)); - await completion; + try { + await requestResult(transaction.objectStore(KEY_MATERIAL_STORE).add(record)); + await completion; + } catch (error) { + await completion.catch(() => undefined); + if (isConstraintError(error)) { + throw new WalletCoreError( + 'REGISTRY_CONFLICT', + 'The device key reference was already stored or canceled.', + { cause: error }, + ); + } + throw error; + } } async #get(ref: KeyRef | SecretRef): Promise { @@ -209,47 +411,106 @@ export class WebCryptoIndexedDbKeyVault implements KeyVault { return result; } - async #delete(ref: KeyRef | SecretRef): Promise { + async #deleteUnlessCancelled(ref: KeyRef | SecretRef): Promise { const database = await this.#open(); const transaction = database.transaction(KEY_MATERIAL_STORE, 'readwrite'); const completion = transactionComplete(transaction); - await requestResult(transaction.objectStore(KEY_MATERIAL_STORE).delete(ref)); + const store = transaction.objectStore(KEY_MATERIAL_STORE); + const existing = await requestResult( + // IndexedDB's legacy DOM declaration returns IDBRequest at this boundary. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + store.get(ref), + ); + if (existing?.kind !== 'cancelled-device-key-ref') { + await requestResult(store.delete(ref)); + } await completion; } } export interface InMemoryKeyVaultOptions { crypto?: Crypto; + /** @internal Deterministic import-race test hook. */ + beforeDeviceKeyImport?: () => Promise; } -export class InMemoryKeyVault implements KeyVault { +export class InMemoryKeyVault implements KeyVaultStorage { readonly #crypto: Crypto; readonly #records = new Map(); + readonly #beforeDeviceKeyImport: (() => Promise) | undefined; public constructor(options: InMemoryKeyVaultOptions = {}) { this.#crypto = requireWebCrypto(options.crypto); + this.#beforeDeviceKeyImport = options.beforeDeviceKeyImport; + managedCapabilities.set(this, { + createDeviceSigningKeyRef: () => asKeyRef(newReference(this.#crypto, 'key')), + importDeviceSigningKeyAtRef: (ref, privateKeyPkcs8, expectedPublicKey) => + this.#importDeviceSigningKeyAtRef(ref, privateKeyPkcs8, expectedPublicKey), + cancelDeviceSigningKeyRef: (ref) => this.#cancelDeviceSigningKeyRef(ref), + signProtocolPayload: (ref, payload) => this.#signProtocolPayload(ref, payload), + }); } public async createSigningKey(): Promise { return this.#createKey('signing-key'); } + async #importDeviceSigningKeyAtRef( + ref: KeyRef, + privateKeyPkcs8: Uint8Array, + expectedPublicKey: Uint8Array, + ): Promise { + await this.#beforeDeviceKeyImport?.(); + const record = await importDeviceSigningKey( + this.#crypto, + ref, + privateKeyPkcs8, + expectedPublicKey, + ); + if (this.#records.has(ref)) { + throw new WalletCoreError('REGISTRY_CONFLICT', 'The device key reference already exists.'); + } + this.#records.set(ref, record); + } + + #cancelDeviceSigningKeyRef(ref: KeyRef): Promise { + const existing = this.#records.get(ref); + if ( + existing !== undefined && + existing.kind !== 'device-signing-key' && + existing.kind !== 'cancelled-device-key-ref' + ) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'A device-key cancellation cannot replace another key role.', + ); + } + this.#records.set(ref, { ref, kind: 'cancelled-device-key-ref' }); + return Promise.resolve(); + } + public async createAgreementKey(): Promise { return this.#createKey('agreement-key'); } - public async sign(ref: KeyRef, data: Uint8Array): Promise { + async #signProtocolPayload(ref: KeyRef, payload: ManagedProtocolPayload): Promise { const record = requireKey(this.#records.get(ref), ref); - if (record.kind !== 'signing-key') { + const allowed = + record.kind === 'signing-key' + ? ROOT_SIGNING_PROTOCOLS + : record.kind === 'device-signing-key' + ? DEVICE_SIGNING_PROTOCOLS + : undefined; + if (allowed === undefined || !allowed.has(payload.protocol)) { throw new WalletCoreError( 'INVALID_REQUEST', - 'An agreement key cannot sign protocol payloads.', + 'This key role is not authorized to sign the requested protocol payload.', ); } const signature = await this.#crypto.subtle.sign( 'Ed25519', record.privateKey, - Uint8Array.from(data), + copyBuffer(createProtocolSignaturePreimage(payload)), ); return new Uint8Array(signature); } @@ -260,7 +521,7 @@ export class InMemoryKeyVault implements KeyVault { } public deleteKey(ref: KeyRef): Promise { - this.#records.delete(ref); + this.#deleteUnlessCancelled(ref); return Promise.resolve(); } @@ -282,19 +543,29 @@ export class InMemoryKeyVault implements KeyVault { } public deleteSecret(ref: SecretRef): Promise { - this.#records.delete(ref); + this.#deleteUnlessCancelled(ref); return Promise.resolve(); } public hasKey(ref: KeyRef): Promise { const record = this.#records.get(ref); - return Promise.resolve(record !== undefined && record.kind !== 'revocation-secret'); + return Promise.resolve( + record !== undefined && + record.kind !== 'revocation-secret' && + record.kind !== 'cancelled-device-key-ref', + ); } public hasSecret(ref: SecretRef): Promise { return Promise.resolve(this.#records.get(ref)?.kind === 'revocation-secret'); } + #deleteUnlessCancelled(ref: KeyRef | SecretRef): void { + if (this.#records.get(ref)?.kind !== 'cancelled-device-key-ref') { + this.#records.delete(ref); + } + } + async #createKey(kind: StoredKeyKind): Promise { const ref = asKeyRef(newReference(this.#crypto, 'key')); this.#records.set(ref, await generateKeyRecord(this.#crypto, kind, ref)); diff --git a/packages/wallet-core/src/types.ts b/packages/wallet-core/src/types.ts index 4ba490a..82109a6 100644 --- a/packages/wallet-core/src/types.ts +++ b/packages/wallet-core/src/types.ts @@ -1,7 +1,16 @@ import type { ContinuityLinkV1, + DeviceActivationRequestV2, + DeviceAuthorizationV2, + DeviceRegistryReceiptV2, + DeviceRootRevokeRequestV2, + DeviceSelfRevokeRequestV2, + DeviceRevocationReasonCode, IdentityGenesisV1, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, NexusSubject, + OwnershipProofV2, OwnershipProofV1, ProofRequest, RegistryReceiptV1, @@ -16,12 +25,12 @@ declare const trustedWalletEventBrand: unique symbol; export type KeyRef = string & { readonly [keyRefBrand]: 'KeyRef' }; export type SecretRef = string & { readonly [secretRefBrand]: 'SecretRef' }; -export interface KeyVault { +export interface KeyVaultStorage { createSigningKey(): Promise; - sign(ref: KeyRef, data: Uint8Array): Promise; createAgreementKey(): Promise; deleteKey(ref: KeyRef): Promise; storeRevocationSecret(secret: Uint8Array): Promise; + /** @deprecated Retained for v1 compatibility. It exposes revocation material to its caller. */ readRevocationSecret(ref: SecretRef): Promise; deleteSecret(ref: SecretRef): Promise; readPublicKey(ref: KeyRef): Promise; @@ -29,6 +38,15 @@ export interface KeyVault { hasSecret(ref: SecretRef): Promise; } +/** + * Legacy custom-vault contract. Built-in vaults use a private, protocol-bound + * signing capability and intentionally do not expose these signing/import APIs. + */ +export interface KeyVault extends KeyVaultStorage { + /** @deprecated Implement only when adapting an existing v1 custom vault. */ + sign(ref: KeyRef, data: Uint8Array): Promise; +} + export interface LocalIdentityRecordV1 { localId: string; subject: NexusSubject; @@ -46,6 +64,33 @@ export interface LocalIdentityRecordV1 { localState: 'active' | 'revoked'; registrationReceipt?: RegistryReceiptV1; revocationReceipt?: RegistryReceiptV1; + /** Present only on an imported v2 device installation. */ + deviceV2?: LocalDeviceRecordV2; + /** Root-device catalogue. Labels and lifecycle hints never enter signed wire objects. */ + issuedDevicesV2?: LocalIssuedDeviceRecordV2[]; +} + +export interface LocalDeviceRecordV2 { + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + authorization: DeviceAuthorizationV2; + signingPrivateKeyRef?: KeyRef; + localState: + 'pending-import' | 'import-cleanup-pending' | 'pending-activation' | 'active' | 'revoked'; + /** Lease start used to avoid recovering an import still active in another tab. */ + importStartedAt?: number; + activationReceipt?: DeviceRegistryReceiptV2; + revocationReceipt?: DeviceRegistryReceiptV2; +} + +export interface LocalIssuedDeviceRecordV2 { + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + authorization: DeviceAuthorizationV2; + issuedAt: number; + label?: string; + localState: 'issued' | 'revoked'; + revocationReceipt?: DeviceRegistryReceiptV2; } export interface AuthorizationHistoryEntry { @@ -62,6 +107,15 @@ export interface IdentityStore { get(localId: string): Promise; list(): Promise; put(record: LocalIdentityRecordV1): Promise; + /** Atomically transforms the latest record in one storage transaction. */ + update?( + localId: string, + mutate: (record: LocalIdentityRecordV1) => LocalIdentityRecordV1, + ): Promise; + /** Atomically appends to the latest root record so concurrent issuance cannot lose entries. */ + appendIssuedDevice?(localId: string, device: LocalIssuedDeviceRecordV2): Promise; + /** Atomically rejects duplicate device IDs and creates one imported-device record. */ + reserveDeviceRecord?(record: LocalIdentityRecordV1): Promise; delete(localId: string): Promise; } @@ -83,6 +137,9 @@ export interface RegistryClient { register(request: RegistryRegisterRequest): Promise; getStatus(subject: NexusSubject): Promise; revoke(request: RevokeBySignatureRequestV1 | RevokeBySecretRequestV1): Promise; + activateDevice?(request: DeviceActivationRequestV2): Promise; + revokeDeviceSelf?(request: DeviceSelfRevokeRequestV2): Promise; + revokeDeviceRoot?(request: DeviceRootRevokeRequestV2): Promise; } /** @@ -91,6 +148,9 @@ export interface RegistryClient { */ export type RegistryReceiptVerifier = (receipt: unknown) => Promise; +/** Verifies the service signature and strict wire shape of a v2 device receipt. */ +export type DeviceRegistryReceiptVerifier = (receipt: unknown) => Promise; + export interface Clock { now(): number; } @@ -121,7 +181,68 @@ export interface LocalIdentitySummary { authorizationHistory: readonly AuthorizationHistoryEntry[]; localState: 'active' | 'revoked'; registered: boolean; + /** Role-aware readiness: active v1 root registration or active v2 device installation. */ + proofReady: boolean; hasAgreementKey: boolean; + device?: { + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + localState: LocalDeviceRecordV2['localState']; + activationDeadline: number; + expiresAt: number; + }; + issuedDevices: readonly { + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + issuedAt: number; + activationDeadline: number; + expiresAt: number; + label?: string; + localState: LocalIssuedDeviceRecordV2['localState']; + }[]; +} + +export interface IssueDeviceTransferOptions { + /** Wallet-local only; it is never signed or included in the transfer. */ + label?: string; + validFrom?: number; + activationDeadline?: number; + expiresAt?: number; +} + +export interface DeviceTransferEnvelopeV2 { + protocol: 'nexus.device-transfer.v2'; + suite: 'NX-HKDF-SHA256-AES256GCM-v2'; + bundleId: string; + salt: string; + iv: string; + ciphertext: string; +} + +export interface IssuedDeviceTransferV2 { + authorization: DeviceAuthorizationV2; + bundle: DeviceTransferEnvelopeV2; + /** A fresh 256-bit secret. Transfer this separately from the encrypted bundle. */ + /** The caller should overwrite this byte array after transferring/encoding it. */ + transferKey: Uint8Array; +} + +export interface ImportedDeviceV2 { + localId: string; + subject: NexusSubject; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + authorization: DeviceAuthorizationV2; +} + +export interface DeviceRequestOptions { + requestId?: string; + expiresAt?: number; +} + +export interface DeviceRevocationOptions { + requestId?: string; + reasonCode?: DeviceRevocationReasonCode; } export interface RevokeIdentityOptions { @@ -178,4 +299,43 @@ export interface WalletCoreApi { localIdB: string, options: ContinuityLinkOptions, ): Promise; + issueDeviceTransfer( + rootLocalId: string, + options?: IssueDeviceTransferOptions, + ): Promise; + importDeviceTransfer( + bundle: DeviceTransferEnvelopeV2, + transferKey: Uint8Array, + ): Promise; + createDeviceActivationRequest( + deviceLocalId: string, + options?: DeviceRequestOptions, + ): Promise; + activateDevice( + deviceLocalId: string, + options?: DeviceRequestOptions, + ): Promise; + proveDevice( + deviceLocalId: string, + boundary: TrustedWalletEventBoundary, + request: ProofRequest, + ): Promise; + createDeviceSelfRevokeRequest( + deviceLocalId: string, + options?: DeviceRevocationOptions, + ): Promise; + revokeDeviceSelf( + deviceLocalId: string, + options?: DeviceRevocationOptions, + ): Promise; + createDeviceRootRevokeRequest( + rootLocalId: string, + deviceId: NexusDeviceIdV2, + options?: DeviceRevocationOptions, + ): Promise; + revokeDeviceRoot( + rootLocalId: string, + deviceId: NexusDeviceIdV2, + options?: DeviceRevocationOptions, + ): Promise; } diff --git a/packages/wallet-core/src/wallet-core.ts b/packages/wallet-core/src/wallet-core.ts index da8cb70..d155d4c 100644 --- a/packages/wallet-core/src/wallet-core.ts +++ b/packages/wallet-core/src/wallet-core.ts @@ -10,18 +10,42 @@ import { base64Url32Schema, base64Url64Schema, base64UrlAtLeast16Schema, + createDeviceAuthorizationHashPreimageV2, + createDeviceIdHashPreimageV2, + decodeBase64UrlExact, + deviceActivationPayloadV2Schema, + deviceAuthorizationPayloadV2Schema, + deviceAuthorizationV2Schema, + deviceRegistryReceiptV2Schema, + deviceRootRevokePayloadV2Schema, + deviceSelfRevokePayloadV2Schema, continuityLinkPayloadV1Schema, encodeBase64Url, identityGenesisV1Schema, + formatDeviceAuthorizationIdV2, + formatDeviceIdV2, + nexusDeviceIdV2Schema, ownershipProofPayloadV1Schema, + ownershipProofPayloadV2Schema, proofRequestSchema, revokeBySecretRequestV1Schema, revokeBySignatureRequestV1Schema, type ContinuityLinkPayloadV1, type ContinuityLinkV1, + type DeviceActivationPayloadV2, + type DeviceActivationRequestV2, + type DeviceAuthorizationPayloadV2, + type DeviceAuthorizationV2, + type DeviceRegistryReceiptV2, + type DeviceRootRevokePayloadV2, + type DeviceRootRevokeRequestV2, + type DeviceSelfRevokePayloadV2, + type DeviceSelfRevokeRequestV2, type IdentityGenesisV1, type OwnershipProofPayloadV1, type OwnershipProofV1, + type OwnershipProofPayloadV2, + type OwnershipProofV2, type ProofRequest, type RegistryReceiptV1, type RevokeBySecretRequestV1, @@ -30,6 +54,7 @@ import { } from '@nexus/protocol'; import { WalletCoreError } from './errors.js'; +import { getManagedKeyVaultCapabilities } from './key-vault.js'; import { requireTrustedWalletEventBoundary } from './trusted-wallet-event.js'; import type { AuthorizationHistoryEntry, @@ -38,9 +63,17 @@ import type { CreatedLocalIdentity, CreateIdentityOptions, DisposeIdentityOptions, + DeviceRegistryReceiptVerifier, + DeviceRequestOptions, + DeviceRevocationOptions, + DeviceTransferEnvelopeV2, IdentityStore, + ImportedDeviceV2, + IssueDeviceTransferOptions, + IssuedDeviceTransferV2, KeyRef, KeyVault, + KeyVaultStorage, LocalIdentityRecordV1, LocalIdentitySummary, RegistryClient, @@ -52,6 +85,7 @@ import type { TrustedWalletEventBoundary, WalletCoreApi, } from './types.js'; +import { decryptDeviceTransfer, encryptDeviceTransfer } from './device-transfer.js'; const IDENTITY_PROTOCOL = 'nexus.identity.v1' as const; const SUITE = 'NX-25519-SHA256-JCS-v1' as const; @@ -60,14 +94,24 @@ const REVOKE_PROTOCOL = 'nexus.revoke.v1' as const; const REVOKE_SECRET_PROTOCOL = 'nexus.revoke-secret.v1' as const; const CONTINUITY_LINK_PROTOCOL = 'nexus.continuity-link.v1' as const; const MAX_AUTHORIZATION_HISTORY_ENTRIES = 200; +const DEVICE_AUTHORIZATION_PROTOCOL = 'nexus.device-authorization.v2' as const; +const DEVICE_ACTIVATION_PROTOCOL = 'nexus.device-activation.v2' as const; +const DEVICE_SELF_REVOKE_PROTOCOL = 'nexus.device-self-revoke.v2' as const; +const DEVICE_ROOT_REVOKE_PROTOCOL = 'nexus.device-root-revoke.v2' as const; +const DEVICE_PROOF_PROTOCOL = 'nexus.ownership-proof.v2' as const; +const DEFAULT_DEVICE_ACTIVATION_WINDOW_SECONDS = 30 * 24 * 60 * 60; +const DEFAULT_DEVICE_AUTHORIZATION_LIFETIME_SECONDS = 366 * 24 * 60 * 60; +const DEFAULT_DEVICE_REQUEST_LIFETIME_SECONDS = 120; +const DEVICE_IMPORT_RECOVERY_LEASE_SECONDS = 5 * 60; export interface WalletCoreOptions { - keyVault: KeyVault; + keyVault: KeyVaultStorage; identityStore: IdentityStore; registryClient: RegistryClient; verifyRegistryReceipt: RegistryReceiptVerifier; crypto?: CryptoProvider; clock?: Clock; + verifyDeviceRegistryReceipt?: DeviceRegistryReceiptVerifier; } const systemClock: Clock = { @@ -112,7 +156,7 @@ function newLocalId(cryptoProvider: CryptoProvider): string { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -function summarize(record: LocalIdentityRecordV1): LocalIdentitySummary { +function summarize(record: LocalIdentityRecordV1, now: number): LocalIdentitySummary { return { localId: record.localId, subject: record.subject, @@ -121,7 +165,34 @@ function summarize(record: LocalIdentityRecordV1): LocalIdentitySummary { authorizationHistory: structuredClone(record.authorizationHistory ?? []), localState: record.localState, registered: record.registrationReceipt !== undefined, - hasAgreementKey: record.genesis.agreementKey !== undefined, + proofReady: + record.localState === 'active' && + (record.deviceV2 === undefined + ? record.registrationReceipt !== undefined + : record.deviceV2.localState === 'active' && + now >= record.deviceV2.authorization.payload.validFrom && + now < record.deviceV2.authorization.payload.expiresAt), + hasAgreementKey: record.agreementPrivateKeyRef !== undefined, + ...(record.deviceV2 === undefined + ? {} + : { + device: { + deviceId: record.deviceV2.deviceId, + authorizationId: record.deviceV2.authorizationId, + localState: record.deviceV2.localState, + activationDeadline: record.deviceV2.authorization.payload.activationDeadline, + expiresAt: record.deviceV2.authorization.payload.expiresAt, + }, + }), + issuedDevices: (record.issuedDevicesV2 ?? []).map((device) => ({ + deviceId: device.deviceId, + authorizationId: device.authorizationId, + issuedAt: device.issuedAt, + activationDeadline: device.authorization.payload.activationDeadline, + expiresAt: device.authorization.payload.expiresAt, + ...(device.label === undefined ? {} : { label: device.label }), + localState: device.localState, + })), }; } @@ -157,12 +228,13 @@ function asCreatedIdentity(record: LocalIdentityRecordV1): CreatedLocalIdentity } export class WalletCore implements WalletCoreApi { - readonly #keyVault: KeyVault; + readonly #keyVault: KeyVaultStorage; readonly #identityStore: IdentityStore; readonly #registryClient: RegistryClient; readonly #verifyRegistryReceipt: RegistryReceiptVerifier; readonly #crypto: CryptoProvider; readonly #clock: Clock; + readonly #verifyDeviceRegistryReceipt: DeviceRegistryReceiptVerifier | undefined; public constructor(options: WalletCoreOptions) { this.#keyVault = options.keyVault; @@ -171,6 +243,7 @@ export class WalletCore implements WalletCoreApi { this.#verifyRegistryReceipt = options.verifyRegistryReceipt; this.#crypto = options.crypto ?? getDefaultCryptoProvider(); this.#clock = options.clock ?? systemClock; + this.#verifyDeviceRegistryReceipt = options.verifyDeviceRegistryReceipt; } public async createIdentity(options: CreateIdentityOptions = {}): Promise { @@ -285,7 +358,7 @@ export class WalletCore implements WalletCoreApi { sequence: 0, genesisHash: encodeBase64Url(await deriveGenesisHash(record.genesis, this.#crypto)), }); - await this.#identityStore.put({ ...record, registrationReceipt }); + await this.#updateRecord(record.localId, (latest) => ({ ...latest, registrationReceipt })); return structuredClone(registrationReceipt); } @@ -321,10 +394,7 @@ export class WalletCore implements WalletCoreApi { ? {} : { contextHash: parsedRequest.contextHash }), }); - const signature = await this.#keyVault.sign( - requireSigningRef(record), - createProtocolSignaturePreimage(payload), - ); + const signature = await this.#signProtocolPayload(requireSigningRef(record), payload); return { payload, signature: base64Url64Schema.parse(encodeBase64Url(signature)) }; } @@ -346,7 +416,7 @@ export class WalletCore implements WalletCoreApi { rememberScope: boolean, ): Promise { const audience = requireTrustedWalletEventBoundary(boundary).audience; - const record = await this.#getActiveRegisteredRecord(localId); + await this.#getActiveRegisteredRecord(localId); const parsedRequest = parseWalletInput( () => proofRequestSchema.parse(request), 'The ownership proof request is invalid.', @@ -354,31 +424,30 @@ export class WalletCore implements WalletCoreApi { const approvedAt = this.#clock.now(); validateEpochSeconds(approvedAt, 'clock.now()'); - const introducedScope = rememberScope && !record.localScopes.includes(audience); const entry: AuthorizationHistoryEntry = { authorizationId: newLocalId(this.#crypto), approvedAt, audience, action: parsedRequest.action, resource: parsedRequest.resource, - introducedScope, + introducedScope: false, contextBound: parsedRequest.contextHash !== undefined, }; - const authorizationHistory = [entry, ...(record.authorizationHistory ?? [])].slice( - 0, - MAX_AUTHORIZATION_HISTORY_ENTRIES, - ); - - await this.#identityStore.put({ - ...record, - localScopes: introducedScope ? [...record.localScopes, audience] : record.localScopes, - authorizationHistory, + await this.#updateRecord(localId, (latest) => { + const introducedScope = rememberScope && !latest.localScopes.includes(audience); + return { + ...latest, + localScopes: introducedScope ? [...latest.localScopes, audience] : latest.localScopes, + authorizationHistory: [ + { ...entry, introducedScope }, + ...(latest.authorizationHistory ?? []), + ].slice(0, MAX_AUTHORIZATION_HISTORY_ENTRIES), + }; }); } public async clearAuthorizationHistory(localId: string): Promise { - const record = await this.#getRecord(localId); - await this.#identityStore.put({ ...record, authorizationHistory: [] }); + await this.#updateRecord(localId, (latest) => ({ ...latest, authorizationHistory: [] })); } public async revoke( @@ -387,11 +456,11 @@ export class WalletCore implements WalletCoreApi { ): Promise { const record = await this.#getRecord(localId); const terminal = await this.#reachTerminalState(record, options); - await this.#identityStore.put({ - ...record, + await this.#updateRecord(localId, (latest) => ({ + ...latest, localState: 'revoked', revocationReceipt: terminal.receipt, - }); + })); return structuredClone(terminal.receipt); } @@ -405,12 +474,11 @@ export class WalletCore implements WalletCoreApi { reasonCode: options.reasonCode ?? 'dispose', }); - const revokedRecord: LocalIdentityRecordV1 = { - ...record, + await this.#updateRecord(localId, (latest) => ({ + ...latest, localState: 'revoked', revocationReceipt: terminal.receipt, - }; - await this.#identityStore.put(revokedRecord); + })); const deletions = await Promise.allSettled([ ...(record.signingPrivateKeyRef === undefined @@ -437,11 +505,13 @@ export class WalletCore implements WalletCoreApi { if (options.retainPublicRecord === false) { await this.#identityStore.delete(localId); } else { - const publicRecord = { ...revokedRecord }; - delete publicRecord.signingPrivateKeyRef; - delete publicRecord.agreementPrivateKeyRef; - delete publicRecord.revocationSecretRef; - await this.#identityStore.put(publicRecord); + await this.#updateRecord(localId, (latest) => { + const publicRecord = { ...latest }; + delete publicRecord.signingPrivateKeyRef; + delete publicRecord.agreementPrivateKeyRef; + delete publicRecord.revocationSecretRef; + return publicRecord; + }); } return structuredClone(terminal.receipt); } @@ -512,10 +582,9 @@ export class WalletCore implements WalletCoreApi { }), 'The continuity link request is invalid.', ); - const preimage = createProtocolSignaturePreimage(payload); const [signatureA, signatureB] = await Promise.all([ - this.#keyVault.sign(requireSigningRef(identityA), preimage), - this.#keyVault.sign(requireSigningRef(identityB), preimage), + this.#signProtocolPayload(requireSigningRef(identityA), payload), + this.#signProtocolPayload(requireSigningRef(identityB), payload), ]); return { payload, @@ -524,44 +593,814 @@ export class WalletCore implements WalletCoreApi { }; } + /** + * Issues one independent device capability from an existing v1 identity root. + * The only private-key output is an authenticated encrypted bundle. + */ + public async issueDeviceTransfer( + rootLocalId: string, + options: IssueDeviceTransferOptions = {}, + ): Promise { + const root = await this.#getActiveRegisteredRecord(rootLocalId); + if (root.deviceV2 !== undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'A delegated device cannot authorize another device.', + ); + } + if ( + this.#identityStore.appendIssuedDevice === undefined || + this.#identityStore.update === undefined + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'This identity store does not support atomic v2 device issuance and updates.', + ); + } + if (options.label !== undefined && options.label.length > 128) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'A local device label cannot exceed 128 characters.', + ); + } + + const now = this.#clock.now(); + validateEpochSeconds(now, 'clock.now()'); + const validFrom = options.validFrom ?? now; + const activationDeadline = + options.activationDeadline ?? now + DEFAULT_DEVICE_ACTIVATION_WINDOW_SECONDS; + const expiresAt = options.expiresAt ?? now + DEFAULT_DEVICE_AUTHORIZATION_LIFETIME_SECONDS; + validateEpochSeconds(validFrom, 'validFrom'); + validateEpochSeconds(activationDeadline, 'activationDeadline'); + validateEpochSeconds(expiresAt, 'expiresAt'); + if (validFrom > activationDeadline || activationDeadline > expiresAt) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'Device validity must satisfy validFrom <= activationDeadline <= expiresAt.', + ); + } + + const generated = await this.#crypto.generateEd25519KeyPair({ + privateKeyExtractable: true, + }); + const [publicKey, privateKeyPkcs8] = await Promise.all([ + this.#crypto.exportEd25519PublicKey(generated.publicKey), + this.#crypto.exportEd25519PrivateKey(generated.privateKey), + ]); + try { + const signingKey = { + alg: 'Ed25519' as const, + publicKey: base64Url32Schema.parse(encodeBase64Url(publicKey)), + }; + const deviceId = formatDeviceIdV2( + await this.#crypto.sha256( + createDeviceIdHashPreimageV2({ subject: root.subject, signingKey }), + ), + ); + const genesisHash = base64Url32Schema.parse( + encodeBase64Url(await deriveGenesisHash(root.genesis, this.#crypto)), + ); + const authorizationPayload: DeviceAuthorizationPayloadV2 = + deviceAuthorizationPayloadV2Schema.parse({ + protocol: DEVICE_AUTHORIZATION_PROTOCOL, + subject: root.subject, + genesisHash, + deviceId, + signingKey, + authorizationNonce: encodeBase64Url(this.#crypto.randomBytes(32)), + validFrom, + activationDeadline, + expiresAt, + }); + const rootSignature = await this.#signProtocolPayload( + requireSigningRef(root), + authorizationPayload, + ); + const authorization: DeviceAuthorizationV2 = deviceAuthorizationV2Schema.parse({ + payload: authorizationPayload, + rootSignature: encodeBase64Url(rootSignature), + }); + const authorizationId = await this.#deriveDeviceAuthorizationId(authorizationPayload); + const encrypted = await encryptDeviceTransfer( + { + subject: root.subject, + genesis: structuredClone(root.genesis), + authorization, + devicePrivateKey: { + alg: 'Ed25519', + format: 'pkcs8', + bytes: encodeBase64Url(privateKeyPkcs8), + }, + }, + this.#crypto, + ); + + try { + await this.#identityStore.appendIssuedDevice(root.localId, { + deviceId, + authorizationId, + authorization, + issuedAt: now, + ...(options.label === undefined ? {} : { label: options.label }), + localState: 'issued', + }); + } catch (error) { + encrypted.transferKey.fill(0); + throw error; + } + return { authorization, ...encrypted }; + } finally { + privateKeyPkcs8.fill(0); + } + } + + public async importDeviceTransfer( + bundle: DeviceTransferEnvelopeV2, + transferKey: Uint8Array, + ): Promise { + const managedKeyVault = getManagedKeyVaultCapabilities(this.#keyVault); + if (managedKeyVault === undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'This key vault does not support crash-safe v2 device-key import.', + ); + } + if ( + this.#identityStore.update === undefined || + this.#identityStore.reserveDeviceRecord === undefined + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'This identity store does not support atomic v2 device-key reservation.', + ); + } + const decrypted = await decryptDeviceTransfer(bundle, transferKey, this.#crypto); + const privateKeyPkcs8 = decrypted.devicePrivateKeyPkcs8; + let signingPrivateKeyRef: KeyRef | undefined; + let reservedLocalId: string | undefined; + try { + const genesis = parseWalletInput( + () => identityGenesisV1Schema.parse(decrypted.genesis), + 'The transferred v1 root genesis is invalid.', + ); + const authorization = parseWalletInput( + () => deviceAuthorizationV2Schema.parse(decrypted.authorization), + 'The transferred device authorization is invalid.', + ); + const subject = await deriveSubject(genesis, this.#crypto); + if (decrypted.subject !== subject || authorization.payload.subject !== subject) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The transferred subject does not match its root genesis and authorization.', + ); + } + const genesisHash = encodeBase64Url(await deriveGenesisHash(genesis, this.#crypto)); + if (authorization.payload.genesisHash !== genesisHash) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The transferred authorization does not match its root genesis.', + ); + } + if (authorization.payload.signingKey.publicKey === genesis.signingKey.publicKey) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The device signing key must be independent from the root signing key.', + ); + } + const deviceId = formatDeviceIdV2( + await this.#crypto.sha256( + createDeviceIdHashPreimageV2({ + subject, + signingKey: authorization.payload.signingKey, + }), + ), + ); + if (authorization.payload.deviceId !== deviceId) { + throw new WalletCoreError('INVALID_REQUEST', 'The transferred device ID is invalid.'); + } + const authorizationId = await this.#deriveDeviceAuthorizationId(authorization.payload); + const rootPublicKey = await this.#crypto.importEd25519PublicKey( + decodeBase64UrlExact(genesis.signingKey.publicKey, 32), + ); + const rootSignatureValid = await this.#crypto.verifyEd25519( + rootPublicKey, + decodeBase64UrlExact(authorization.rootSignature, 64), + createProtocolSignaturePreimage(authorization.payload), + ); + if (!rootSignatureValid) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The transferred device authorization has an invalid root signature.', + ); + } + const now = this.#clock.now(); + validateEpochSeconds(now, 'clock.now()'); + if ( + now > authorization.payload.activationDeadline || + now >= authorization.payload.expiresAt + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The transferred device authorization can no longer be activated.', + ); + } + const publicKeyBytes = decodeBase64UrlExact(authorization.payload.signingKey.publicKey, 32); + signingPrivateKeyRef = managedKeyVault.createDeviceSigningKeyRef(); + const localId = newLocalId(this.#crypto); + await this.#identityStore.reserveDeviceRecord({ + localId, + subject, + genesis, + localScopes: [], + authorizationHistory: [], + localState: 'active', + deviceV2: { + deviceId, + authorizationId, + authorization, + signingPrivateKeyRef, + localState: 'pending-import', + importStartedAt: now, + }, + }); + reservedLocalId = localId; + await managedKeyVault.importDeviceSigningKeyAtRef( + signingPrivateKeyRef, + privateKeyPkcs8, + publicKeyBytes, + ); + const finalizingRef = signingPrivateKeyRef; + await this.#identityStore.update(localId, (latest) => { + const latestDevice = latest.deviceV2; + if (latestDevice === undefined || latestDevice.signingPrivateKeyRef !== finalizingRef) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'The pending device import reservation changed unexpectedly.', + ); + } + if (latestDevice.localState === 'pending-activation') return latest; + if (latestDevice.localState !== 'pending-import') { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'The pending device import is no longer finalizable.', + ); + } + const importedDevice = { ...latestDevice }; + delete importedDevice.importStartedAt; + return { + ...latest, + deviceV2: { ...importedDevice, localState: 'pending-activation' }, + }; + }); + return { localId, subject, deviceId, authorizationId, authorization }; + } catch (error) { + if (reservedLocalId !== undefined && signingPrivateKeyRef !== undefined) { + try { + const ownsCleanup = await this.#claimImportedKeyCleanup( + reservedLocalId, + signingPrivateKeyRef, + ); + if (ownsCleanup) { + await this.#cancelManagedDeviceKeyRef(signingPrivateKeyRef); + await this.#identityStore.delete(reservedLocalId); + } + } catch (cleanupError) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'Device import failed and its temporary imported key could not be cleaned up.', + { cause: cleanupError }, + ); + } + } + throw error; + } finally { + privateKeyPkcs8.fill(0); + } + } + + public async createDeviceActivationRequest( + deviceLocalId: string, + options: DeviceRequestOptions = {}, + ): Promise { + const record = await this.#getDeviceRecord(deviceLocalId, true); + const device = record.deviceV2; + if (device.localState === 'revoked') { + throw new WalletCoreError('IDENTITY_REVOKED', 'This device is locally revoked.'); + } + const issuedAt = this.#clock.now(); + validateEpochSeconds(issuedAt, 'clock.now()'); + const expiresAt = options.expiresAt ?? issuedAt + DEFAULT_DEVICE_REQUEST_LIFETIME_SECONDS; + validateEpochSeconds(expiresAt, 'expiresAt'); + if (expiresAt <= issuedAt || expiresAt > device.authorization.payload.activationDeadline) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The activation request expiry must be within the authorization activation window.', + ); + } + const payload: DeviceActivationPayloadV2 = deviceActivationPayloadV2Schema.parse({ + protocol: DEVICE_ACTIVATION_PROTOCOL, + subject: record.subject, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + requestId: options.requestId ?? encodeBase64Url(this.#crypto.randomBytes(32)), + iat: issuedAt, + exp: expiresAt, + }); + const signature = await this.#signProtocolPayload( + this.#requireDeviceSigningRef(device), + payload, + ); + return { + authorization: structuredClone(device.authorization), + payload, + deviceSignature: base64Url64Schema.parse(encodeBase64Url(signature)), + }; + } + + public async activateDevice( + deviceLocalId: string, + options: DeviceRequestOptions = {}, + ): Promise { + if (this.#registryClient.activateDevice === undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'The registry does not support v2 devices.'); + } + const request = await this.createDeviceActivationRequest(deviceLocalId, options); + const rawReceipt = await this.#registryClient.activateDevice(request); + const receipt = await this.#verifyDeviceReceipt(rawReceipt, { + subject: request.payload.subject, + genesisHash: request.authorization.payload.genesisHash, + deviceId: request.payload.deviceId, + authorizationId: request.payload.authorizationId, + authorizationExpiresAt: request.authorization.payload.expiresAt, + eventType: 'activated', + deviceState: 'active', + }); + await this.#updateRecord(deviceLocalId, (latest) => { + if (latest.deviceV2 === undefined) return latest; + if (latest.deviceV2.localState === 'revoked') return latest; + return { + ...latest, + deviceV2: { ...latest.deviceV2, localState: 'active', activationReceipt: receipt }, + }; + }); + return structuredClone(receipt); + } + + public async proveDevice( + deviceLocalId: string, + boundary: TrustedWalletEventBoundary, + request: ProofRequest, + ): Promise { + const audience = requireTrustedWalletEventBoundary(boundary).audience; + const record = await this.#getDeviceRecord(deviceLocalId, true); + const device = record.deviceV2; + if (device.localState !== 'active') { + throw new WalletCoreError( + 'IDENTITY_NOT_REGISTERED', + 'The device must be activated before it can create ownership proofs.', + ); + } + const parsedRequest = parseWalletInput( + () => proofRequestSchema.parse(request), + 'The ownership proof request is invalid.', + ); + const issuedAt = this.#clock.now(); + validateEpochSeconds(issuedAt, 'clock.now()'); + if ( + parsedRequest.expiresAt <= issuedAt || + issuedAt < device.authorization.payload.validFrom || + parsedRequest.expiresAt > device.authorization.payload.expiresAt + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'The proof time is outside the device authorization lifetime.', + ); + } + const payload: OwnershipProofPayloadV2 = ownershipProofPayloadV2Schema.parse({ + protocol: DEVICE_PROOF_PROTOCOL, + subject: record.subject, + genesis: structuredClone(record.genesis), + deviceId: device.deviceId, + authorizationId: device.authorizationId, + authorization: structuredClone(device.authorization), + aud: audience, + act: parsedRequest.action, + resource: parsedRequest.resource, + nonce: parsedRequest.nonce, + iat: issuedAt, + exp: parsedRequest.expiresAt, + ...(parsedRequest.contextHash === undefined + ? {} + : { contextHash: parsedRequest.contextHash }), + }); + const signature = await this.#signProtocolPayload( + this.#requireDeviceSigningRef(device), + payload, + ); + return { payload, deviceSignature: base64Url64Schema.parse(encodeBase64Url(signature)) }; + } + + public async createDeviceSelfRevokeRequest( + deviceLocalId: string, + options: DeviceRevocationOptions = {}, + ): Promise { + const record = await this.#getDeviceRecord(deviceLocalId, true); + const device = record.deviceV2; + if (device.localState === 'revoked') { + throw new WalletCoreError('IDENTITY_REVOKED', 'This device is already locally revoked.'); + } + const issuedAt = this.#clock.now(); + validateEpochSeconds(issuedAt, 'clock.now()'); + const payload: DeviceSelfRevokePayloadV2 = deviceSelfRevokePayloadV2Schema.parse({ + protocol: DEVICE_SELF_REVOKE_PROTOCOL, + subject: record.subject, + genesisHash: device.authorization.payload.genesisHash, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + requestId: options.requestId ?? encodeBase64Url(this.#crypto.randomBytes(32)), + issuedAt, + ...(options.reasonCode === undefined ? {} : { reasonCode: options.reasonCode }), + }); + const signature = await this.#signProtocolPayload( + this.#requireDeviceSigningRef(device), + payload, + ); + return { + authorization: structuredClone(device.authorization), + payload, + deviceSignature: base64Url64Schema.parse(encodeBase64Url(signature)), + }; + } + + public async revokeDeviceSelf( + deviceLocalId: string, + options: DeviceRevocationOptions = {}, + ): Promise { + const existing = await this.#getRecord(deviceLocalId); + if ( + existing.deviceV2?.localState === 'revoked' && + existing.deviceV2.revocationReceipt !== undefined + ) { + const existingRef = existing.deviceV2.signingPrivateKeyRef; + if (existingRef !== undefined) { + await this.#keyVault.deleteKey(existingRef); + await this.#updateRecord(deviceLocalId, (latest) => { + if (latest.deviceV2 === undefined) return latest; + const cleaned = { ...latest.deviceV2 }; + delete cleaned.signingPrivateKeyRef; + return { ...latest, deviceV2: cleaned }; + }); + } + return structuredClone(existing.deviceV2.revocationReceipt); + } + if (this.#registryClient.revokeDeviceSelf === undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'The registry does not support v2 devices.'); + } + const request = await this.createDeviceSelfRevokeRequest(deviceLocalId, options); + const rawReceipt = await this.#registryClient.revokeDeviceSelf(request); + const receipt = await this.#verifyDeviceReceipt(rawReceipt, { + subject: request.payload.subject, + genesisHash: request.payload.genesisHash, + deviceId: request.payload.deviceId, + authorizationId: request.payload.authorizationId, + eventType: 'revoked', + deviceState: 'revoked', + revokedBy: 'device', + allowConvergedRevocation: true, + }); + const revoked = await this.#updateRecord(deviceLocalId, (current) => { + if (current.deviceV2 === undefined) return current; + return { + ...current, + deviceV2: { ...current.deviceV2, localState: 'revoked', revocationReceipt: receipt }, + }; + }); + const signingRef = revoked.deviceV2?.signingPrivateKeyRef; + if (signingRef !== undefined) { + try { + await this.#keyVault.deleteKey(signingRef); + } catch (error) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'The device is revoked, but its local key still needs cleanup.', + { cause: error }, + ); + } + await this.#updateRecord(deviceLocalId, (latest) => { + if (latest.deviceV2 === undefined) return latest; + const cleaned = { ...latest.deviceV2 }; + delete cleaned.signingPrivateKeyRef; + return { ...latest, deviceV2: cleaned }; + }); + } + return structuredClone(receipt); + } + + public async createDeviceRootRevokeRequest( + rootLocalId: string, + deviceIdInput: Parameters[1], + options: DeviceRevocationOptions = {}, + ): Promise { + const root = await this.#getActiveRegisteredRecord(rootLocalId); + if (root.deviceV2 !== undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'A delegated device has no root authority.'); + } + const deviceId = nexusDeviceIdV2Schema.parse(deviceIdInput); + const issued = (root.issuedDevicesV2 ?? []).find((device) => device.deviceId === deviceId); + if (issued === undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'This root has no local record of issuing the requested device.', + ); + } + const issuedAt = this.#clock.now(); + validateEpochSeconds(issuedAt, 'clock.now()'); + const payload: DeviceRootRevokePayloadV2 = deviceRootRevokePayloadV2Schema.parse({ + protocol: DEVICE_ROOT_REVOKE_PROTOCOL, + subject: root.subject, + genesisHash: issued.authorization.payload.genesisHash, + deviceId, + requestId: options.requestId ?? encodeBase64Url(this.#crypto.randomBytes(32)), + issuedAt, + ...(options.reasonCode === undefined ? {} : { reasonCode: options.reasonCode }), + }); + const signature = await this.#signProtocolPayload(requireSigningRef(root), payload); + return { payload, rootSignature: base64Url64Schema.parse(encodeBase64Url(signature)) }; + } + + public async revokeDeviceRoot( + rootLocalId: string, + deviceId: Parameters[1], + options: DeviceRevocationOptions = {}, + ): Promise { + if (this.#registryClient.revokeDeviceRoot === undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'The registry does not support v2 devices.'); + } + const request = await this.createDeviceRootRevokeRequest(rootLocalId, deviceId, options); + const rawReceipt = await this.#registryClient.revokeDeviceRoot(request); + const rootBeforeReceipt = await this.#getRecord(rootLocalId); + const issuedBeforeReceipt = (rootBeforeReceipt.issuedDevicesV2 ?? []).find( + (issued) => issued.deviceId === deviceId, + ); + if (issuedBeforeReceipt === undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'This root no longer has the requested device in its local catalogue.', + ); + } + const receipt = await this.#verifyDeviceReceipt(rawReceipt, { + subject: request.payload.subject, + genesisHash: request.payload.genesisHash, + deviceId: request.payload.deviceId, + authorizationId: issuedBeforeReceipt.authorizationId, + eventType: 'revoked', + deviceState: 'revoked', + revokedBy: 'root', + allowConvergedRevocation: true, + }); + const records = await this.#identityStore.list(); + for (const record of records) { + if (record.localId === rootLocalId) { + await this.#updateRecord(record.localId, (latest) => ({ + ...latest, + issuedDevicesV2: (latest.issuedDevicesV2 ?? []).map((issued) => + issued.deviceId === deviceId + ? { ...issued, localState: 'revoked' as const, revocationReceipt: receipt } + : issued, + ), + })); + } else if (record.deviceV2?.deviceId === deviceId) { + const revoked = await this.#updateRecord(record.localId, (latest) => { + if (latest.deviceV2 === undefined) return latest; + return { + ...latest, + deviceV2: { ...latest.deviceV2, localState: 'revoked', revocationReceipt: receipt }, + }; + }); + const signingRef = revoked.deviceV2?.signingPrivateKeyRef; + if (signingRef !== undefined) { + try { + await this.#keyVault.deleteKey(signingRef); + } catch (error) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'The device is revoked, but its local key still needs cleanup.', + { cause: error }, + ); + } + await this.#updateRecord(record.localId, (latest) => { + if (latest.deviceV2 === undefined) return latest; + const cleaned = { ...latest.deviceV2 }; + delete cleaned.signingPrivateKeyRef; + return { ...latest, deviceV2: cleaned }; + }); + } + } + } + return structuredClone(receipt); + } + public async listIdentitySummaries(): Promise { - return (await this.#identityStore.list()).map(summarize); + const now = this.#clock.now(); + validateEpochSeconds(now, 'clock.now()'); + await this.#recoverPendingDeviceImports(now); + return (await this.#identityStore.list()).map((record) => summarize(record, now)); } public async listIdentitySummariesForBoundary( boundary: TrustedWalletEventBoundary, ): Promise { const audience = requireTrustedWalletEventBoundary(boundary).audience; + const now = this.#clock.now(); + validateEpochSeconds(now, 'clock.now()'); + await this.#recoverPendingDeviceImports(now); return (await this.#identityStore.list()) .filter((record) => record.localScopes.includes(audience)) - .map(summarize); + .map((record) => summarize(record, now)); } public async addScope(localId: string, boundary: TrustedWalletEventBoundary): Promise { const audience = requireTrustedWalletEventBoundary(boundary).audience; - const record = await this.#getRecord(localId); - if (record.localScopes.includes(audience)) return; - await this.#identityStore.put({ ...record, localScopes: [...record.localScopes, audience] }); + await this.#updateRecord(localId, (latest) => + latest.localScopes.includes(audience) + ? latest + : { ...latest, localScopes: [...latest.localScopes, audience] }, + ); } public async removeScope(localId: string, boundary: TrustedWalletEventBoundary): Promise { const audience = requireTrustedWalletEventBoundary(boundary).audience; - const record = await this.#getRecord(localId); - await this.#identityStore.put({ - ...record, - localScopes: record.localScopes.filter((scope) => scope !== audience), - }); + await this.#updateRecord(localId, (latest) => ({ + ...latest, + localScopes: latest.localScopes.filter((scope) => scope !== audience), + })); } public async setLabel(localId: string, label?: string): Promise { - const record = await this.#getRecord(localId); - if (label === undefined) { - const withoutLabel = { ...record }; + await this.#updateRecord(localId, (latest) => { + if (label !== undefined) return { ...latest, label }; + const withoutLabel = { ...latest }; delete withoutLabel.label; - await this.#identityStore.put(withoutLabel); - return; + return withoutLabel; + }); + } + + async #updateRecord( + localId: string, + mutate: (record: LocalIdentityRecordV1) => LocalIdentityRecordV1, + ): Promise { + if (this.#identityStore.update !== undefined) { + return this.#identityStore.update(localId, mutate); + } + // Compatibility path for existing v1-only custom stores. V2 issuance + // requires the atomic capability and never reaches this fallback. + const current = await this.#getRecord(localId); + const updated = mutate(current); + if (updated.localId !== localId) { + throw new WalletCoreError('STORAGE_ERROR', 'An identity update cannot change its local ID.'); + } + await this.#identityStore.put(updated); + return structuredClone(updated); + } + + async #recoverPendingDeviceImports(now: number): Promise { + if (this.#identityStore.update === undefined) return; + const records = await this.#identityStore.list(); + for (const record of records) { + const device = record.deviceV2; + if (device?.localState === 'import-cleanup-pending') { + const ref = device.signingPrivateKeyRef; + if (ref !== undefined) await this.#cancelManagedDeviceKeyRef(ref); + await this.#identityStore.delete(record.localId); + continue; + } + if (device?.localState !== 'pending-import') continue; + const startedAt = device.importStartedAt ?? 0; + if (now < startedAt + DEVICE_IMPORT_RECOVERY_LEASE_SECONDS) continue; + const ref = device.signingPrivateKeyRef; + if (ref !== undefined && (await this.#keyVault.hasKey(ref))) { + await this.#identityStore.update(record.localId, (latest) => { + if ( + latest.deviceV2?.localState !== 'pending-import' || + latest.deviceV2.signingPrivateKeyRef !== ref + ) { + return latest; + } + const recovered = { ...latest.deviceV2 }; + delete recovered.importStartedAt; + return { + ...latest, + deviceV2: { ...recovered, localState: 'pending-activation' }, + }; + }); + } else { + // Atomically claim the still-pending reservation before deleting it. + // The importer may have inserted its key and promoted the record since + // the hasKey observation; a promoted record must win and be preserved. + const claimed = await this.#identityStore.update(record.localId, (latest) => { + if ( + latest.deviceV2?.localState !== 'pending-import' || + latest.deviceV2.signingPrivateKeyRef !== ref + ) { + return latest; + } + return { + ...latest, + deviceV2: { + ...latest.deviceV2, + localState: 'import-cleanup-pending', + importStartedAt: 0, + }, + }; + }); + if ( + claimed.deviceV2?.localState === 'import-cleanup-pending' && + claimed.deviceV2.signingPrivateKeyRef === ref + ) { + if (ref !== undefined) await this.#cancelManagedDeviceKeyRef(ref); + await this.#identityStore.delete(record.localId); + } + } } - await this.#identityStore.put({ ...record, label }); + } + + async #claimImportedKeyCleanup(localId: string, ref: KeyRef): Promise { + if (this.#identityStore.update === undefined) return false; + const durable = await this.#identityStore.get(localId); + if (durable === undefined) { + // The reference was preallocated uniquely for this import. If recovery + // already removed the reservation before insertion, this importer alone + // owns cleanup of anything subsequently stored under that ref. + return true; + } + if ( + durable.deviceV2?.localState === 'import-cleanup-pending' && + durable.deviceV2.signingPrivateKeyRef === ref + ) { + return true; + } + if ( + durable.deviceV2?.localState !== 'pending-import' || + durable.deviceV2.signingPrivateKeyRef !== ref + ) { + return false; + } + try { + const claimed = await this.#identityStore.update(localId, (latest) => { + if ( + latest.deviceV2?.localState !== 'pending-import' || + latest.deviceV2.signingPrivateKeyRef !== ref + ) { + return latest; + } + return { + ...latest, + deviceV2: { + ...latest.deviceV2, + localState: 'import-cleanup-pending', + importStartedAt: 0, + }, + }; + }); + return ( + claimed.deviceV2?.localState === 'import-cleanup-pending' && + claimed.deviceV2.signingPrivateKeyRef === ref + ); + } catch (error) { + if (error instanceof WalletCoreError && error.code === 'IDENTITY_NOT_FOUND') return true; + throw error; + } + } + + async #cancelManagedDeviceKeyRef(ref: KeyRef): Promise { + const managed = getManagedKeyVaultCapabilities(this.#keyVault); + if (managed === undefined) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'A pending v2 device import requires its managed key-vault cancellation capability.', + ); + } + await managed.cancelDeviceSigningKeyRef(ref); + } + + #signProtocolPayload(ref: KeyRef, payload: { readonly protocol: string }): Promise { + const managed = getManagedKeyVaultCapabilities(this.#keyVault); + if (managed !== undefined) return managed.signProtocolPayload(ref, payload); + if ( + payload.protocol !== OWNERSHIP_PROOF_PROTOCOL && + payload.protocol !== CONTINUITY_LINK_PROTOCOL && + payload.protocol !== REVOKE_PROTOCOL + ) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'A legacy custom key vault cannot sign v2 protocol payloads.', + ); + } + const legacySign = (this.#keyVault as Partial).sign; + if (legacySign === undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'This key vault cannot sign protocol payloads.'); + } + return legacySign.call(this.#keyVault, ref, createProtocolSignaturePreimage(payload)); } async #getRecord(localId: string): Promise { @@ -587,6 +1426,124 @@ export class WalletCore implements WalletCoreApi { return record; } + async #getDeviceRecord( + localId: string, + requireSigningMaterial: boolean, + ): Promise }> { + const record = await this.#getRecord(localId); + if (record.localState !== 'active') { + throw new WalletCoreError('IDENTITY_REVOKED', 'This identity is locally marked as revoked.'); + } + if (record.deviceV2 === undefined) { + throw new WalletCoreError('INVALID_REQUEST', 'This local identity is not a v2 device.'); + } + if ( + record.deviceV2.localState === 'pending-import' || + record.deviceV2.localState === 'import-cleanup-pending' + ) { + throw new WalletCoreError( + 'STORAGE_ERROR', + 'This device import is incomplete and awaits recovery.', + ); + } + if (requireSigningMaterial && record.deviceV2.signingPrivateKeyRef === undefined) { + throw new WalletCoreError( + 'IDENTITY_REVOKED', + 'This device no longer has active signing material.', + ); + } + return { ...record, deviceV2: record.deviceV2 }; + } + + #requireDeviceSigningRef(device: NonNullable): KeyRef { + if (device.signingPrivateKeyRef === undefined) { + throw new WalletCoreError( + 'IDENTITY_REVOKED', + 'This device no longer has active signing material.', + ); + } + return device.signingPrivateKeyRef; + } + + async #deriveDeviceAuthorizationId( + payload: DeviceAuthorizationPayloadV2, + ): Promise> { + return formatDeviceAuthorizationIdV2( + await this.#crypto.sha256(createDeviceAuthorizationHashPreimageV2(payload)), + ); + } + + async #verifyDeviceReceipt( + rawReceipt: unknown, + expectation: { + subject: LocalIdentityRecordV1['subject']; + genesisHash: string; + deviceId: NonNullable['deviceId']; + authorizationId?: NonNullable['authorizationId']; + authorizationExpiresAt?: number; + eventType: 'activated' | 'revoked'; + deviceState: 'active' | 'revoked'; + revokedBy?: 'root' | 'device'; + allowConvergedRevocation?: boolean; + }, + ): Promise { + if (this.#verifyDeviceRegistryReceipt === undefined) { + throw new WalletCoreError( + 'INVALID_REQUEST', + 'No v2 device registry receipt verifier is configured.', + ); + } + let receipt: DeviceRegistryReceiptV2; + try { + receipt = deviceRegistryReceiptV2Schema.parse( + await this.#verifyDeviceRegistryReceipt(rawReceipt), + ); + } catch (error) { + throw new WalletCoreError( + 'INVALID_REGISTRY_RECEIPT', + 'The device registry receipt did not pass cryptographic verification.', + { cause: error }, + ); + } + const payload = receipt.payload; + const isConvergedRevocation = + expectation.allowConvergedRevocation === true && + expectation.eventType === 'revoked' && + expectation.deviceState === 'revoked' && + payload.eventType === 'revoked' && + payload.deviceState === 'revoked'; + const hasValidConvergedAuthority = + isConvergedRevocation && + (payload.revokedBy === 'root' || + (payload.revokedBy === 'device' && payload.authorizationId !== undefined)); + const hasMatchingAuthorization = + expectation.authorizationId === undefined || + payload.authorizationId === undefined || + payload.authorizationId === expectation.authorizationId; + if ( + payload.subject !== expectation.subject || + payload.genesisHash !== expectation.genesisHash || + payload.identityState !== 'active' || + payload.identitySequence !== 0 || + payload.deviceId !== expectation.deviceId || + payload.eventType !== expectation.eventType || + payload.deviceState !== expectation.deviceState || + (expectation.authorizationExpiresAt !== undefined && + payload.authorizationExpiresAt !== expectation.authorizationExpiresAt) || + (isConvergedRevocation + ? !hasValidConvergedAuthority || !hasMatchingAuthorization + : payload.revokedBy !== expectation.revokedBy || + (expectation.authorizationId !== undefined && + payload.authorizationId !== expectation.authorizationId)) + ) { + throw new WalletCoreError( + 'INVALID_REGISTRY_RECEIPT', + 'The verified device receipt does not confirm the expected transition.', + ); + } + return receipt; + } + async #verifyReceipt( rawReceipt: unknown, expectation: { @@ -729,10 +1686,7 @@ export class WalletCore implements WalletCoreApi { ...(options.reasonCode === undefined ? {} : { reasonCode: options.reasonCode }), }; validateEpochSeconds(payload.iat, 'clock.now()'); - const signature = await this.#keyVault.sign( - requireSigningRef(record), - createProtocolSignaturePreimage(payload), - ); + const signature = await this.#signProtocolPayload(requireSigningRef(record), payload); request = revokeBySignatureRequestV1Schema.parse({ mode: 'signature', payload, diff --git a/packages/wallet-core/test/device-v2.test.ts b/packages/wallet-core/test/device-v2.test.ts new file mode 100644 index 0000000..4d7b43d --- /dev/null +++ b/packages/wallet-core/test/device-v2.test.ts @@ -0,0 +1,1476 @@ +import { IDBFactory } from 'fake-indexeddb'; +import { describe, expect, it } from 'vitest'; + +import { + createProtocolSignaturePreimage, + getDefaultCryptoProvider, + WebCryptoProvider, +} from '@nexus/crypto'; +import { + createDeviceIdHashPreimageV2, + base64Url64Schema, + decodeBase64Url, + decodeBase64UrlExact, + deviceRegistryReceiptV2Schema, + encodeBase64Url, + formatDeviceIdV2, + proofRequestSchema, + type DeviceActivationRequestV2, + type DeviceAuthorizationPayloadV2, + type DeviceRegistryReceiptV2, + type DeviceRootRevokeRequestV2, + type DeviceSelfRevokeRequestV2, + type NexusDeviceAuthorizationIdV2, + type NexusDeviceIdV2, + type NexusSubject, + type RevokeBySecretRequestV1, + type RevokeBySignatureRequestV1, +} from '@nexus/protocol'; + +import { + acceptUnsignedInMemoryRegistryReceipt, + captureWalletEventBoundary, + InMemoryIdentityStore, + InMemoryKeyVault, + InMemoryRegistryClient, + IndexedDbIdentityStore, + WalletCore, + WebCryptoIndexedDbKeyVault, + type DeviceTransferEnvelopeV2, + type IdentityStore, + type KeyVault, + type RegistryClient, + type RegistryIdentityStatus, + type RegistryRegisterRequest, +} from '../src/index.js'; +import { decryptDeviceTransferPlaintext, encryptDeviceTransfer } from '../src/device-transfer.js'; +import { getManagedKeyVaultCapabilities, signManagedProtocolPayload } from '../src/key-vault.js'; + +const NOW = 1_786_400_000; + +function boundary(origin: string) { + return captureWalletEventBoundary(new MessageEvent('message', { origin })); +} + +function createHarness() { + const keyVault = new InMemoryKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + return { wallet, keyVault, identityStore }; +} + +function deviceReceipt( + eventType: 'activated' | 'revoked', + subject: NexusSubject, + deviceId: NexusDeviceIdV2, + genesisHash: string, + authorizationId?: NexusDeviceAuthorizationIdV2, + authorizationExpiresAt?: number, +): DeviceRegistryReceiptV2 { + return deviceRegistryReceiptV2Schema.parse({ + payload: { + protocol: 'nexus.device-registry-receipt.v2', + eventId: `nxde2_${encodeBase64Url(new Uint8Array(32).fill(eventType === 'activated' ? 1 : 2))}`, + operationId: `nxo2_${encodeBase64Url(new Uint8Array(32).fill(eventType === 'activated' ? 3 : 4))}`, + eventType, + subject, + genesisHash, + identitySequence: 0, + identityState: 'active', + deviceLedgerSequence: eventType === 'activated' ? 1 : 2, + deviceId, + ...(authorizationId === undefined ? {} : { authorizationId }), + ...(authorizationExpiresAt === undefined ? {} : { authorizationExpiresAt }), + deviceState: eventType === 'activated' ? 'active' : 'revoked', + ...(eventType === 'revoked' + ? { revokedBy: authorizationId === undefined ? ('root' as const) : ('device' as const) } + : {}), + acceptedAt: NOW, + signerKid: 'test-device-registry', + }, + signature: encodeBase64Url(new Uint8Array(64).fill(5)), + }); +} + +class DeviceRegistryClient implements RegistryClient { + readonly #delegate = new InMemoryRegistryClient({ clock: { now: () => NOW } }); + #terminalReceipt: DeviceRegistryReceiptV2 | undefined; + + public register(request: RegistryRegisterRequest): Promise { + return this.#delegate.register(request); + } + + public getStatus(subject: NexusSubject): Promise { + return this.#delegate.getStatus(subject); + } + + public revoke(request: RevokeBySignatureRequestV1 | RevokeBySecretRequestV1): Promise { + return this.#delegate.revoke(request); + } + + public activateDevice(request: DeviceActivationRequestV2): Promise { + return Promise.resolve( + deviceReceipt( + 'activated', + request.payload.subject, + request.payload.deviceId, + request.authorization.payload.genesisHash, + request.payload.authorizationId, + request.authorization.payload.expiresAt, + ), + ); + } + + public revokeDeviceSelf(request: DeviceSelfRevokeRequestV2): Promise { + this.#terminalReceipt ??= deviceReceipt( + 'revoked', + request.payload.subject, + request.payload.deviceId, + request.payload.genesisHash, + request.payload.authorizationId, + ); + return Promise.resolve(this.#terminalReceipt); + } + + public revokeDeviceRoot(request: DeviceRootRevokeRequestV2): Promise { + this.#terminalReceipt ??= deviceReceipt( + 'revoked', + request.payload.subject, + request.payload.deviceId, + request.payload.genesisHash, + ); + return Promise.resolve(this.#terminalReceipt); + } +} + +class FlakyDeleteKeyVault extends InMemoryKeyVault { + public failDeletes = false; + + public override deleteKey(ref: Parameters[0]): Promise { + if (this.failDeletes) return Promise.reject(new Error('simulated key deletion failure')); + return super.deleteKey(ref); + } +} + +class FailAfterDeviceImportStore extends InMemoryIdentityStore { + public failFinalization = true; + + public override update( + localId: string, + mutate: Parameters[1], + ): ReturnType { + const current = this.get(localId); + return current.then((record) => { + if (this.failFinalization && record?.deviceV2?.localState === 'pending-import') { + return Promise.reject(new Error('simulated device-import finalization failure')); + } + return super.update(localId, mutate); + }); + } +} + +class RejectCatalogueAppendStore extends InMemoryIdentityStore { + public override appendIssuedDevice(): Promise { + return Promise.reject(new Error('simulated catalogue append failure')); + } +} + +class TrackingCryptoProvider extends WebCryptoProvider { + public readonly randomOutputs: Uint8Array[] = []; + + public override randomBytes(length: number): Uint8Array { + const bytes = super.randomBytes(length); + this.randomOutputs.push(bytes); + return bytes; + } +} + +class PausingImportFinalizationStore extends InMemoryIdentityStore { + readonly paused: Promise; + #signalPaused: (() => void) | undefined; + #release: (() => void) | undefined; + #pauseOnce = true; + + public constructor() { + super(); + this.paused = new Promise((resolve) => { + this.#signalPaused = resolve; + }); + } + + public release(): void { + this.#release?.(); + } + + public override async update( + localId: string, + mutate: Parameters[1], + ): ReturnType { + const current = await this.get(localId); + if (this.#pauseOnce && current?.deviceV2?.localState === 'pending-import') { + this.#pauseOnce = false; + this.#signalPaused?.(); + await new Promise((resolve) => { + this.#release = resolve; + }); + } + return super.update(localId, mutate); + } +} + +class PausingFalseKeyObservationVault extends InMemoryKeyVault { + readonly falseObserved: Promise; + #signalFalseObserved: (() => void) | undefined; + #releaseObservation: (() => void) | undefined; + #pauseOnce = true; + + public constructor(options: ConstructorParameters[0]) { + super(options); + this.falseObserved = new Promise((resolve) => { + this.#signalFalseObserved = resolve; + }); + } + + public releaseObservation(): void { + this.#releaseObservation?.(); + } + + public override async hasKey(ref: Parameters[0]): Promise { + const observed = await super.hasKey(ref); + if (this.#pauseOnce && !observed) { + this.#pauseOnce = false; + this.#signalFalseObserved?.(); + await new Promise((resolve) => { + this.#releaseObservation = resolve; + }); + } + return observed; + } +} + +describe('v2 root-authorized devices', () => { + it('issues only an encrypted transfer and records the device in the root catalogue', async () => { + const { wallet, identityStore } = createHarness(); + const root = await wallet.createIdentity(); + + const first = await wallet.issueDeviceTransfer(root.localId, { label: 'Travel laptop' }); + const second = await wallet.issueDeviceTransfer(root.localId); + + expect(first.transferKey).toHaveLength(32); + expect(first.bundle.protocol).toBe('nexus.device-transfer.v2'); + expect(first.bundle.ciphertext).not.toContain(root.subject); + expect(JSON.stringify(first.bundle)).not.toContain('privateKey'); + expect(first.authorization.payload.subject).toBe(root.subject); + expect(second.authorization.payload.deviceId).not.toBe(first.authorization.payload.deviceId); + + const record = await identityStore.get(root.localId); + expect(record?.issuedDevicesV2).toHaveLength(2); + expect(record?.issuedDevicesV2?.[0]).toMatchObject({ + deviceId: first.authorization.payload.deviceId, + label: 'Travel laptop', + localState: 'issued', + }); + expect( + await wallet.createDeviceRootRevokeRequest( + root.localId, + first.authorization.payload.deviceId, + ), + ).toMatchObject({ + payload: { + protocol: 'nexus.device-root-revoke.v2', + subject: root.subject, + deviceId: first.authorization.payload.deviceId, + }, + }); + }); + + it('encrypts an exact device-only transfer shape with no root private material', async () => { + const { wallet } = createHarness(); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const plaintext = await decryptDeviceTransferPlaintext( + issued.bundle, + issued.transferKey, + getDefaultCryptoProvider(), + ); + + expect(Object.keys(plaintext).sort()).toEqual([ + 'authorization', + 'devicePrivateKey', + 'genesis', + 'protocol', + 'subject', + ]); + expect(Object.keys(plaintext.devicePrivateKey).sort()).toEqual(['alg', 'bytes', 'format']); + expect(plaintext.devicePrivateKey).toMatchObject({ alg: 'Ed25519', format: 'pkcs8' }); + expect(plaintext.genesis.signingKey).toEqual(root.genesis.signingKey); + expect(JSON.stringify(plaintext)).not.toContain('rootPrivate'); + expect(JSON.stringify(plaintext)).not.toContain('revocationSecret'); + expect(JSON.stringify(plaintext)).not.toContain('authorizationHistory'); + expect(JSON.stringify(plaintext)).not.toContain('localScopes'); + }); + + it('atomically preserves every catalogue entry during concurrent issuance', async () => { + const { wallet, identityStore } = createHarness(); + const root = await wallet.createIdentity(); + + const issued = await Promise.all( + Array.from({ length: 20 }, (_, index) => + wallet.issueDeviceTransfer(root.localId, { label: `Device ${String(index)}` }), + ), + ); + + const record = await identityStore.get(root.localId); + const catalog = record?.issuedDevicesV2 ?? []; + expect(catalog).toHaveLength(20); + expect(new Set(catalog.map((device) => device.deviceId)).size).toBe(20); + expect(new Set(issued.map((entry) => entry.authorization.payload.deviceId))).toEqual( + new Set(catalog.map((device) => device.deviceId)), + ); + }); + + it('bounds the root-local issued-device catalogue', async () => { + const { wallet, identityStore } = createHarness(); + const root = await wallet.createIdentity(); + const first = await wallet.issueDeviceTransfer(root.localId); + const record = await identityStore.get(root.localId); + const issuedEntry = record?.issuedDevicesV2?.[0]; + if (issuedEntry === undefined) throw new Error('Expected an issued-device catalogue entry.'); + await identityStore.update(root.localId, (latest) => ({ + ...latest, + issuedDevicesV2: Array.from({ length: 256 }, () => structuredClone(issuedEntry)), + })); + + await expect(wallet.issueDeviceTransfer(root.localId)).rejects.toMatchObject({ + code: 'STORAGE_ERROR', + }); + expect(first.transferKey).toHaveLength(32); + }); + + it('zeros the generated transfer key if catalogue persistence fails', async () => { + const crypto = new TrackingCryptoProvider(); + const identityStore = new RejectCatalogueAppendStore(); + const wallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + crypto, + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + crypto.randomOutputs.length = 0; + + await expect(wallet.issueDeviceTransfer(root.localId)).rejects.toThrow( + 'simulated catalogue append failure', + ); + const thirtyTwoByteOutputs = crypto.randomOutputs.filter((bytes) => bytes.byteLength === 32); + // authorization nonce, transfer key, bundle ID, and HKDF salt + expect(thirtyTwoByteOutputs).toHaveLength(4); + expect(thirtyTwoByteOutputs[1]).toEqual(new Uint8Array(32)); + }); + + it('preserves issuance, label, and scope across concurrent transactional updates', async () => { + const indexedDB = new IDBFactory(); + const databaseName = 'wallet-v2-concurrent-mutations'; + const registryClient = new InMemoryRegistryClient({ clock: { now: () => NOW } }); + const createWallet = () => + new WalletCore({ + keyVault: new WebCryptoIndexedDbKeyVault({ databaseName, indexedDB }), + identityStore: new IndexedDbIdentityStore({ databaseName, indexedDB }), + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const first = createWallet(); + const second = createWallet(); + const root = await first.createIdentity(); + const rp = boundary('https://rp-a.test'); + + const [issued] = await Promise.all([ + first.issueDeviceTransfer(root.localId), + second.setLabel(root.localId, 'Concurrent root'), + second.addScope(root.localId, rp), + ]); + + const summary = (await first.listIdentitySummaries()).find( + (entry) => entry.localId === root.localId, + ); + expect(summary).toMatchObject({ label: 'Concurrent root' }); + expect(summary?.localScopes).toContain('https://rp-a.test'); + expect(summary?.issuedDevices.map((entry) => entry.deviceId)).toContain( + issued.authorization.payload.deviceId, + ); + }); + + it('imports a device non-extractably, proves possession, and preserves the v1 subject', async () => { + const { wallet, identityStore, keyVault } = createHarness(); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const imported = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + + expect(imported.subject).toBe(root.subject); + expect(imported.deviceId).toBe(issued.authorization.payload.deviceId); + const record = await identityStore.get(imported.localId); + expect(record?.signingPrivateKeyRef).toBeUndefined(); + expect(record?.deviceV2?.localState).toBe('pending-activation'); + expect(record?.deviceV2?.signingPrivateKeyRef).toBeDefined(); + const importedSummary = (await wallet.listIdentitySummaries()).find( + (summary) => summary.localId === imported.localId, + ); + expect(importedSummary?.hasAgreementKey).toBe(false); + + const proofRequest = proofRequestSchema.parse({ + action: 'post.edit', + resource: 'post:01JABC', + nonce: 'AQEBAQEBAQEBAQEBAQEBAQ', + expiresAt: NOW + 60, + }); + await expect( + wallet.proveDevice(imported.localId, boundary('https://rp-a.test'), proofRequest), + ).rejects.toMatchObject({ code: 'IDENTITY_NOT_REGISTERED' }); + + const activation = await wallet.createDeviceActivationRequest(imported.localId); + const crypto = getDefaultCryptoProvider(); + const publicKey = await crypto.importEd25519PublicKey( + decodeBase64UrlExact(issued.authorization.payload.signingKey.publicKey, 32), + ); + expect( + await crypto.verifyEd25519( + publicKey, + decodeBase64UrlExact(activation.deviceSignature, 64), + createProtocolSignaturePreimage(activation.payload), + ), + ).toBe(true); + + const deviceRef = record?.deviceV2?.signingPrivateKeyRef; + if (deviceRef === undefined) throw new Error('Expected imported device key reference.'); + expect(await keyVault.hasKey(deviceRef)).toBe(true); + + if (record?.deviceV2 === undefined) throw new Error('Expected imported device metadata.'); + await identityStore.put({ + ...record, + deviceV2: { ...record.deviceV2, localState: 'active' }, + }); + const proof = await wallet.proveDevice( + imported.localId, + boundary('https://rp-a.test'), + proofRequest, + ); + expect(proof.payload.subject).toBe(root.subject); + expect(proof.payload.deviceId).toBe(imported.deviceId); + expect( + await crypto.verifyEd25519( + publicKey, + decodeBase64UrlExact(proof.deviceSignature, 64), + createProtocolSignaturePreimage(proof.payload), + ), + ).toBe(true); + + const expiredWallet = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => issued.authorization.payload.expiresAt }, + }); + expect( + (await expiredWallet.listIdentitySummaries()).find( + (summary) => summary.localId === imported.localId, + )?.proofReady, + ).toBe(false); + }); + + it('atomically permits only one concurrent installation of the same device key', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const keyVault = new InMemoryKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const target = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + + const results = await Promise.allSettled([ + target.importDeviceTransfer(issued.bundle, issued.transferKey), + target.importDeviceTransfer(issued.bundle, issued.transferKey), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + expect( + (await identityStore.list()).filter((record) => record.deviceV2 !== undefined), + ).toHaveLength(1); + }); + + it('atomically reserves one device ID across two IndexedDB store instances', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const indexedDB = new IDBFactory(); + const databaseName = 'wallet-v2-cross-instance-reservation'; + const createTarget = () => + new WalletCore({ + keyVault: new WebCryptoIndexedDbKeyVault({ databaseName, indexedDB }), + identityStore: new IndexedDbIdentityStore({ databaseName, indexedDB }), + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const results = await Promise.allSettled([ + createTarget().importDeviceTransfer(issued.bundle, issued.transferKey), + createTarget().importDeviceTransfer(issued.bundle, issued.transferKey), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + const records = await new IndexedDbIdentityStore({ databaseName, indexedDB }).list(); + expect( + records.filter( + (record) => record.deviceV2?.deviceId === issued.authorization.payload.deviceId, + ), + ).toHaveLength(1); + }); + + it('durably tracks a key when import finalization and cleanup both fail', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const keyVault = new FlakyDeleteKeyVault(); + keyVault.failDeletes = true; + const identityStore = new FailAfterDeviceImportStore(); + const target = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + + await expect( + target.importDeviceTransfer(issued.bundle, issued.transferKey), + ).rejects.toMatchObject({ code: 'STORAGE_ERROR' }); + const pending = (await identityStore.list()).find( + (record) => record.deviceV2?.deviceId === issued.authorization.payload.deviceId, + ); + expect(pending?.deviceV2).toMatchObject({ localState: 'pending-import' }); + expect(pending?.deviceV2?.signingPrivateKeyRef).toBeDefined(); + if (pending?.deviceV2?.signingPrivateKeyRef === undefined) { + throw new Error('Expected a durable pending-import key reference.'); + } + expect(await keyVault.hasKey(pending.deviceV2.signingPrivateKeyRef)).toBe(true); + }); + + it('keeps the key when lease recovery promotes an import before its original finalizer resumes', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const keyVault = new InMemoryKeyVault(); + const identityStore = new PausingImportFinalizationStore(); + const registryClient = new InMemoryRegistryClient({ clock: { now: () => NOW } }); + const importer = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const recovery = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW + 301 }, + }); + + const importing = importer.importDeviceTransfer(issued.bundle, issued.transferKey); + await identityStore.paused; + const pending = (await identityStore.list()).find( + (record) => record.deviceV2?.deviceId === issued.authorization.payload.deviceId, + ); + const ref = pending?.deviceV2?.signingPrivateKeyRef; + if (ref === undefined) throw new Error('Expected pending import reference.'); + if (pending === undefined) throw new Error('Expected pending import record.'); + expect(await keyVault.hasKey(ref)).toBe(true); + + await recovery.listIdentitySummaries(); + expect((await identityStore.get(pending.localId))?.deviceV2?.localState).toBe( + 'pending-activation', + ); + identityStore.release(); + const imported = await importing; + expect(imported.localId).toBe(pending.localId); + expect(await keyVault.hasKey(ref)).toBe(true); + expect((await identityStore.get(imported.localId))?.deviceV2?.signingPrivateKeyRef).toBe(ref); + await expect(importer.createDeviceActivationRequest(imported.localId)).resolves.toMatchObject({ + payload: { deviceId: imported.deviceId }, + }); + }); + + it('removes a late-inserted key when lease recovery deleted its reservation first', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + let signalPaused: (() => void) | undefined; + let release: (() => void) | undefined; + const paused = new Promise((resolve) => { + signalPaused = resolve; + }); + const keyVault = new InMemoryKeyVault({ + beforeDeviceKeyImport: async () => { + signalPaused?.(); + await new Promise((resolve) => { + release = resolve; + }); + }, + }); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new InMemoryRegistryClient({ clock: { now: () => NOW } }); + const importer = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const recovery = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW + 301 }, + }); + + const importing = importer.importDeviceTransfer(issued.bundle, issued.transferKey); + await paused; + const pending = (await identityStore.list()).find( + (record) => record.deviceV2?.deviceId === issued.authorization.payload.deviceId, + ); + const ref = pending?.deviceV2?.signingPrivateKeyRef; + if (pending === undefined || ref === undefined) { + throw new Error('Expected a pending import reservation with a key reference.'); + } + expect(await keyVault.hasKey(ref)).toBe(false); + + await recovery.listIdentitySummaries(); + expect(await identityStore.get(pending.localId)).toBeUndefined(); + + release?.(); + await expect(importing).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + expect(await keyVault.hasKey(ref)).toBe(false); + expect(await identityStore.get(pending.localId)).toBeUndefined(); + }); + + it('durably rejects a late key insertion after recovery cancels its reference', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const plaintext = await decryptDeviceTransferPlaintext( + issued.bundle, + issued.transferKey, + getDefaultCryptoProvider(), + ); + const sourceRecord = await source.identityStore.get(root.localId); + const issuedRecord = sourceRecord?.issuedDevicesV2?.find( + (device) => device.deviceId === issued.authorization.payload.deviceId, + ); + if (issuedRecord === undefined) throw new Error('Expected issued-device catalogue record.'); + + const keyVault = new InMemoryKeyVault(); + const managed = getManagedKeyVaultCapabilities(keyVault); + if (managed === undefined) throw new Error('Expected managed key-vault capability.'); + const ref = managed.createDeviceSigningKeyRef(); + const identityStore = new InMemoryIdentityStore(); + const localId = '00000000-0000-4000-8000-000000000001'; + await identityStore.reserveDeviceRecord({ + localId, + subject: root.subject, + genesis: root.genesis, + localScopes: [], + authorizationHistory: [], + localState: 'active', + deviceV2: { + deviceId: issued.authorization.payload.deviceId, + authorizationId: issuedRecord.authorizationId, + authorization: issued.authorization, + signingPrivateKeyRef: ref, + localState: 'pending-import', + importStartedAt: NOW, + }, + }); + const recovery = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW + 301 }, + }); + + await recovery.listIdentitySummaries(); + expect(await identityStore.get(localId)).toBeUndefined(); + await expect( + managed.importDeviceSigningKeyAtRef( + ref, + decodeBase64Url(plaintext.devicePrivateKey.bytes), + decodeBase64UrlExact(issued.authorization.payload.signingKey.publicKey, 32), + ), + ).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + expect(await keyVault.hasKey(ref)).toBe(false); + await keyVault.deleteKey(ref); + await keyVault.deleteSecret(ref as unknown as Parameters[0]); + await expect( + managed.importDeviceSigningKeyAtRef( + ref, + decodeBase64Url(plaintext.devicePrivateKey.bytes), + decodeBase64UrlExact(issued.authorization.payload.signingKey.publicKey, 32), + ), + ).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + expect(await keyVault.hasKey(ref)).toBe(false); + }); + + it('persists canceled refs across IndexedDB vault instances and rejects late insertion', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const plaintext = await decryptDeviceTransferPlaintext( + issued.bundle, + issued.transferKey, + getDefaultCryptoProvider(), + ); + const issuedRecord = (await source.identityStore.get(root.localId))?.issuedDevicesV2?.find( + (device) => device.deviceId === issued.authorization.payload.deviceId, + ); + if (issuedRecord === undefined) throw new Error('Expected issued-device catalogue record.'); + + const indexedDB = new IDBFactory(); + const databaseName = 'wallet-v2-cancelled-device-key-ref'; + const recoveryVault = new WebCryptoIndexedDbKeyVault({ databaseName, indexedDB }); + const lateVault = new WebCryptoIndexedDbKeyVault({ databaseName, indexedDB }); + const managedRecovery = getManagedKeyVaultCapabilities(recoveryVault); + const managedLate = getManagedKeyVaultCapabilities(lateVault); + if (managedRecovery === undefined || managedLate === undefined) { + throw new Error('Expected managed IndexedDB key-vault capabilities.'); + } + const ref = managedRecovery.createDeviceSigningKeyRef(); + const identityStore = new IndexedDbIdentityStore({ databaseName, indexedDB }); + const localId = '00000000-0000-4000-8000-000000000002'; + await identityStore.reserveDeviceRecord({ + localId, + subject: root.subject, + genesis: root.genesis, + localScopes: [], + authorizationHistory: [], + localState: 'active', + deviceV2: { + deviceId: issued.authorization.payload.deviceId, + authorizationId: issuedRecord.authorizationId, + authorization: issued.authorization, + signingPrivateKeyRef: ref, + localState: 'pending-import', + importStartedAt: NOW, + }, + }); + const recovery = new WalletCore({ + keyVault: recoveryVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW + 301 }, + }); + + await recovery.listIdentitySummaries(); + expect(await identityStore.get(localId)).toBeUndefined(); + const privateKey = decodeBase64Url(plaintext.devicePrivateKey.bytes); + const publicKey = decodeBase64UrlExact(issued.authorization.payload.signingKey.publicKey, 32); + await expect( + managedLate.importDeviceSigningKeyAtRef(ref, privateKey, publicKey), + ).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + expect(await lateVault.hasKey(ref)).toBe(false); + await lateVault.deleteKey(ref); + await lateVault.deleteSecret( + ref as unknown as Parameters[0], + ); + await expect( + managedLate.importDeviceSigningKeyAtRef(ref, privateKey, publicKey), + ).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('Failed to reopen wallet DB.')); + }); + const transaction = database.transaction('key-material', 'readonly'); + const stored = await new Promise((resolve, reject) => { + const request = transaction.objectStore('key-material').get(ref); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('Failed to read tombstone.')); + }); + expect(stored).toEqual({ ref, kind: 'cancelled-device-key-ref' }); + database.close(); + + const concurrentRef = managedRecovery.createDeviceSigningKeyRef(); + await Promise.all([ + lateVault.deleteKey(concurrentRef), + managedRecovery.cancelDeviceSigningKeyRef(concurrentRef), + ]); + await lateVault.deleteSecret( + concurrentRef as unknown as Parameters[0], + ); + await expect( + managedLate.importDeviceSigningKeyAtRef(concurrentRef, privateKey, publicKey), + ).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + expect(await lateVault.hasKey(concurrentRef)).toBe(false); + }); + + it('preserves an import that finalizes after recovery observes no key', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + let signalInsertionPaused: (() => void) | undefined; + let releaseInsertion: (() => void) | undefined; + const insertionPaused = new Promise((resolve) => { + signalInsertionPaused = resolve; + }); + const keyVault = new PausingFalseKeyObservationVault({ + beforeDeviceKeyImport: async () => { + signalInsertionPaused?.(); + await new Promise((resolve) => { + releaseInsertion = resolve; + }); + }, + }); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new InMemoryRegistryClient({ clock: { now: () => NOW } }); + const importer = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const recovery = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW + 301 }, + }); + + const importing = importer.importDeviceTransfer(issued.bundle, issued.transferKey); + await insertionPaused; + const recovering = recovery.listIdentitySummaries(); + await keyVault.falseObserved; + + releaseInsertion?.(); + const imported = await importing; + const importedRecord = await identityStore.get(imported.localId); + const ref = importedRecord?.deviceV2?.signingPrivateKeyRef; + if (ref === undefined) throw new Error('Expected finalized device signing-key reference.'); + expect(importedRecord?.deviceV2?.localState).toBe('pending-activation'); + + keyVault.releaseObservation(); + await recovering; + expect((await identityStore.get(imported.localId))?.deviceV2?.localState).toBe( + 'pending-activation', + ); + expect(await keyVault.hasKey(ref)).toBe(true); + }); + + it('stores an imported device private key as non-extractable in IndexedDB', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + + const indexedDB = new IDBFactory(); + const databaseName = 'wallet-v2-device-nonextractable'; + const keyVault = new WebCryptoIndexedDbKeyVault({ databaseName, indexedDB }); + const identityStore = new IndexedDbIdentityStore({ databaseName, indexedDB }); + const target = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const imported = await target.importDeviceTransfer(issued.bundle, issued.transferKey); + const record = await identityStore.get(imported.localId); + const ref = record?.deviceV2?.signingPrivateKeyRef; + if (ref === undefined) throw new Error('Expected imported device key reference.'); + + const database = await new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName); + request.onsuccess = () => resolve(request.result); + request.onerror = () => + reject(new Error('Unable to open the test wallet database.', { cause: request.error })); + }); + const stored = await new Promise<{ privateKey: CryptoKey }>((resolve, reject) => { + const request = database.transaction('key-material').objectStore('key-material').get(ref); + request.onsuccess = () => resolve(request.result as { privateKey: CryptoKey }); + request.onerror = () => + reject(new Error('Unable to read the imported device key.', { cause: request.error })); + }); + expect(stored.privateKey.extractable).toBe(false); + database.close(); + await keyVault.close(); + await identityStore.close(); + }); + + it('deletes device keys only after a cryptographically accepted revocation receipt', async () => { + const keyVault = new InMemoryKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new DeviceRegistryClient(); + let rejectDeviceReceipt = false; + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt: (receipt) => { + if (rejectDeviceReceipt) throw new Error('bad device registry signature'); + return Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)); + }, + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const imported = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + await wallet.activateDevice(imported.localId); + const active = await identityStore.get(imported.localId); + const ref = active?.deviceV2?.signingPrivateKeyRef; + if (ref === undefined) throw new Error('Expected active device key reference.'); + + rejectDeviceReceipt = true; + await expect(wallet.revokeDeviceSelf(imported.localId)).rejects.toMatchObject({ + code: 'INVALID_REGISTRY_RECEIPT', + }); + expect(await keyVault.hasKey(ref)).toBe(true); + + rejectDeviceReceipt = false; + await wallet.revokeDeviceSelf(imported.localId); + expect(await keyVault.hasKey(ref)).toBe(false); + expect((await identityStore.get(imported.localId))?.deviceV2?.localState).toBe('revoked'); + }); + + it('persists terminal state before deletion and retries failed key cleanup safely', async () => { + const keyVault = new FlakyDeleteKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new DeviceRegistryClient(); + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt: (receipt) => + Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)), + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const imported = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + await wallet.activateDevice(imported.localId); + keyVault.failDeletes = true; + + await expect(wallet.revokeDeviceSelf(imported.localId)).rejects.toMatchObject({ + code: 'STORAGE_ERROR', + }); + const pendingCleanup = await identityStore.get(imported.localId); + expect(pendingCleanup?.deviceV2).toMatchObject({ localState: 'revoked' }); + expect(pendingCleanup?.deviceV2?.revocationReceipt).toBeDefined(); + expect(pendingCleanup?.deviceV2?.signingPrivateKeyRef).toBeDefined(); + await expect( + wallet.proveDevice( + imported.localId, + boundary('https://rp-a.test'), + proofRequestSchema.parse({ + action: 'post.edit', + resource: 'post:01JABC', + nonce: 'AQEBAQEBAQEBAQEBAQEBAQ', + expiresAt: NOW + 60, + }), + ), + ).rejects.toMatchObject({ code: 'IDENTITY_NOT_REGISTERED' }); + + keyVault.failDeletes = false; + await wallet.revokeDeviceSelf(imported.localId); + expect( + (await identityStore.get(imported.localId))?.deviceV2?.signingPrivateKeyRef, + ).toBeUndefined(); + }); + + it('root revocation is receipt-gated and updates the issued-device catalogue', async () => { + const keyVault = new InMemoryKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new DeviceRegistryClient(); + let rejectDeviceReceipt = false; + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt: (receipt) => { + if (rejectDeviceReceipt) throw new Error('bad device registry signature'); + return Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)); + }, + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const imported = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + const deviceRecord = await identityStore.get(imported.localId); + const ref = deviceRecord?.deviceV2?.signingPrivateKeyRef; + if (ref === undefined) throw new Error('Expected installed device key reference.'); + + rejectDeviceReceipt = true; + await expect(wallet.revokeDeviceRoot(root.localId, imported.deviceId)).rejects.toMatchObject({ + code: 'INVALID_REGISTRY_RECEIPT', + }); + expect(await keyVault.hasKey(ref)).toBe(true); + expect((await identityStore.get(root.localId))?.issuedDevicesV2?.[0]?.localState).toBe( + 'issued', + ); + + rejectDeviceReceipt = false; + await wallet.revokeDeviceRoot(root.localId, imported.deviceId); + expect(await keyVault.hasKey(ref)).toBe(false); + expect((await identityStore.get(root.localId))?.issuedDevicesV2?.[0]?.localState).toBe( + 'revoked', + ); + }); + + it('converges self-revocation after a root pre-activation tombstone', async () => { + const registryClient = new DeviceRegistryClient(); + const verifyDeviceRegistryReceipt = (receipt: unknown) => + Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)); + const rootStore = new InMemoryIdentityStore(); + const rootWallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: rootStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const deviceKeyVault = new InMemoryKeyVault(); + const deviceStore = new InMemoryIdentityStore(); + const deviceWallet = new WalletCore({ + keyVault: deviceKeyVault, + identityStore: deviceStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const root = await rootWallet.createIdentity(); + const issued = await rootWallet.issueDeviceTransfer(root.localId); + const imported = await deviceWallet.importDeviceTransfer(issued.bundle, issued.transferKey); + const deviceRef = (await deviceStore.get(imported.localId))?.deviceV2?.signingPrivateKeyRef; + if (deviceRef === undefined) throw new Error('Expected imported device key reference.'); + + const rootReceipt = await rootWallet.revokeDeviceRoot(root.localId, imported.deviceId); + expect(rootReceipt.payload.revokedBy).toBe('root'); + expect(rootReceipt.payload.authorizationId).toBeUndefined(); + expect(await deviceKeyVault.hasKey(deviceRef)).toBe(true); + + const converged = await deviceWallet.revokeDeviceSelf(imported.localId); + expect(converged).toEqual(rootReceipt); + expect(await deviceKeyVault.hasKey(deviceRef)).toBe(false); + expect((await deviceStore.get(imported.localId))?.deviceV2?.localState).toBe('revoked'); + }); + + it('converges root revocation after an existing device-authored revocation', async () => { + const registryClient = new DeviceRegistryClient(); + const verifyDeviceRegistryReceipt = (receipt: unknown) => + Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)); + const rootStore = new InMemoryIdentityStore(); + const rootWallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: rootStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const deviceWallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: new InMemoryIdentityStore(), + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const root = await rootWallet.createIdentity(); + const issued = await rootWallet.issueDeviceTransfer(root.localId); + const imported = await deviceWallet.importDeviceTransfer(issued.bundle, issued.transferKey); + + const selfReceipt = await deviceWallet.revokeDeviceSelf(imported.localId); + expect(selfReceipt.payload.revokedBy).toBe('device'); + expect(selfReceipt.payload.authorizationId).toBe(imported.authorizationId); + + const converged = await rootWallet.revokeDeviceRoot(root.localId, imported.deviceId); + expect(converged).toEqual(selfReceipt); + expect((await rootStore.get(root.localId))?.issuedDevicesV2?.[0]?.localState).toBe('revoked'); + }); + + it('converges concurrent root and device revocation without state rollback', async () => { + const registryClient = new DeviceRegistryClient(); + const verifyDeviceRegistryReceipt = (receipt: unknown) => + Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)); + const rootStore = new InMemoryIdentityStore(); + const deviceStore = new InMemoryIdentityStore(); + const rootWallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: rootStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const deviceWallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: deviceStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const root = await rootWallet.createIdentity(); + const issued = await rootWallet.issueDeviceTransfer(root.localId); + const imported = await deviceWallet.importDeviceTransfer(issued.bundle, issued.transferKey); + + const [rootReceipt, selfReceipt] = await Promise.all([ + rootWallet.revokeDeviceRoot(root.localId, imported.deviceId), + deviceWallet.revokeDeviceSelf(imported.localId), + ]); + expect(rootReceipt).toEqual(selfReceipt); + expect((await rootStore.get(root.localId))?.issuedDevicesV2?.[0]?.localState).toBe('revoked'); + expect((await deviceStore.get(imported.localId))?.deviceV2?.localState).toBe('revoked'); + }); + + it('lets two independent device keys prove the same subject while revoking only one', async () => { + const keyVault = new InMemoryKeyVault(); + const identityStore = new InMemoryIdentityStore(); + const registryClient = new DeviceRegistryClient(); + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt: (receipt) => + Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)), + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + const [issuedA, issuedB] = await Promise.all([ + wallet.issueDeviceTransfer(root.localId), + wallet.issueDeviceTransfer(root.localId), + ]); + const [deviceA, deviceB] = await Promise.all([ + wallet.importDeviceTransfer(issuedA.bundle, issuedA.transferKey), + wallet.importDeviceTransfer(issuedB.bundle, issuedB.transferKey), + ]); + await Promise.all([ + wallet.activateDevice(deviceA.localId), + wallet.activateDevice(deviceB.localId), + ]); + const request = proofRequestSchema.parse({ + action: 'post.edit', + resource: 'post:01JABC', + nonce: 'AQEBAQEBAQEBAQEBAQEBAQ', + expiresAt: NOW + 60, + }); + const [proofA, proofB] = await Promise.all([ + wallet.proveDevice(deviceA.localId, boundary('https://rp-a.test'), request), + wallet.proveDevice(deviceB.localId, boundary('https://rp-a.test'), request), + ]); + expect(proofA.payload.subject).toBe(root.subject); + expect(proofB.payload.subject).toBe(root.subject); + expect(proofA.payload.deviceId).not.toBe(proofB.payload.deviceId); + + await wallet.revokeDeviceRoot(root.localId, deviceA.deviceId); + await expect( + wallet.proveDevice(deviceA.localId, boundary('https://rp-a.test'), request), + ).rejects.toMatchObject({ code: 'IDENTITY_REVOKED' }); + expect( + (await wallet.proveDevice(deviceB.localId, boundary('https://rp-a.test'), request)).payload + .deviceId, + ).toBe(deviceB.deviceId); + }); + + it('treats two installations of one copied bundle as the same revocable device', async () => { + const registryClient = new DeviceRegistryClient(); + const verifyDeviceRegistryReceipt = (receipt: unknown) => + Promise.resolve(deviceRegistryReceiptV2Schema.parse(receipt)); + const rootWallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: new InMemoryIdentityStore(), + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const createCloneWallet = () => + new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: new InMemoryIdentityStore(), + registryClient, + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + verifyDeviceRegistryReceipt, + clock: { now: () => NOW }, + }); + const root = await rootWallet.createIdentity(); + const issued = await rootWallet.issueDeviceTransfer(root.localId); + const cloneA = createCloneWallet(); + const cloneB = createCloneWallet(); + const [deviceA, deviceB] = await Promise.all([ + cloneA.importDeviceTransfer(issued.bundle, issued.transferKey), + cloneB.importDeviceTransfer(issued.bundle, issued.transferKey), + ]); + expect(deviceA.deviceId).toBe(deviceB.deviceId); + + const rootReceipt = await rootWallet.revokeDeviceRoot(root.localId, deviceA.deviceId); + const [receiptA, receiptB] = await Promise.all([ + cloneA.revokeDeviceSelf(deviceA.localId), + cloneB.revokeDeviceSelf(deviceB.localId), + ]); + expect(receiptA).toEqual(rootReceipt); + expect(receiptB).toEqual(rootReceipt); + }); + + it('rejects a tampered bundle, wrong key, duplicate install, and device delegation', async () => { + const { wallet } = createHarness(); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const wrongKey = new Uint8Array(32).fill(7); + await expect(wallet.importDeviceTransfer(issued.bundle, wrongKey)).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + }); + + const ciphertext = decodeBase64Url(issued.bundle.ciphertext); + ciphertext[0] = (ciphertext[0] ?? 0) ^ 1; + const tampered: DeviceTransferEnvelopeV2 = { + ...issued.bundle, + ciphertext: encodeBase64Url(ciphertext), + }; + await expect(wallet.importDeviceTransfer(tampered, issued.transferKey)).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + }); + + const installed = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + await expect( + wallet.importDeviceTransfer(issued.bundle, issued.transferKey), + ).rejects.toMatchObject({ code: 'REGISTRY_CONFLICT' }); + await expect(wallet.issueDeviceTransfer(installed.localId)).rejects.toMatchObject({ + code: 'IDENTITY_NOT_REGISTERED', + }); + }); + + it('rejects certified public/private mismatch and every authenticated header mutation', async () => { + const { wallet } = createHarness(); + const root = await wallet.createIdentity(); + const [issuedA, issuedB] = await Promise.all([ + wallet.issueDeviceTransfer(root.localId), + wallet.issueDeviceTransfer(root.localId), + ]); + const [plainA, plainB] = await Promise.all([ + decryptDeviceTransferPlaintext( + issuedA.bundle, + issuedA.transferKey, + getDefaultCryptoProvider(), + ), + decryptDeviceTransferPlaintext( + issuedB.bundle, + issuedB.transferKey, + getDefaultCryptoProvider(), + ), + ]); + const mismatched = await encryptDeviceTransfer( + { + subject: plainA.subject, + genesis: plainA.genesis, + authorization: plainA.authorization, + devicePrivateKey: plainB.devicePrivateKey, + }, + getDefaultCryptoProvider(), + ); + await expect( + wallet.importDeviceTransfer(mismatched.bundle, mismatched.transferKey), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + + const alternate32 = encodeBase64Url(new Uint8Array(32).fill(0x72)); + const alternate12 = encodeBase64Url(new Uint8Array(12).fill(0x73)); + const mutations: DeviceTransferEnvelopeV2[] = [ + { ...issuedA.bundle, bundleId: alternate32 }, + { ...issuedA.bundle, salt: alternate32 }, + { ...issuedA.bundle, iv: alternate12 }, + { ...issuedA.bundle, protocol: 'nexus.device-transfer.v3' as never }, + { ...issuedA.bundle, suite: 'NX-UNKNOWN-v2' as never }, + ]; + for (const mutation of mutations) { + await expect( + wallet.importDeviceTransfer(mutation, issuedA.transferKey), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + } + }); + + it('rejects a root signing key reused as a device key before storing key material', async () => { + const source = createHarness(); + const root = await source.wallet.createIdentity(); + const issued = await source.wallet.issueDeviceTransfer(root.localId); + const plaintext = await decryptDeviceTransferPlaintext( + issued.bundle, + issued.transferKey, + getDefaultCryptoProvider(), + ); + const rootRecord = await source.identityStore.get(root.localId); + const rootRef = rootRecord?.signingPrivateKeyRef; + if (rootRecord === undefined || rootRef === undefined) { + throw new Error('Expected a root signing-key reference.'); + } + const crypto = getDefaultCryptoProvider(); + const signingKey = rootRecord.genesis.signingKey; + const deviceId = formatDeviceIdV2( + await crypto.sha256( + createDeviceIdHashPreimageV2({ + subject: root.subject, + signingKey, + }), + ), + ); + const payload = { + ...plaintext.authorization.payload, + deviceId, + signingKey, + } satisfies DeviceAuthorizationPayloadV2; + const rootKeyDeviceTransfer = await encryptDeviceTransfer( + { + subject: plaintext.subject, + genesis: plaintext.genesis, + authorization: { + payload, + rootSignature: base64Url64Schema.parse( + encodeBase64Url(await signManagedProtocolPayload(source.keyVault, rootRef, payload)), + ), + }, + devicePrivateKey: plaintext.devicePrivateKey, + }, + crypto, + ); + let importCalls = 0; + const keyVault = new InMemoryKeyVault({ + beforeDeviceKeyImport: () => { + importCalls += 1; + return Promise.resolve(); + }, + }); + const identityStore = new InMemoryIdentityStore(); + const target = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + + await expect( + target.importDeviceTransfer(rootKeyDeviceTransfer.bundle, rootKeyDeviceTransfer.transferKey), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + expect(importCalls).toBe(0); + expect(await identityStore.list()).toEqual([]); + }); + + it('rejects an oversized ciphertext string before attempting base64 decoding', async () => { + const { wallet } = createHarness(); + const root = await wallet.createIdentity(); + const issued = await wallet.issueDeviceTransfer(root.localId); + const oversized = { ...issued.bundle, ciphertext: 'A'.repeat(87_383) }; + await expect(wallet.importDeviceTransfer(oversized, issued.transferKey)).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + }); + }); + + it('keeps existing v1-only custom vault and store implementations compatible', async () => { + const vaultDelegate = new InMemoryKeyVault(); + const storeDelegate = new InMemoryIdentityStore(); + const keyVault: KeyVault = { + createSigningKey: () => vaultDelegate.createSigningKey(), + sign: (ref, data) => + signManagedProtocolPayload(vaultDelegate, ref, { + protocol: 'nexus.ownership-proof.v1', + legacyPreimage: encodeBase64Url(data), + }), + createAgreementKey: () => vaultDelegate.createAgreementKey(), + deleteKey: (ref) => vaultDelegate.deleteKey(ref), + storeRevocationSecret: (secret) => vaultDelegate.storeRevocationSecret(secret), + readRevocationSecret: (ref) => vaultDelegate.readRevocationSecret(ref), + deleteSecret: (ref) => vaultDelegate.deleteSecret(ref), + readPublicKey: (ref) => vaultDelegate.readPublicKey(ref), + hasKey: (ref) => vaultDelegate.hasKey(ref), + hasSecret: (ref) => vaultDelegate.hasSecret(ref), + }; + const identityStore: IdentityStore = { + get: (localId) => storeDelegate.get(localId), + list: () => storeDelegate.list(), + put: (record) => storeDelegate.put(record), + delete: (localId) => storeDelegate.delete(localId), + }; + const wallet = new WalletCore({ + keyVault, + identityStore, + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const identity = await wallet.createIdentity(); + expect(identity.subject).toMatch(/^nx1_/u); + await expect(wallet.issueDeviceTransfer(identity.localId)).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + }); + }); + + it('does not offer any raw root or device private-key export API', () => { + const { wallet, keyVault } = createHarness(); + const indexedVault = new WebCryptoIndexedDbKeyVault({ indexedDB: new IDBFactory() }); + expect('exportIdentity' in wallet).toBe(false); + for (const vault of [keyVault, indexedVault]) { + expect('sign' in vault).toBe(false); + expect('exportPrivateKey' in vault).toBe(false); + expect('importDeviceSigningKey' in vault).toBe(false); + expect('importDeviceSigningKeyAtRef' in vault).toBe(false); + expect('createDeviceSigningKeyRef' in vault).toBe(false); + expect('cancelDeviceSigningKeyRef' in vault).toBe(false); + expect('createDeviceSigningKeyForTransfer' in vault).toBe(false); + } + }); + + it('enforces protocol allowlists for managed root and device signing keys', async () => { + const { wallet, keyVault, identityStore } = createHarness(); + const root = await wallet.createIdentity(); + const rootRecord = await identityStore.get(root.localId); + const rootRef = rootRecord?.signingPrivateKeyRef; + if (rootRef === undefined) throw new Error('Expected root signing-key reference.'); + await expect( + signManagedProtocolPayload(keyVault, rootRef, { + protocol: 'nexus.device-activation.v2', + }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + + const issued = await wallet.issueDeviceTransfer(root.localId); + const imported = await wallet.importDeviceTransfer(issued.bundle, issued.transferKey); + const deviceRecord = await identityStore.get(imported.localId); + const deviceRef = deviceRecord?.deviceV2?.signingPrivateKeyRef; + if (deviceRef === undefined) throw new Error('Expected device signing-key reference.'); + await expect( + signManagedProtocolPayload(keyVault, deviceRef, { + protocol: 'nexus.device-root-revoke.v2', + }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST' }); + }); +}); diff --git a/packages/wallet-core/test/wallet-core.test.ts b/packages/wallet-core/test/wallet-core.test.ts index d501549..e90589a 100644 --- a/packages/wallet-core/test/wallet-core.test.ts +++ b/packages/wallet-core/test/wallet-core.test.ts @@ -27,6 +27,7 @@ import { type RegistryReceiptVerifier, type TrustedWalletEventBoundary, } from '../src/index.js'; +import { signManagedProtocolPayload } from '../src/key-vault.js'; const NOW = 1_786_400_000; @@ -119,7 +120,10 @@ describe('WebCrypto IndexedDB vault', () => { const secretRef = await first.storeRevocationSecret(new Uint8Array(32).fill(0x5a)); const publicKey = await first.readPublicKey(signingRef); const agreementPublicKey = await first.readPublicKey(agreementRef); - const firstSignature = await first.sign(signingRef, new Uint8Array([1, 2, 3])); + const firstSignature = await signManagedProtocolPayload(first, signingRef, { + protocol: 'nexus.ownership-proof.v1', + nonce: 'first', + }); expect(publicKey).toHaveLength(32); expect(agreementPublicKey).toHaveLength(32); expect(firstSignature).toHaveLength(64); @@ -129,7 +133,12 @@ describe('WebCrypto IndexedDB vault', () => { expect(await reopened.readPublicKey(signingRef)).toEqual(publicKey); expect(await reopened.readPublicKey(agreementRef)).toEqual(agreementPublicKey); expect(await reopened.readRevocationSecret(secretRef)).toEqual(new Uint8Array(32).fill(0x5a)); - expect(await reopened.sign(signingRef, new Uint8Array([4, 5, 6]))).toHaveLength(64); + expect( + await signManagedProtocolPayload(reopened, signingRef, { + protocol: 'nexus.ownership-proof.v1', + nonce: 'reopened', + }), + ).toHaveLength(64); await reopened.deleteKey(signingRef); await reopened.deleteKey(agreementRef); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89037b5..d38455c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,9 +358,6 @@ importers: '@nexus/protocol': specifier: workspace:* version: link:../../packages/protocol - '@nexus/verifier': - specifier: workspace:* - version: link:../../packages/verifier devDependencies: '@cloudflare/vitest-pool-workers': specifier: ^0.21.0 @@ -368,6 +365,9 @@ importers: '@cloudflare/workers-types': specifier: ^5.20260811.1 version: 5.20260811.1 + '@nexus/verifier': + specifier: workspace:* + version: link:../../packages/verifier typescript: specifier: ^5.9.2 version: 5.9.3 diff --git a/tests/browser-e2e/fixture-server.ts b/tests/browser-e2e/fixture-server.ts index 2029003..dbd594b 100644 --- a/tests/browser-e2e/fixture-server.ts +++ b/tests/browser-e2e/fixture-server.ts @@ -26,7 +26,7 @@ const tls = { // This process trusts only its own ephemeral test boundary. App code remains fail-closed. process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; -const stopRegistry = await startRegistryFixture(tls); +const registryFixture = await startRegistryFixture(tls); const servers: ViteDevServer[] = []; try { @@ -79,6 +79,7 @@ async function startRp(origin: string, cacheName: string): Promise> { async function shutdown(): Promise { await Promise.allSettled(servers.map((server) => server.close())); - await stopRegistry(); + await registryFixture.stop(); } diff --git a/tests/browser-e2e/registry-fixture.ts b/tests/browser-e2e/registry-fixture.ts index 0835e31..bedb935 100644 --- a/tests/browser-e2e/registry-fixture.ts +++ b/tests/browser-e2e/registry-fixture.ts @@ -2,12 +2,22 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer, type ServerOptions } from 'node:https'; import { + deriveDeviceAuthorizationIdV2, + deriveDeviceIdV2, + deriveDeviceOperationIdV2, deriveGenesisHash, deriveSubject, signProtocolPayload, + verifyProtocolPayload, } from '../../packages/crypto/src/index.js'; import { base64Url32Schema, + decodeBase64UrlExact, + deviceActivationRequestV2Schema, + deviceRegistryReceiptV2Schema, + deviceRegistryStatusV2Schema, + deviceStatusRequestV2Schema, + deviceStatusStatementV2Schema, encodeBase64Url, registerIdentityRequestV1Schema, registryReceiptV1Schema, @@ -17,7 +27,10 @@ import { statusRequestV1Schema, statusStatementV1Schema, type Base64Url32, + type DeviceRegistryReceiptV2, type IdentityGenesisV1, + type NexusDeviceAuthorizationIdV2, + type NexusDeviceIdV2, type NexusSubject, type RegistryReceiptV1, type ServiceKeySet, @@ -40,9 +53,24 @@ interface StoredIdentity { revokedAt?: number; registrationReceipt: RegistryReceiptV1; revocationReceipt?: RegistryReceiptV1; + deviceLedgerSequence: number; + devices: Map; } -export async function startRegistryFixture(tls: ServerOptions): Promise<() => Promise> { +interface StoredDevice { + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + activatedAt: number; + authorizationExpiresAt: number; + receipt: DeviceRegistryReceiptV2; +} + +export interface StartedRegistryFixture { + readonly serviceKeyset: ServiceKeySet; + readonly stop: () => Promise; +} + +export async function startRegistryFixture(tls: ServerOptions): Promise { const registry = await TestRegistry.create(); const server = createServer(tls, (request, response) => { void registry.route(request, response); @@ -52,10 +80,13 @@ export async function startRegistryFixture(tls: ServerOptions): Promise<() => Pr server.once('error', reject); server.listen(port, '127.0.0.1', resolve); }); - return () => - new Promise((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); + return { + serviceKeyset: registry.serviceKeyset, + stop: () => + new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }), + }; } class TestRegistry { @@ -66,6 +97,10 @@ class TestRegistry { private readonly keyset: ServiceKeySet, ) {} + public get serviceKeyset(): ServiceKeySet { + return this.keyset; + } + public static async create(): Promise { const pair = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']); const rawPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); @@ -122,6 +157,14 @@ class TestRegistry { await this.#revoke(await readJson(request), response); return; } + if (url.pathname === '/v2/device/activate') { + await this.#activateDevice(await readJson(request), response); + return; + } + if (url.pathname === '/v2/device/status') { + await this.#deviceStatus(await readJson(request), response); + return; + } sendError(response, 404, 'NOT_FOUND'); } catch (error) { sendError(response, 400, error instanceof Error ? error.name : 'BAD_REQUEST'); @@ -158,6 +201,8 @@ class TestRegistry { genesisHash, registeredAt: acceptedAt, registrationReceipt, + deviceLedgerSequence: 0, + devices: new Map(), }); sendJson(response, 201, { receipt: registrationReceipt }); } @@ -251,6 +296,145 @@ class TestRegistry { sendJson(response, 200, { receipt }); } + async #activateDevice(value: unknown, response: ServerResponse): Promise { + const request = deviceActivationRequestV2Schema.parse(value); + const authorization = request.authorization.payload; + const identity = this.#identities.get(authorization.subject); + if (identity === undefined) { + sendError(response, 404, 'IDENTITY_NOT_FOUND'); + return; + } + if (identity.revokedAt !== undefined) { + sendError(response, 409, 'IDENTITY_REVOKED'); + return; + } + const [deviceId, authorizationId] = await Promise.all([ + deriveDeviceIdV2({ subject: authorization.subject, signingKey: authorization.signingKey }), + deriveDeviceAuthorizationIdV2(authorization), + ]); + if ( + authorization.genesisHash !== identity.genesisHash || + authorization.deviceId !== deviceId || + request.payload.deviceId !== deviceId || + request.payload.authorizationId !== authorizationId + ) { + sendError(response, 400, 'BAD_REQUEST'); + return; + } + const rootKey = await importEd25519PublicKey(identity.genesis.signingKey.publicKey); + const deviceKey = await importEd25519PublicKey(authorization.signingKey.publicKey); + const [validRoot, validDevice] = await Promise.all([ + verifyProtocolPayload(authorization, request.authorization.rootSignature, rootKey), + verifyProtocolPayload(request.payload, request.deviceSignature, deviceKey), + ]); + if (!validRoot || !validDevice) { + sendError(response, 403, 'INVALID_SIGNATURE'); + return; + } + const now = nowSeconds(); + if ( + now < authorization.validFrom - 30 || + now > authorization.activationDeadline + 30 || + now >= authorization.expiresAt || + now < request.payload.iat - 30 || + now > request.payload.exp + 30 || + request.payload.exp - request.payload.iat > 300 + ) { + sendError(response, 400, 'BAD_REQUEST'); + return; + } + const existing = identity.devices.get(deviceId); + if (existing !== undefined) { + if (existing.authorizationId !== authorizationId) { + sendError(response, 409, 'DEVICE_AUTHORIZATION_CONFLICT'); + return; + } + sendJson(response, 200, { receipt: existing.receipt }); + return; + } + + const acceptedAt = now; + identity.deviceLedgerSequence += 1; + const operationId = await deriveDeviceOperationIdV2(request.payload); + const receipt = await this.#deviceReceipt({ + eventId: await deviceEventId(`activated:${identity.subject}:${deviceId}`), + operationId, + eventType: 'activated', + subject: identity.subject, + genesisHash: identity.genesisHash, + identitySequence: 0, + identityState: 'active', + deviceLedgerSequence: identity.deviceLedgerSequence, + deviceId, + authorizationId, + deviceState: 'active', + authorizationExpiresAt: authorization.expiresAt, + acceptedAt, + }); + identity.devices.set(deviceId, { + deviceId, + authorizationId, + activatedAt: acceptedAt, + authorizationExpiresAt: authorization.expiresAt, + receipt, + }); + sendJson(response, 201, { receipt }); + } + + async #deviceStatus(value: unknown, response: ServerResponse): Promise { + const request = deviceStatusRequestV2Schema.parse(value); + const identity = this.#identities.get(request.subject); + const device = identity?.devices.get(request.deviceId); + if ( + identity === undefined || + device === undefined || + device.authorizationId !== request.authorizationId + ) { + sendError(response, 404, 'DEVICE_NOT_FOUND'); + return; + } + const issuedAt = nowSeconds(); + const identityRevoked = identity.revokedAt !== undefined; + const expired = issuedAt >= device.authorizationExpiresAt; + const deviceState = identityRevoked ? 'revoked' : expired ? 'expired' : 'active'; + const statusPayload = { + protocol: 'nexus.device-status-statement.v2' as const, + subject: identity.subject, + genesisHash: identity.genesisHash, + identityState: identityRevoked ? ('revoked' as const) : ('active' as const), + identitySequence: identityRevoked ? 1 : 0, + deviceLedgerSequence: identity.deviceLedgerSequence, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + deviceState, + activatedAt: device.activatedAt, + ...(identity.revokedAt === undefined ? {} : { revokedAt: identity.revokedAt }), + authorizationExpiresAt: device.authorizationExpiresAt, + iat: issuedAt, + exp: expired ? issuedAt + 60 : Math.min(issuedAt + 60, device.authorizationExpiresAt), + signerKid: SIGNER_KID, + }; + const statusStatement = deviceStatusStatementV2Schema.parse({ + payload: statusPayload, + signature: await signProtocolPayload(statusPayload, this.privateKey), + }); + const status = deviceRegistryStatusV2Schema.parse({ + subject: identity.subject, + genesisHash: identity.genesisHash, + identityState: statusPayload.identityState, + identitySequence: statusPayload.identitySequence, + deviceLedgerSequence: identity.deviceLedgerSequence, + deviceId: device.deviceId, + authorizationId: device.authorizationId, + deviceState, + activatedAt: device.activatedAt, + revokedAt: identity.revokedAt ?? null, + authorizationExpiresAt: device.authorizationExpiresAt, + statusStatement, + }); + sendJson(response, 200, status); + } + async #receipt( payload: Omit, ): Promise { @@ -264,6 +448,20 @@ class TestRegistry { signature: await signProtocolPayload(signedPayload, this.privateKey), }); } + + async #deviceReceipt( + payload: Omit, + ): Promise { + const signedPayload = { + protocol: 'nexus.device-registry-receipt.v2' as const, + ...payload, + signerKid: SIGNER_KID, + }; + return deviceRegistryReceiptV2Schema.parse({ + payload: signedPayload, + signature: await signProtocolPayload(signedPayload, this.privateKey), + }); + } } function setApiHeaders(request: IncomingMessage, response: ServerResponse): void { @@ -311,6 +509,16 @@ async function eventId(label: string): Promise<`nxe1_${string}`> { return `nxe1_${encodeBase64Url(new Uint8Array(digest))}`; } +async function deviceEventId(label: string): Promise<`nxde2_${string}`> { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(label)); + return `nxde2_${encodeBase64Url(new Uint8Array(digest))}`; +} + +async function importEd25519PublicKey(encoded: string): Promise { + const publicKey = Uint8Array.from(decodeBase64UrlExact(encoded, 32)); + return crypto.subtle.importKey('raw', publicKey, 'Ed25519', false, ['verify']); +} + function nowSeconds(): number { return Math.floor(Date.now() / 1000); } diff --git a/tests/browser-e2e/security.spec.ts b/tests/browser-e2e/security.spec.ts index 5ae2ac7..f756e9a 100644 --- a/tests/browser-e2e/security.spec.ts +++ b/tests/browser-e2e/security.spec.ts @@ -10,7 +10,7 @@ import { type Page, type Request, } from '@playwright/test'; -import type { OwnershipProofV1, ProofRequest } from '@nexus/protocol'; +import type { OwnershipProofV1, OwnershipProofV2, ProofRequest } from '@nexus/protocol'; import { REGISTRY_ORIGIN, @@ -31,6 +31,10 @@ interface LoggedRequest { interface IssuedChallenge extends ProofRequest { challengeId: string; + acceptedProofProtocols: readonly [ + 'nexus.ownership-proof.v2' | 'nexus.ownership-proof.v1', + ...('nexus.ownership-proof.v2' | 'nexus.ownership-proof.v1')[], + ]; } interface NoteView { @@ -52,7 +56,8 @@ interface RawWalletResponse { channel: string; type: string; requestId: string; - proof?: OwnershipProofV1; + proofProtocol?: 'nexus.ownership-proof.v2' | 'nexus.ownership-proof.v1'; + proof?: OwnershipProofV1 | OwnershipProofV2; error?: { code: string }; }; } @@ -120,10 +125,12 @@ test.describe.serial('Nexus browser security boundary', () => { const loginBody = JSON.parse(loginRequest?.body ?? '') as { challengeId: string; operation: { action: string }; + proofProtocol: 'nexus.ownership-proof.v1'; proof: OwnershipProofV1; }; proofForA = loginBody.proof; expect(loginBody.operation).toEqual({ action: 'session.start' }); + expect(loginBody.proofProtocol).toBe('nexus.ownership-proof.v1'); expect(proofForA.payload.aud).toBe(RP_A_ORIGIN); expect(proofForA.payload.act).toBe('session.start'); expect(proofForA.payload.resource).toBe('urn:rowo:nexus-notes:session'); @@ -171,6 +178,106 @@ test.describe.serial('Nexus browser security boundary', () => { await page.close(); }); + test('installs a root-authorized device and completes a negotiated v2 RP session', async () => { + const browser = context.browser(); + if (browser === null) throw new Error('Expected a browser.'); + const v2Context = await browser.newContext({ ignoreHTTPSErrors: true, acceptDownloads: true }); + const v2Requests: LoggedRequest[] = []; + v2Context.on('request', (request) => { + v2Requests.push({ + method: request.method(), + url: request.url(), + body: request.postData(), + }); + }); + + try { + const wallet = await v2Context.newPage(); + await wallet.goto(WALLET_ORIGIN); + await wallet.getByRole('button', { name: 'Create first identity' }).click(); + await wallet.getByLabel('Local label (optional)').fill('E2E v2 root'); + await wallet.getByRole('button', { name: 'Create identity' }).click(); + await expect(wallet.getByRole('heading', { name: 'E2E v2 root' }).first()).toBeVisible(); + + const rp = await v2Context.newPage(); + await rp.goto(RP_A_ORIGIN); + const negotiationChallenge = await issueChallengeIn(v2Context, RP_A_ORIGIN); + const v2OnlyRequest = await openRawWalletRequestV2(rp, toProofRequest(negotiationChallenge), [ + 'nexus.ownership-proof.v2', + ]); + await expect( + v2OnlyRequest.popup.getByText(/No active, registered identity is available/u), + ).toBeVisible(); + await waitForRawV2RequestToBeSent(rp); + await rp.waitForTimeout(300); + expect(await hasRawV2WalletResponse(rp)).toBe(false); + await v2OnlyRequest.popup.close(); + + await wallet.reload(); + await expect(wallet.getByRole('heading', { name: 'E2E v2 root' }).first()).toBeVisible(); + await wallet.getByRole('button', { name: 'View details' }).click(); + await expect(wallet.getByRole('button', { name: 'Hide details' })).toBeVisible(); + await wallet.getByRole('button', { name: 'Add device' }).click(); + await wallet.getByLabel('Device label').fill('E2E delegated device'); + await wallet.getByRole('checkbox').check(); + await wallet.getByRole('button', { name: 'Create device transfer' }).click(); + await expect(wallet.getByText('Device authorized', { exact: true })).toBeVisible(); + const transferKey = await wallet.getByLabel(/Transfer key/u).inputValue(); + expect(transferKey).toMatch(/^[A-Za-z0-9_-]{43}$/u); + const downloadPromise = wallet.waitForEvent('download'); + await wallet.getByRole('button', { name: 'Download encrypted transfer file' }).click(); + const transferDownload = await downloadPromise; + const transferPath = await transferDownload.path(); + if (transferPath === null) throw new Error('The encrypted device transfer was not saved.'); + await wallet.getByRole('button', { name: 'Done' }).click(); + + await wallet.getByRole('button', { name: 'Install device' }).click(); + await wallet.locator('#device-transfer-file').setInputFiles(transferPath); + await wallet.getByLabel('Transfer key', { exact: true }).fill(transferKey); + await wallet.getByRole('button', { name: 'Install and activate' }).click(); + await expect( + wallet.getByText('Device installed and activated for this identity.', { exact: true }), + ).toBeVisible(); + await expect(wallet.getByText('Active device', { exact: true })).toBeVisible(); + + await rp.reload(); + const popupPromise = rp.waitForEvent('popup'); + await rp.getByRole('button', { name: 'Log in', exact: true }).click(); + const popup = await popupPromise; + await expect(popup.getByRole('heading', { name: 'Allow a bound proof?' })).toBeVisible(); + await expect(popup.getByRole('option', { name: /device key \(v2\)/u })).toHaveJSProperty( + 'selected', + true, + ); + await approveWallet(popup); + await expect(rp.getByText('Session active', { exact: true })).toBeVisible(); + + const operation = findLastRequest(v2Requests, '/api/operations'); + expect(operation).toBeDefined(); + const body = JSON.parse(operation?.body ?? '') as { + proofProtocol: string; + proof: OwnershipProofV2; + }; + expect(body.proofProtocol).toBe('nexus.ownership-proof.v2'); + expect(body.proof.payload.protocol).toBe('nexus.ownership-proof.v2'); + expect(body.proof.payload.deviceId).toMatch(/^nxd2_/u); + expect(body.proof.payload.authorizationId).toMatch(/^nxa2_/u); + + const sessionResponse = await v2Context.request.get(`${RP_A_ORIGIN}/api/session`); + expect(sessionResponse.status()).toBe(200); + const sessionBody = (await sessionResponse.json()) as { + session: { proofProtocol?: string; deviceId?: string; authorizationId?: string }; + }; + expect(sessionBody.session).toMatchObject({ + proofProtocol: 'nexus.ownership-proof.v2', + deviceId: body.proof.payload.deviceId, + authorizationId: body.proof.payload.authorizationId, + }); + } finally { + await v2Context.close(); + } + }); + test('supports private notes, replies, and idempotent likes in the browser', async () => { const shortenedDestinations: string[] = []; let shortLinkSequence = 0; @@ -295,6 +402,7 @@ test.describe.serial('Nexus browser security boundary', () => { data: { challengeId: challenge.challengeId, operation: { action: 'session.start' }, + proofProtocol: 'nexus.ownership-proof.v1', proof: proofForA, }, }); @@ -320,7 +428,8 @@ test.describe.serial('Nexus browser security boundary', () => { data: { challengeId: challenge.challengeId, operation: { action: 'session.start' }, - proof: response.data.proof, + proofProtocol: 'nexus.ownership-proof.v1', + proof: response.data.proof as OwnershipProofV1, }, }); expect(accepted.status()).toBe(200); @@ -436,6 +545,9 @@ test.describe.serial('Nexus browser security boundary', () => { }); test('authoritative revocation invalidates the existing session before its next mutation', async () => { + await context.request.delete(`${RP_A_ORIGIN}/api/session`, { + headers: { 'X-Nexus-Notes-Session': '1' }, + }); const page = await context.newPage(); await page.goto(RP_A_ORIGIN); const popupPromise = page.waitForEvent('popup'); @@ -473,11 +585,26 @@ test.describe.serial('Nexus browser security boundary', () => { async function approveWallet(popup: Page): Promise { const button = popup.getByRole('button', { name: /Approve (new scope|and sign)/u }); await expect(button).toBeVisible(); + const legacyRootConfirmation = popup.getByRole('checkbox', { + name: 'Use this root key for this legacy v1 proof.', + }); + if (!(await button.isEnabled())) { + await expect(legacyRootConfirmation).toBeVisible(); + await legacyRootConfirmation.check(); + } + await expect(button).toBeEnabled(); await button.click(); } async function issueChallenge(origin: string): Promise { - const response = await context.request.post(`${origin}/api/challenges`, { + return issueChallengeIn(context, origin); +} + +async function issueChallengeIn( + browserContext: BrowserContext, + origin: string, +): Promise { + const response = await browserContext.request.post(`${origin}/api/challenges`, { data: { action: 'session.start' }, }); expect(response.status()).toBe(201); @@ -485,6 +612,103 @@ async function issueChallenge(origin: string): Promise { return value.challenge; } +async function openRawWalletRequestV2( + page: Page, + request: ProofRequest, + acceptedProofProtocols: readonly ['nexus.ownership-proof.v2'], +): Promise<{ popup: Page; requestId: string }> { + const requestId = randomBytes(16).toString('base64url'); + await page.evaluate( + ({ requestId: id, proofRequest, protocols, walletOrigin }) => { + type E2eV2State = { + listener: (event: MessageEvent) => void; + popup: Window | null; + response?: RawWalletResponse; + requestSent: boolean; + }; + type E2eV2Window = Window & { __nexusE2eV2?: E2eV2State }; + const target = window as E2eV2Window; + if (target.__nexusE2eV2 !== undefined) { + window.removeEventListener('message', target.__nexusE2eV2.listener); + } + const state = { popup: null, requestSent: false } as E2eV2State; + state.listener = (event: MessageEvent): void => { + if (event.source !== state.popup || event.origin !== walletOrigin) return; + const data = event.data as { channel?: string; type?: string; requestId?: string }; + if ( + data?.channel === 'nexus.popup.v2' && + data.type === 'NEXUS_READY' && + !state.requestSent + ) { + state.requestSent = true; + state.popup?.postMessage( + { + channel: 'nexus.popup.v2', + type: 'NEXUS_PROOF_REQUEST', + requestId: id, + request: proofRequest, + acceptedProofProtocols: protocols, + }, + walletOrigin, + ); + return; + } + if ( + data?.channel === 'nexus.popup.v2' && + data.requestId === id && + (data.type === 'NEXUS_PROOF_RESULT' || data.type === 'NEXUS_PROOF_ERROR') + ) { + state.response = { + origin: event.origin, + sourceMatches: event.source === state.popup, + data: event.data as RawWalletResponse['data'], + }; + window.removeEventListener('message', state.listener); + } + }; + target.__nexusE2eV2 = state; + window.addEventListener('message', state.listener); + + const trigger = document.createElement('button'); + trigger.id = 'nexus-e2e-open-wallet-v2'; + trigger.type = 'button'; + trigger.textContent = 'Open E2E wallet v2'; + trigger.addEventListener( + 'click', + () => { + state.popup = window.open(walletOrigin, '_blank', 'popup,width=480,height=720'); + trigger.remove(); + }, + { once: true }, + ); + document.body.append(trigger); + }, + { + requestId, + proofRequest: request, + protocols: acceptedProofProtocols, + walletOrigin: WALLET_ORIGIN, + }, + ); + const popupPromise = page.waitForEvent('popup'); + await page.locator('#nexus-e2e-open-wallet-v2').click(); + return { popup: await popupPromise, requestId }; +} + +async function waitForRawV2RequestToBeSent(page: Page): Promise { + await page.waitForFunction(() => { + const target = window as Window & { __nexusE2eV2?: { requestSent: boolean } }; + return target.__nexusE2eV2?.requestSent === true; + }); +} + +function hasRawV2WalletResponse(page: Page): Promise { + return page.evaluate(() => { + const target = window as Window & { __nexusE2eV2?: { response?: RawWalletResponse } }; + return target.__nexusE2eV2?.response !== undefined; + }); +} + async function listNotes(origin: string): Promise { const response = await context.request.get(`${origin}/api/notes`); expect(response.status()).toBe(200); @@ -597,8 +821,15 @@ function hasRawWalletResponse(page: Page): Promise { } function lastRequest(pathname: string): LoggedRequest | undefined { - for (let index = networkLog.length - 1; index >= 0; index -= 1) { - const request = networkLog[index]; + return findLastRequest(networkLog, pathname); +} + +function findLastRequest( + requests: readonly LoggedRequest[], + pathname: string, +): LoggedRequest | undefined { + for (let index = requests.length - 1; index >= 0; index -= 1) { + const request = requests[index]; if ( request !== undefined && request.method === 'POST' && diff --git a/tests/security/device-delegation-v2.guardrails.test.ts b/tests/security/device-delegation-v2.guardrails.test.ts new file mode 100644 index 0000000..9e017d9 --- /dev/null +++ b/tests/security/device-delegation-v2.guardrails.test.ts @@ -0,0 +1,229 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +import { NEXUS_POPUP_CHANNEL, parseWalletMessage } from '../../packages/sdk-browser/src/index.js'; +import { + acceptUnsignedInMemoryRegistryReceipt, + InMemoryIdentityStore, + InMemoryKeyVault, + InMemoryRegistryClient, + WalletCore, + WebCryptoIndexedDbKeyVault, +} from '../../packages/wallet-core/src/index.js'; +import { + NEXUS_POPUP_CHANNEL_V2, + parseProofRequestMessage, +} from '../../apps/wallet/src/lib/popup-protocol.js'; + +const NOW = 1_788_000_000; +const REQUEST_ID = 'BBBBBBBBBBBBBBBBBBBBBB'; +const NONCE = 'AAAAAAAAAAAAAAAAAAAAAA'; + +function parseWorkspaceSource(path: string): ts.SourceFile { + const absolutePath = join(process.cwd(), ...path.split('/')); + return ts.createSourceFile( + path, + readFileSync(absolutePath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); +} + +function namedInterface(source: ts.SourceFile, name: string): ts.InterfaceDeclaration { + const declaration = source.statements.find( + (statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && statement.name.text === name, + ); + if (declaration === undefined) throw new Error(`Missing interface ${name}`); + return declaration; +} + +function memberName(member: ts.TypeElement): string | undefined { + const name = member.name; + if (name === undefined) return undefined; + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return undefined; +} + +function interfaceMemberNames(source: ts.SourceFile, name: string): readonly string[] { + return namedInterface(source, name) + .members.map(memberName) + .filter((member): member is string => member !== undefined); +} + +function visitCalls(source: ts.SourceFile): readonly ts.CallExpression[] { + const calls: ts.CallExpression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) calls.push(node); + ts.forEachChild(node, visit); + }; + visit(source); + return calls; +} + +function publicMethodNames(prototype: object): readonly string[] { + return Object.getOwnPropertyNames(prototype).filter( + (name) => name !== 'constructor' && typeof Reflect.get(prototype, name) === 'function', + ); +} + +function popupEvent(data: unknown): { event: MessageEvent; source: Window } { + const channel = new MessageChannel(); + const source = channel.port1 as unknown as Window; + return { + event: new MessageEvent('message', { + source, + origin: 'https://rp.example', + data, + }), + source, + }; +} + +function validProofRequest(): Record { + return { + action: 'note.create', + resource: 'note:example', + nonce: NONCE, + expiresAt: NOW + 120, + }; +} + +describe('v2 device-delegation security guardrails', () => { + it('keeps every private key created by the wallet vault non-extractable', () => { + const source = parseWorkspaceSource('packages/wallet-core/src/key-vault.ts'); + const generateKeyCalls = visitCalls(source).filter( + (call) => + ts.isPropertyAccessExpression(call.expression) && + call.expression.name.text === 'generateKey', + ); + + expect(generateKeyCalls.length).toBeGreaterThan(0); + for (const call of generateKeyCalls) { + expect(call.arguments[1]?.kind).toBe(ts.SyntaxKind.FalseKeyword); + } + + const privateKeyImports = visitCalls(source).filter((call) => { + if (!ts.isPropertyAccessExpression(call.expression)) return false; + if (call.expression.name.text !== 'importKey') return false; + const format = call.arguments[0]; + return format !== undefined && ts.isStringLiteral(format) && format.text === 'pkcs8'; + }); + expect(privateKeyImports.length).toBeGreaterThan(0); + for (const call of privateKeyImports) { + expect(call.arguments[3]?.kind).toBe(ts.SyntaxKind.FalseKeyword); + } + }); + + it('does not expose raw identity or root-key extraction through wallet public APIs', () => { + const source = parseWorkspaceSource('packages/wallet-core/src/types.ts'); + const interfaceMethods = [ + ...interfaceMemberNames(source, 'KeyVault'), + ...interfaceMemberNames(source, 'WalletCoreApi'), + ]; + const runtimeMethods = [ + ...publicMethodNames(InMemoryKeyVault.prototype), + ...publicMethodNames(WebCryptoIndexedDbKeyVault.prototype), + ...publicMethodNames(WalletCore.prototype), + ]; + const forbiddenPrivateExport = + /(?:export|extract|backup|dump|serialize|restore).*(?:identity|root|private|secret)|(?:identity|root|private|secret).*(?:export|extract|backup|dump|serialize|restore)/iu; + + expect( + [...interfaceMethods, ...runtimeMethods].filter((name) => forbiddenPrivateExport.test(name)), + ).toEqual([]); + }); + + it('permits only authenticated encrypted device-key transfer output', async () => { + const wallet = new WalletCore({ + keyVault: new InMemoryKeyVault(), + identityStore: new InMemoryIdentityStore(), + registryClient: new InMemoryRegistryClient({ clock: { now: () => NOW } }), + verifyRegistryReceipt: acceptUnsignedInMemoryRegistryReceipt, + clock: { now: () => NOW }, + }); + const root = await wallet.createIdentity(); + + const transfer = await wallet.issueDeviceTransfer(root.localId); + + expect(Object.keys(transfer).sort()).toEqual(['authorization', 'bundle', 'transferKey']); + expect(transfer.transferKey).toBeInstanceOf(Uint8Array); + expect(transfer.transferKey).toHaveLength(32); + expect(Object.keys(transfer.bundle).sort()).toEqual([ + 'bundleId', + 'ciphertext', + 'iv', + 'protocol', + 'salt', + 'suite', + ]); + expect(transfer.bundle).toMatchObject({ + protocol: 'nexus.device-transfer.v2', + suite: 'NX-HKDF-SHA256-AES256GCM-v2', + }); + expect(transfer.bundle.ciphertext).not.toContain(root.genesis.signingKey.publicKey); + expect(Object.keys(transfer.bundle)).not.toContain('devicePrivateKey'); + expect(Object.keys(transfer.bundle)).not.toContain('rootPrivateKey'); + }); + + it('does not expose device management or root authorization to an RP popup', () => { + const managementTypes = [ + 'NEXUS_DEVICE_AUTHORIZE_REQUEST', + 'NEXUS_DEVICE_EXPORT_REQUEST', + 'NEXUS_DEVICE_ROOT_REVOKE_REQUEST', + 'NEXUS_ROOT_SIGN_REQUEST', + ]; + for (const type of managementTypes) { + const { event, source } = popupEvent({ + channel: NEXUS_POPUP_CHANNEL_V2, + type, + requestId: REQUEST_ID, + deviceId: 'nxd2_attacker', + }); + expect(parseProofRequestMessage(event, source, NOW)).toBeUndefined(); + } + + const { event, source } = popupEvent({ + channel: NEXUS_POPUP_CHANNEL_V2, + type: 'NEXUS_PROOF_REQUEST', + requestId: REQUEST_ID, + request: validProofRequest(), + acceptedProofProtocols: ['nexus.ownership-proof.v2'], + rootOperation: { type: 'revoke-device', deviceId: 'nxd2_attacker' }, + }); + expect(parseProofRequestMessage(event, source, NOW)).toBeUndefined(); + }); + + it('preserves the exact v1 popup request and ready-message behavior', () => { + const { event, source } = popupEvent({ + channel: NEXUS_POPUP_CHANNEL, + type: 'NEXUS_PROOF_REQUEST', + requestId: REQUEST_ID, + request: validProofRequest(), + }); + expect(parseProofRequestMessage(event, source, NOW)).toMatchObject({ + popupProtocol: NEXUS_POPUP_CHANNEL, + acceptedProofProtocols: ['nexus.ownership-proof.v1'], + origin: 'https://rp.example', + }); + + expect(parseWalletMessage({ channel: NEXUS_POPUP_CHANNEL, type: 'NEXUS_READY' })).toEqual({ + channel: 'nexus.popup.v1', + type: 'NEXUS_READY', + }); + expect( + parseWalletMessage({ + channel: NEXUS_POPUP_CHANNEL, + type: 'NEXUS_READY', + supportedProofProtocols: ['nexus.ownership-proof.v2', 'nexus.ownership-proof.v1'], + }), + ).toBeUndefined(); + expect(publicMethodNames(WalletCore.prototype)).toContain('prove'); + }); +}); diff --git a/tests/worker-integration/env.d.ts b/tests/worker-integration/env.d.ts index 58ff5b3..a5e4abe 100644 --- a/tests/worker-integration/env.d.ts +++ b/tests/worker-integration/env.d.ts @@ -1,11 +1,11 @@ -import type { RegistryEventV1 } from '@nexus/protocol'; +import type { DeviceRegistryEventV2, RegistryEventV1 } from '@nexus/protocol'; import type { D1Migration } from 'cloudflare:test'; import type { IdentityState } from '../../workers/registry/src/identity-state-do'; -interface AcceptanceQueueControl { +interface AcceptanceQueueControl { reset(): Promise; - getMessages(): Promise; + getMessages(): Promise; } declare global { @@ -14,7 +14,8 @@ declare global { IDENTITY_STATE: DurableObjectNamespace; INDEX_DB: D1Database; TEST_MIGRATIONS: D1Migration[]; - TEST_QUEUE_CONTROL: AcceptanceQueueControl; + TEST_QUEUE_CONTROL: AcceptanceQueueControl; + TEST_DEVICE_QUEUE_CONTROL: AcceptanceQueueControl; } } } diff --git a/tests/worker-integration/helpers.ts b/tests/worker-integration/helpers.ts index 16c2403..5929f6e 100644 --- a/tests/worker-integration/helpers.ts +++ b/tests/worker-integration/helpers.ts @@ -2,6 +2,8 @@ import { env } from 'cloudflare:workers'; import { computeRevocationCommitment, + deriveDeviceAuthorizationIdV2, + deriveDeviceIdV2, deriveGenesisHash, deriveSubject, signProtocolPayload, @@ -10,6 +12,13 @@ import { OWNERSHIP_PROOF_PROTOCOL_V1, REVOKE_PROTOCOL_V1, REVOKE_SECRET_PROTOCOL_V1, + base64Url32Schema, + base64Url64Schema, + deviceActivationRequestV2Schema, + deviceAuthorizationPayloadV2Schema, + deviceRegistryReceiptV2Schema, + deviceRootRevokeRequestV2Schema, + deviceStatusStatementV2Schema, encodeBase64Url, identityGenesisV1Schema, ownershipProofPayloadV1Schema, @@ -19,7 +28,14 @@ import { revokeBySecretV1Schema, revokeBySignaturePayloadV1Schema, statusStatementV1Schema, + type DeviceActivationRequestV2, + type DeviceAuthorizationV2, type IdentityGenesisV1, + type DeviceRegistryEventV2, + type DeviceRegistryReceiptPayloadV2, + type DeviceRootRevokeRequestV2, + type DeviceStatusStatementPayloadV2, + type NexusDeviceIdV2, type NexusSubject, type OwnershipProofV1, type RegistryEventV1, @@ -53,6 +69,12 @@ export interface AcceptanceIdentity { revocationSecret: Uint8Array; } +export interface AcceptanceDevice { + authorization: DeviceAuthorizationV2; + activation: DeviceActivationRequestV2; + privateKey: CryptoKey; +} + export async function createAcceptanceIdentity(): Promise { const keyPair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); const signingPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey)); @@ -73,6 +95,71 @@ export async function createAcceptanceIdentity(): Promise { }; } +export async function createAcceptanceDevice( + identity: AcceptanceIdentity, + now: number, +): Promise { + const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); + const publicKey = base64Url32Schema.parse( + encodeBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), + ); + const deviceId = await deriveDeviceIdV2({ + subject: identity.subject, + signingKey: { alg: 'Ed25519', publicKey }, + }); + const payload = deviceAuthorizationPayloadV2Schema.parse({ + protocol: 'nexus.device-authorization.v2', + subject: identity.subject, + genesisHash: identity.genesisHash, + deviceId, + signingKey: { alg: 'Ed25519', publicKey }, + authorizationNonce: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + validFrom: now - 1, + activationDeadline: now + 300, + expiresAt: now + 3600, + }); + const authorization: DeviceAuthorizationV2 = { + payload, + rootSignature: base64Url64Schema.parse(await signProtocolPayload(payload, identity.privateKey)), + }; + const authorizationId = await deriveDeviceAuthorizationIdV2(payload); + const activationPayload = { + protocol: 'nexus.device-activation.v2', + subject: identity.subject, + deviceId, + authorizationId, + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + iat: now, + exp: now + 60, + } as const; + const activation = deviceActivationRequestV2Schema.parse({ + authorization, + payload: activationPayload, + deviceSignature: await signProtocolPayload(activationPayload, pair.privateKey), + }); + return { authorization, activation, privateKey: pair.privateKey }; +} + +export async function rootRevokeAcceptanceDevice( + identity: AcceptanceIdentity, + deviceId: NexusDeviceIdV2, + issuedAt: number, +): Promise { + const payload = { + protocol: 'nexus.device-root-revoke.v2', + subject: identity.subject, + genesisHash: identity.genesisHash, + deviceId, + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + issuedAt, + reasonCode: 'lost-device', + } as const; + return deviceRootRevokeRequestV2Schema.parse({ + payload, + rootSignature: await signProtocolPayload(payload, identity.privateKey), + }); +} + export function registryServiceAdapter(): RegistryService { return { async register(input) { @@ -94,6 +181,32 @@ export function registryServiceAdapter(): RegistryService { async revokeBySecret(input) { return await env.IDENTITY_STATE.getByName(input.subject).revokeBySecret(input); }, + async activateDevice(input) { + return await env.IDENTITY_STATE.getByName(input.payload.subject).activateDevice(input); + }, + async deviceStatus(input) { + return await env.IDENTITY_STATE.getByName(input.subject).deviceStatus({ + deviceId: input.deviceId, + authorizationId: input.authorizationId, + }); + }, + async deviceStatusBatch(inputs) { + return await Promise.all( + inputs.map( + async (input) => + await env.IDENTITY_STATE.getByName(input.subject).deviceStatus({ + deviceId: input.deviceId, + authorizationId: input.authorizationId, + }), + ), + ); + }, + async revokeDeviceSelf(input) { + return await env.IDENTITY_STATE.getByName(input.payload.subject).revokeDeviceSelf(input); + }, + async revokeDeviceRoot(input) { + return await env.IDENTITY_STATE.getByName(input.payload.subject).revokeDeviceRoot(input); + }, }; } @@ -105,6 +218,14 @@ function fakeStatusSignature(payload: StatusStatementPayloadV1) { return Promise.resolve(statusStatementV1Schema.parse({ payload, signature: ZERO_64 })); } +function fakeDeviceReceiptSignature(payload: DeviceRegistryReceiptPayloadV2) { + return Promise.resolve(deviceRegistryReceiptV2Schema.parse({ payload, signature: ZERO_64 })); +} + +function fakeDeviceStatusSignature(payload: DeviceStatusStatementPayloadV2) { + return Promise.resolve(deviceStatusStatementV2Schema.parse({ payload, signature: ZERO_64 })); +} + export function createAcceptanceEdge(now: number) { const registry = registryServiceAdapter(); const edgeEnv: EdgeEnv = { @@ -145,6 +266,8 @@ export function createAcceptanceEdge(now: number) { metricSink: null, signReceipt: fakeReceiptSignature, signStatus: fakeStatusSignature, + signDeviceReceipt: fakeDeviceReceiptSignature, + signDeviceStatus: fakeDeviceStatusSignature, }); return { app, edgeEnv, registry }; } @@ -242,8 +365,11 @@ export async function authoritativeStatusThroughEdge(subject: NexusSubject, now: } export async function resetAcceptanceState(): Promise { - await env.TEST_QUEUE_CONTROL.reset(); + await Promise.all([env.TEST_QUEUE_CONTROL.reset(), env.TEST_DEVICE_QUEUE_CONTROL.reset()]); await env.INDEX_DB.batch([ + env.INDEX_DB.prepare('DELETE FROM device_projection_heads'), + env.INDEX_DB.prepare('DELETE FROM device_states'), + env.INDEX_DB.prepare('DELETE FROM device_registry_events'), env.INDEX_DB.prepare('DELETE FROM registry_events'), env.INDEX_DB.prepare('DELETE FROM pending_registry_events'), env.INDEX_DB.prepare('DELETE FROM identities'), @@ -258,3 +384,12 @@ export async function queuedEvents(expectedCount: number): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const messages = await env.TEST_DEVICE_QUEUE_CONTROL.getMessages(); + if (messages.length >= expectedCount) return messages; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${expectedCount} device registry Queue messages.`); +} diff --git a/tests/worker-integration/registry-edge-projector.acceptance.ts b/tests/worker-integration/registry-edge-projector.acceptance.ts index fd296be..eac4bcb 100644 --- a/tests/worker-integration/registry-edge-projector.acceptance.ts +++ b/tests/worker-integration/registry-edge-projector.acceptance.ts @@ -1,9 +1,12 @@ import { env } from 'cloudflare:workers'; +import { createExecutionContext } from 'cloudflare:test'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + decodeBase64UrlExact, encodeBase64Url, registryStatusV1Schema, + transparencyInclusionProofV1Schema, type NexusSubject, type OwnershipProofV1, type VerificationExpectation, @@ -11,6 +14,7 @@ import { import { verifyOwnershipProof, verifyRpOperation, + verifyTransparencyInclusionProof, type ChallengeRecord, type ChallengeStore, } from '@nexus/verifier'; @@ -23,13 +27,17 @@ import { WALLET_ORIGIN, authoritativeStatusThroughEdge, createAcceptanceEdge, + createAcceptanceDevice, createAcceptanceIdentity, nexusPost, ownershipProof, queuedEvents, + queuedDeviceEvents, resetAcceptanceState, + rootRevokeAcceptanceDevice, secretRevocation, } from './helpers'; +import { createAcceptanceTransparency } from './transparency-harness'; const NOW = Math.floor(Date.now() / 1_000); @@ -87,6 +95,15 @@ function batch(...messages: Message[]): MessageBatch { } as unknown as MessageBatch; } +function deviceBatch(...messages: Message[]): MessageBatch { + return { + queue: 'nexus-registry-device-events', + messages, + ackAll: vi.fn(), + retryAll: vi.fn(), + } as unknown as MessageBatch; +} + type PublicBatchResult = { ok: true; status: unknown } | { ok: false; error: { code: string; message: string } }; @@ -234,7 +251,10 @@ describe('registry, edge, verifier, and projection acceptance', () => { if (registered === undefined || revoked === undefined) throw new Error('Missing event fixture.'); - const transparency = { append: vi.fn(() => Promise.resolve()) }; + const transparency = { + append: vi.fn(() => Promise.resolve()), + appendDevice: vi.fn(() => Promise.resolve()), + }; const early = queueMessage(revoked); const earlyDuplicate = queueMessage(revoked); await projector.queue(batch(early.value, earlyDuplicate.value), { @@ -315,4 +335,145 @@ describe('registry, edge, verifier, and projection acceptance', () => { expect(successfulSubjects).toEqual([third.subject, first.subject]); expect(JSON.stringify(payload.results[1])).not.toContain(missing.subject); }); + + it('projects and transparently proves v2 activation and root revocation from the distinct Queue', async () => { + const identity = await createAcceptanceIdentity(); + const device = await createAcceptanceDevice(identity, NOW); + const { app, edgeEnv } = createAcceptanceEdge(NOW); + + const registration = await app.fetch( + nexusPost( + '/v1/identity/register', + { subject: identity.subject, genesis: identity.genesis }, + WALLET_ORIGIN, + ), + edgeEnv, + ); + expect(registration.status).toBe(200); + + const activation = await app.fetch( + nexusPost('/v2/device/activate', device.activation, WALLET_ORIGIN), + edgeEnv, + ); + expect(activation.status).toBe(200); + + const rootRevocation = await rootRevokeAcceptanceDevice( + identity, + device.authorization.payload.deviceId, + NOW, + ); + const revocation = await app.fetch( + nexusPost('/v2/device/revoke-root', rootRevocation, WALLET_ORIGIN), + edgeEnv, + ); + expect(revocation.status).toBe(200); + + const [v1Events, deviceEvents] = await Promise.all([queuedEvents(1), queuedDeviceEvents(2)]); + expect(v1Events).toHaveLength(1); + expect(deviceEvents.map((event) => event.eventType)).toEqual(['activated', 'revoked']); + const activated = deviceEvents[0]; + const revoked = deviceEvents[1]; + if (activated === undefined || revoked === undefined) { + throw new Error('The v2 registry Queue did not contain both device lifecycle events.'); + } + + const transparencyService = createAcceptanceTransparency(createExecutionContext()); + const appendDeviceResults: Awaited>[] = []; + const transparency = { + append: (event: (typeof v1Events)[number]) => transparencyService.append(event), + appendDevice: async (event: (typeof deviceEvents)[number]) => { + const result = await transparencyService.appendDevice(event); + appendDeviceResults.push(result); + return result; + }, + }; + + const rootMessage = queueMessage(v1Events[0]); + await projector.queue(batch(rootMessage.value), { + INDEX_DB: env.INDEX_DB, + TRANSPARENCY_SERVICE: transparency, + }); + expect(rootMessage.ack).toHaveBeenCalledOnce(); + + const deviceMessages = [ + queueMessage(activated), + queueMessage(activated), + queueMessage(revoked), + queueMessage(revoked), + ]; + await projector.queue(deviceBatch(...deviceMessages.map((message) => message.value)), { + INDEX_DB: env.INDEX_DB, + TRANSPARENCY_SERVICE: transparency, + }); + expect(deviceMessages.every((message) => message.ack.mock.calls.length === 1)).toBe(true); + expect(deviceMessages.every((message) => message.retry.mock.calls.length === 0)).toBe(true); + expect(appendDeviceResults.map((result) => result.duplicate)).toEqual([ + false, + true, + false, + true, + ]); + + const projectedIdentity = await env.INDEX_DB.prepare( + `SELECT state, sequence FROM identities WHERE subject = ?`, + ) + .bind(identity.subject) + .first<{ state: string; sequence: number }>(); + expect(projectedIdentity).toEqual({ state: 'active', sequence: 0 }); + expect(await count('registry_events')).toBe(1); + expect(await count('device_registry_events')).toBe(2); + + const projectedDevice = await env.INDEX_DB.prepare( + `SELECT authorization_id, state, event_sequence, activated_at, revoked_at, + revoked_by, latest_event_id + FROM device_states WHERE subject = ? AND device_id = ?`, + ) + .bind(identity.subject, device.authorization.payload.deviceId) + .first<{ + authorization_id: string | null; + state: string; + event_sequence: number; + activated_at: number | null; + revoked_at: number | null; + revoked_by: string | null; + latest_event_id: string; + }>(); + expect(projectedDevice).toEqual({ + authorization_id: activated.authorizationId, + state: 'revoked', + event_sequence: 2, + activated_at: activated.acceptedAt, + revoked_at: revoked.acceptedAt, + revoked_by: 'root', + latest_event_id: revoked.eventId, + }); + const head = await env.INDEX_DB.prepare( + `SELECT max_device_ledger_sequence FROM device_projection_heads WHERE subject = ?`, + ) + .bind(identity.subject) + .first<{ max_device_ledger_sequence: number }>(); + expect(head).toEqual({ max_device_ledger_sequence: 2 }); + + const eventHash = decodeBase64UrlExact(revoked.eventId.slice('nxde2_'.length), 32); + const eventHashBase64Url = encodeBase64Url(eventHash); + const inclusionResponse = await transparencyService.fetch( + new Request('https://transparency.test/v1/transparency/inclusion', { + method: 'POST', + headers: { 'content-type': 'application/nexus+json' }, + body: JSON.stringify({ eventHash: eventHashBase64Url }), + }), + ); + expect(inclusionResponse.status).toBe(200); + const inclusion = transparencyInclusionProofV1Schema.parse(await inclusionResponse.json()); + expect(inclusion.eventHash).toBe(eventHashBase64Url); + await expect( + verifyTransparencyInclusionProof({ + eventHash, + leafIndex: inclusion.leafIndex, + treeSize: inclusion.treeSize, + auditPath: inclusion.auditPath.map((hash) => decodeBase64UrlExact(hash, 32)), + expectedRoot: decodeBase64UrlExact(inclusion.checkpoint.payload.rootHash, 32), + }), + ).resolves.toBe(true); + }); }); diff --git a/tests/worker-integration/transparency-harness.ts b/tests/worker-integration/transparency-harness.ts new file mode 100644 index 0000000..b057cad --- /dev/null +++ b/tests/worker-integration/transparency-harness.ts @@ -0,0 +1,186 @@ +import { decodeBase64UrlExact, encodeBase64Url } from '@nexus/protocol'; +import type { + AppendResult, + StoredCheckpoint, + StoredInclusionProof, +} from '../../workers/transparency/src/types'; +import { + hashLeaf, + hashNode, + inclusionProof, + treeRoot, + type HashFunction, + type MerkleNodeReader, +} from '../../workers/transparency/src/merkle'; +import TransparencyService from '../../workers/transparency/src/index'; +import type { TransparencyShard } from '../../workers/transparency/src/transparency-shard-do'; + +interface StoredEvent { + eventId: string; + eventHash: string; + leafIndex: number; +} + +interface InternalRequest { + eventId?: string; + eventHash?: string; + shardId: string; + checkpointedAt?: number; +} + +const sha256: HashFunction = async (value) => { + const owned = new Uint8Array(value.byteLength); + owned.set(value); + return new Uint8Array(await crypto.subtle.digest('SHA-256', owned.buffer)); +}; + +class MemoryTransparencyShard implements MerkleNodeReader { + readonly #eventsById = new Map(); + readonly #eventsByHash = new Map(); + readonly #nodes = new Map(); + readonly #checkpoints = new Map(); + #size = 0; + + constructor(readonly shardId: string) {} + + getNode(level: number, nodeIndex: number): Uint8Array | undefined { + return this.#nodes.get(`${level}:${nodeIndex}`); + } + + async fetch(requestInput: RequestInfo | URL, init?: RequestInit): Promise { + const request = + requestInput instanceof Request ? requestInput : new Request(requestInput, init); + const url = new URL(request.url); + const input = await request.json(); + if (input.shardId !== this.shardId) return jsonError('SHARD_CONFLICT', 409); + + if (url.pathname === '/internal/v1/append') { + if (input.eventId === undefined || input.eventHash === undefined) { + return jsonError('BAD_REQUEST', 400); + } + return Response.json(await this.#append(input.eventId, input.eventHash)); + } + if (url.pathname === '/internal/v1/checkpoint') { + return Response.json(await this.#checkpoint(input.checkpointedAt ?? 0)); + } + if (url.pathname === '/internal/v1/inclusion') { + const eventHash = url.searchParams.get('eventHash'); + if (eventHash === null) return jsonError('BAD_REQUEST', 400); + return Response.json(await this.#prove(eventHash, input.checkpointedAt ?? 0)); + } + return jsonError('NOT_FOUND', 404); + } + + async #append(eventId: string, eventHash: string): Promise { + const hashBytes = decodeBase64UrlExact(eventHash, 32); + const duplicate = this.#eventsById.get(eventId); + if (duplicate !== undefined) { + if (duplicate.eventHash !== eventHash) throw new Error('Conflicting transparency event ID.'); + return this.#appendResult(duplicate, true); + } + if (this.#eventsByHash.has(eventHash)) throw new Error('Conflicting transparency event hash.'); + + let level = 0; + let nodeIndex = this.#size; + let node = await hashLeaf(hashBytes, sha256); + this.#nodes.set(`${level}:${nodeIndex}`, node); + while (nodeIndex % 2 === 1) { + const left = this.getNode(level, nodeIndex - 1); + if (left === undefined) throw new Error('The acceptance Merkle tree is corrupt.'); + node = await hashNode(left, node, sha256); + nodeIndex = Math.floor(nodeIndex / 2); + level += 1; + this.#nodes.set(`${level}:${nodeIndex}`, node); + } + + const stored = { eventId, eventHash, leafIndex: this.#size }; + this.#eventsById.set(eventId, stored); + this.#eventsByHash.set(eventHash, stored); + this.#size += 1; + return this.#appendResult(stored, false); + } + + #appendResult(event: StoredEvent, duplicate: boolean): AppendResult { + return { + eventId: event.eventId, + eventHash: event.eventHash, + shardId: this.shardId, + leafIndex: event.leafIndex, + treeSize: this.#size, + duplicate, + }; + } + + async #checkpoint(checkpointedAt: number): Promise { + const prior = this.#checkpoints.get(this.#size); + if (prior !== undefined) return prior; + const checkpoint = { + shardId: this.shardId, + treeSize: this.#size, + rootHash: encodeBase64Url(await treeRoot(this, this.#size, sha256)), + checkpointedAt, + }; + this.#checkpoints.set(this.#size, checkpoint); + return checkpoint; + } + + async #prove(eventHash: string, checkpointedAt: number): Promise { + const event = this.#eventsByHash.get(eventHash); + if (event === undefined) throw new Error('Transparency event not found.'); + const checkpoint = await this.#checkpoint(checkpointedAt); + const auditPath = await inclusionProof(this, event.leafIndex, checkpoint.treeSize, sha256); + return { + ...checkpoint, + eventHash, + leafIndex: event.leafIndex, + auditPath: auditPath.map(encodeBase64Url), + }; + } +} + +class MemoryTransparencyNamespace { + readonly #shards = new Map(); + + idFromName(name: string): DurableObjectId { + return name as unknown as DurableObjectId; + } + + get(id: DurableObjectId): DurableObjectStub { + const name = id as unknown as string; + let shard = this.#shards.get(name); + if (shard === undefined) { + shard = new MemoryTransparencyShard(name); + this.#shards.set(name, shard); + } + return shard as unknown as DurableObjectStub; + } +} + +function jsonError(code: string, status: number): Response { + return Response.json({ error: { code, message: code } }, { status }); +} + +const TEST_PRIVATE_KEY_PKCS8 = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; // gitleaks:allow -- fixed test-only Ed25519 fixture +const TEST_PUBLIC_KEY = '_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU'; + +export function createAcceptanceTransparency(context: ExecutionContext): TransparencyService { + return new TransparencyService(context, { + TRANSPARENCY_SHARDS: + new MemoryTransparencyNamespace() as unknown as DurableObjectNamespace, + TRANSPARENCY_ARTIFACTS: {} as R2Bucket, + TRANSPARENCY_SIGNING_KEY_PKCS8_B64URL: TEST_PRIVATE_KEY_PKCS8, + TRANSPARENCY_SIGNING_KID: 'acceptance-transparency', + TRANSPARENCY_PUBLIC_KEYSET_JSON: JSON.stringify({ + keys: [ + { + kty: 'OKP', + crv: 'Ed25519', + alg: 'EdDSA', + kid: 'acceptance-transparency', + x: TEST_PUBLIC_KEY, + use: 'sig', + }, + ], + }), + }); +} diff --git a/tests/worker-integration/vitest.config.ts b/tests/worker-integration/vitest.config.ts index 7401484..2d33d39 100644 --- a/tests/worker-integration/vitest.config.ts +++ b/tests/worker-integration/vitest.config.ts @@ -52,7 +52,9 @@ export default defineConfig(async () => { d1Databases: { INDEX_DB: 'nexus-index-acceptance' }, serviceBindings: { REGISTRY_EVENTS: 'acceptance-registry-queue', + REGISTRY_DEVICE_EVENTS: 'acceptance-registry-device-queue', TEST_QUEUE_CONTROL: 'acceptance-registry-queue', + TEST_DEVICE_QUEUE_CONTROL: 'acceptance-registry-device-queue', }, workers: [ { @@ -60,6 +62,11 @@ export default defineConfig(async () => { modules: true, script: queueScript, }, + { + name: 'acceptance-registry-device-queue', + modules: true, + script: queueScript, + }, ], }, }), diff --git a/workers/edge-api/src/index.ts b/workers/edge-api/src/index.ts index 6d39ffb..bf408a5 100644 --- a/workers/edge-api/src/index.ts +++ b/workers/edge-api/src/index.ts @@ -1,5 +1,6 @@ import { NEXUS_BODY_LIMITS, + NexusDeviceFault, NexusFault, assertExactRequestOrigin, assertHttps, @@ -9,12 +10,15 @@ import { createCorsHeaders, createJsonConsoleSink, createNexusErrorBody, + createNexusDeviceErrorBody, createRequestId, createWellKnownHeaders, + deviceFaultCode, emitAggregateMetric, faultCode, handleExactOriginPreflight, isNexusErrorCode, + isNexusDeviceErrorCode, latencyBucket, parseExactOrigin, readBodyBytes, @@ -22,21 +26,47 @@ import { signStatusStatement, sizeBucket, toNexusErrorResponse, + toNexusDeviceErrorResponse, type AggregateMetricSink, type AggregateOperation, + type NexusDeviceErrorCode, type ServiceSigningConfig, } from '@nexus/cloudflare-common'; -import { deriveGenesisHash, deriveSubject } from '@nexus/crypto'; import { + deriveGenesisHash, + deriveSubject, + getDefaultCryptoProvider, + signProtocolPayload, +} from '@nexus/crypto'; +import { + DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2, + DEVICE_STATUS_STATEMENT_PROTOCOL_V2, IDENTITY_PROTOCOL_V1, NEXUS_SUITE_V1, + OWNERSHIP_PROOF_PROTOCOL_V2, REGISTRY_RECEIPT_PROTOCOL_V1, REVOKE_PROTOCOL_V1, REVOKE_SECRET_PROTOCOL_V1, STATUS_STATEMENT_PROTOCOL_V1, + base64Url32Schema, + decodeBase64Url, + deviceActivationRequestV2Schema, + deviceRegistryEventV2Schema, + deviceRegistryReceiptV2Schema, + deviceRegistryStatusV2Schema, + deviceRootRevokeRequestV2Schema, + deviceSelfRevokeRequestV2Schema, + deviceStatusBatchResponseV2Schema, + deviceStatusBatchRequestV2Schema, + deviceStatusRequestV2Schema, + deviceStatusStatementV2Schema, encodeBase64Url, identityGenesisV1Schema, nexusEventIdSchema, + nexusDeviceAuthorizationIdV2Schema, + nexusDeviceEventIdV2Schema, + nexusDeviceIdV2Schema, + nexusDeviceOperationIdV2Schema, nexusSubjectSchema, registerIdentityRequestV1Schema, registryEventV1Schema, @@ -48,6 +78,16 @@ import { statusBatchRequestV1Schema, statusRequestV1Schema, statusStatementV1Schema, + type DeviceActivationRequestV2, + type DeviceRegistryEventV2, + type DeviceRegistryReceiptPayloadV2, + type DeviceRegistryReceiptV2, + type DeviceRegistryStatusV2, + type DeviceRootRevokeRequestV2, + type DeviceSelfRevokeRequestV2, + type DeviceStatusRequestV2, + type DeviceStatusStatementPayloadV2, + type DeviceStatusStatementV2, type IdentityGenesisV1, type NexusErrorCode, type NexusSubject, @@ -72,6 +112,9 @@ type RegistryErrorCode = | 'IDENTITY_REVOKED' | 'SEQUENCE_CONFLICT' | 'SUBJECT_GENESIS_CONFLICT' + | 'DEVICE_NOT_FOUND' + | 'DEVICE_REVOKED' + | 'DEVICE_AUTHORIZATION_CONFLICT' | 'INTERNAL_ERROR'; interface RegistryFault { @@ -100,6 +143,39 @@ interface RegistryMutation extends AuthoritativeStatus { readonly event: RegistryEventV1; } +export interface AuthoritativeDeviceStatusV2 { + readonly subject: string; + readonly genesisHash: string; + readonly identityState: 'active' | 'revoked'; + readonly identitySequence: number; + readonly deviceLedgerSequence: number; + readonly deviceId: string; + readonly authorizationId: string; + readonly deviceState: 'active' | 'revoked' | 'expired' | 'unknown'; + readonly activatedAt: number | null; + readonly revokedAt: number | null; + readonly authorizationExpiresAt: number | null; +} + +export interface DeviceRegistryMutationV2 { + readonly subject: string; + readonly genesisHash: string; + readonly identityState: 'active' | 'revoked'; + readonly identitySequence: number; + readonly deviceLedgerSequence: number; + readonly deviceId: string; + readonly authorizationId: string | null; + readonly deviceState: 'active' | 'revoked'; + readonly activatedAt: number | null; + readonly revokedAt: number | null; + readonly authorizationExpiresAt: number | null; + readonly operationId: string; + readonly eventId: string; + readonly eventType: 'activated' | 'revoked'; + readonly acceptedAt: number; + readonly event: DeviceRegistryEventV2; +} + /** Structural RPC contract. The edge Worker never imports the registry implementation. */ export interface RegistryService { register(input: { @@ -113,6 +189,19 @@ export interface RegistryService { readonly signature: string; }): Promise>; revokeBySecret(payload: RevokeBySecretV1): Promise>; + activateDevice( + input: DeviceActivationRequestV2, + ): Promise>; + deviceStatus(input: DeviceStatusRequestV2): Promise>; + deviceStatusBatch( + inputs: DeviceStatusRequestV2[], + ): Promise>>; + revokeDeviceSelf( + input: DeviceSelfRevokeRequestV2, + ): Promise>; + revokeDeviceRoot( + input: DeviceRootRevokeRequestV2, + ): Promise>; } export interface PublicApiRateLimiter { @@ -152,6 +241,14 @@ export interface EdgeDependencies { payload: StatusStatementPayloadV1, config: ServiceSigningConfig, ) => Promise; + readonly signDeviceReceipt?: ( + payload: DeviceRegistryReceiptPayloadV2, + config: ServiceSigningConfig, + ) => Promise; + readonly signDeviceStatus?: ( + payload: DeviceStatusStatementPayloadV2, + config: ServiceSigningConfig, + ) => Promise; } interface ResolvedDependencies { @@ -162,6 +259,8 @@ interface ResolvedDependencies { readonly rateLimitKey: NonNullable; readonly signReceipt: NonNullable; readonly signStatus: NonNullable; + readonly signDeviceReceipt: NonNullable; + readonly signDeviceStatus: NonNullable; } interface EdgeApi { @@ -183,6 +282,11 @@ const ROUTES: Readonly> = Object.freeze({ kind: 'well-known', method: 'GET', }, + '/.well-known/nexus-v2.json': { + operation: 'discovery', + kind: 'well-known', + method: 'GET', + }, '/.well-known/jwks.json': { operation: 'jwks', kind: 'well-known', method: 'GET' }, '/v1/identity/register': { operation: 'register', @@ -208,6 +312,36 @@ const ROUTES: Readonly> = Object.freeze({ method: 'POST', bodyLimit: NEXUS_BODY_LIMITS.revoke, }, + '/v2/device/activate': { + operation: 'register', + kind: 'mutation', + method: 'POST', + bodyLimit: 16 * 1_024, + }, + '/v2/device/status': { + operation: 'status', + kind: 'public-read', + method: 'POST', + bodyLimit: 2 * 1_024, + }, + '/v2/device/status-batch': { + operation: 'status_batch', + kind: 'public-read', + method: 'POST', + bodyLimit: 32 * 1_024, + }, + '/v2/device/revoke-self': { + operation: 'revoke', + kind: 'mutation', + method: 'POST', + bodyLimit: 16 * 1_024, + }, + '/v2/device/revoke-root': { + operation: 'revoke', + kind: 'mutation', + method: 'POST', + bodyLimit: 8 * 1_024, + }, }); const configsByEnvironment = new WeakMap< @@ -221,6 +355,69 @@ const configsByEnvironment = new WeakMap< const RATE_LIMIT_PERIOD_SECONDS = 60; const RATE_LIMIT_KEY_DOMAIN = 'NEXUS-EDGE-RATE-LIMIT\0v1\0'; let ephemeralRateLimitSalt: { bucket: number; value: Uint8Array } | undefined; +const v2ImportedKeyPromises = new WeakMap>(); + +function decodePemPrivateKey(value: string): Uint8Array { + const match = /^\s*-----BEGIN PRIVATE KEY-----([\s\S]+)-----END PRIVATE KEY-----\s*$/u.exec( + value, + ); + const body = match?.[1]?.replaceAll(/\s/gu, '') ?? ''; + if (body.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(body)) { + throw new NexusFault('INTERNAL_ERROR'); + } + try { + return Uint8Array.from(atob(body), (character) => character.charCodeAt(0)); + } catch { + throw new NexusFault('INTERNAL_ERROR'); + } +} + +function decodeV2SigningKey(value: string | Uint8Array): Uint8Array { + if (value instanceof Uint8Array) return value.slice(); + if (value.includes('-----BEGIN')) return decodePemPrivateKey(value); + try { + return decodeBase64Url(value); + } catch { + throw new NexusFault('INTERNAL_ERROR'); + } +} + +async function importV2SigningKey(config: ServiceSigningConfig): Promise { + const existing = v2ImportedKeyPromises.get(config); + if (existing !== undefined) return existing; + const provider = config.provider ?? getDefaultCryptoProvider(); + const pkcs8 = decodeV2SigningKey(config.privateKeyPkcs8); + const pending = provider.importEd25519PrivateKey(pkcs8, { extractable: false }).finally(() => { + pkcs8.fill(0); + }); + v2ImportedKeyPromises.set(config, pending); + try { + return await pending; + } catch { + v2ImportedKeyPromises.delete(config); + throw new NexusFault('INTERNAL_ERROR'); + } +} + +async function signDeviceReceipt( + payload: DeviceRegistryReceiptPayloadV2, + config: ServiceSigningConfig, +): Promise { + if (payload.signerKid !== config.signerKid) throw new NexusFault('INTERNAL_ERROR'); + const provider = config.provider ?? getDefaultCryptoProvider(); + const signature = await signProtocolPayload(payload, await importV2SigningKey(config), provider); + return deviceRegistryReceiptV2Schema.parse({ payload, signature }); +} + +async function signDeviceStatus( + payload: DeviceStatusStatementPayloadV2, + config: ServiceSigningConfig, +): Promise { + if (payload.signerKid !== config.signerKid) throw new NexusFault('INTERNAL_ERROR'); + const provider = config.provider ?? getDefaultCryptoProvider(); + const signature = await signProtocolPayload(payload, await importV2SigningKey(config), provider); + return deviceStatusStatementV2Schema.parse({ payload, signature }); +} function saltForRateLimitBucket(bucket: number): Uint8Array { if (ephemeralRateLimitSalt?.bucket === bucket) return ephemeralRateLimitSalt.value; @@ -520,15 +717,94 @@ function parseRevoke(value: unknown): ReturnType { + throwV2ProtocolHint(value, 'nexus.device-activation.v2'); + throwInvalidSubjectHint(value); + const result = deviceActivationRequestV2Schema.safeParse(value); + if (!result.success) throw new NexusFault('BAD_REQUEST'); + return result.data; +} + +function parseDeviceStatus(value: unknown): ReturnType { + throwInvalidSubjectHint(value); + const result = deviceStatusRequestV2Schema.safeParse(value); + if (!result.success) throw new NexusFault('BAD_REQUEST'); + return result.data; +} + +function parseDeviceStatusBatch( + value: unknown, +): ReturnType { + if (isRecord(value) && Array.isArray(value.devices)) { + for (const device of value.devices) throwInvalidSubjectHint(device); + } + const result = deviceStatusBatchRequestV2Schema.safeParse(value); + if (!result.success) throw new NexusFault('BAD_REQUEST'); + return result.data; +} + +function parseDeviceSelfRevoke( + value: unknown, +): ReturnType { + throwV2ProtocolHint(value, 'nexus.device-self-revoke.v2'); + throwInvalidSubjectHint(value); + const result = deviceSelfRevokeRequestV2Schema.safeParse(value); + if (!result.success) throw new NexusFault('BAD_REQUEST'); + return result.data; +} + +function parseDeviceRootRevoke( + value: unknown, +): ReturnType { + throwV2ProtocolHint(value, 'nexus.device-root-revoke.v2'); + throwInvalidSubjectHint(value); + const result = deviceRootRevokeRequestV2Schema.safeParse(value); + if (!result.success) throw new NexusFault('BAD_REQUEST'); + return result.data; +} + function registryError(error: RegistryFault): NexusFault { return new NexusFault(isNexusErrorCode(error.code) ? error.code : 'INTERNAL_ERROR'); } +function deviceRegistryError(error: RegistryFault): NexusDeviceFault { + return new NexusDeviceFault(isNexusDeviceErrorCode(error.code) ? error.code : 'INTERNAL_ERROR'); +} + function unwrapRegistry(result: RegistryResult): T { if (!result.ok) throw registryError(result.error); return result.value; } +function unwrapDeviceRegistry(result: RegistryResult): T { + if (!result.ok) throw deviceRegistryError(result.error); + return result.value; +} + async function callRegistry(call: () => Promise): Promise { try { return await call(); @@ -621,6 +897,124 @@ async function validateMutation( return value; } +function isSafeEpoch(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function validateDeviceStatus( + value: AuthoritativeDeviceStatusV2, + expected: DeviceStatusRequestV2, +): AuthoritativeDeviceStatusV2 { + if ( + value.subject !== expected.subject || + value.deviceId !== expected.deviceId || + value.authorizationId !== expected.authorizationId || + !nexusSubjectSchema.safeParse(value.subject).success || + !base64Url32Schema.safeParse(value.genesisHash).success || + !nexusDeviceIdV2Schema.safeParse(value.deviceId).success || + !nexusDeviceAuthorizationIdV2Schema.safeParse(value.authorizationId).success || + (value.identityState !== 'active' && value.identityState !== 'revoked') || + !isSafeEpoch(value.identitySequence) || + (value.identityState === 'active' && value.identitySequence !== 0) || + (value.identityState === 'revoked' && value.identitySequence !== 1) || + !isSafeEpoch(value.deviceLedgerSequence) || + !['active', 'revoked', 'expired', 'unknown'].includes(value.deviceState) || + (value.activatedAt !== null && !isSafeEpoch(value.activatedAt)) || + (value.revokedAt !== null && !isSafeEpoch(value.revokedAt)) || + (value.authorizationExpiresAt !== null && !isSafeEpoch(value.authorizationExpiresAt)) || + (value.deviceState === 'active' && + (value.identityState !== 'active' || + value.activatedAt === null || + value.revokedAt !== null || + value.authorizationExpiresAt === null)) || + (value.deviceState === 'expired' && + (value.identityState !== 'active' || + value.activatedAt === null || + value.revokedAt !== null || + value.authorizationExpiresAt === null)) || + (value.deviceState === 'revoked' && value.revokedAt === null) || + (value.deviceState === 'unknown' && + (value.activatedAt !== null || + value.revokedAt !== null || + value.authorizationExpiresAt !== null)) || + (value.activatedAt !== null && + value.revokedAt !== null && + value.revokedAt < value.activatedAt) || + (value.activatedAt !== null && + value.authorizationExpiresAt !== null && + value.authorizationExpiresAt < value.activatedAt) + ) { + throw new NexusFault('INTERNAL_ERROR'); + } + return value; +} + +function validateDeviceMutation( + value: DeviceRegistryMutationV2, + expectedSubject: NexusSubject, + expectedDeviceId: string, + expectedAuthorizationId?: string, +): DeviceRegistryMutationV2 { + const event = deviceRegistryEventV2Schema.safeParse(value.event); + if ( + value.subject !== expectedSubject || + value.deviceId !== expectedDeviceId || + (expectedAuthorizationId !== undefined && value.authorizationId !== expectedAuthorizationId) || + !nexusSubjectSchema.safeParse(value.subject).success || + !base64Url32Schema.safeParse(value.genesisHash).success || + !nexusDeviceIdV2Schema.safeParse(value.deviceId).success || + (value.authorizationId !== null && + !nexusDeviceAuthorizationIdV2Schema.safeParse(value.authorizationId).success) || + !nexusDeviceOperationIdV2Schema.safeParse(value.operationId).success || + !nexusDeviceEventIdV2Schema.safeParse(value.eventId).success || + (value.identityState !== 'active' && value.identityState !== 'revoked') || + !isSafeEpoch(value.identitySequence) || + (value.identityState === 'active' && value.identitySequence !== 0) || + (value.identityState === 'revoked' && value.identitySequence !== 1) || + !isSafeEpoch(value.deviceLedgerSequence) || + (value.eventType !== 'activated' && value.eventType !== 'revoked') || + (value.deviceState !== 'active' && value.deviceState !== 'revoked') || + (value.activatedAt !== null && !isSafeEpoch(value.activatedAt)) || + (value.revokedAt !== null && !isSafeEpoch(value.revokedAt)) || + (value.authorizationExpiresAt !== null && !isSafeEpoch(value.authorizationExpiresAt)) || + (value.eventType === 'activated' && + (value.deviceState !== 'active' || + value.activatedAt === null || + value.revokedAt !== null || + value.authorizationId === null || + value.authorizationExpiresAt === null)) || + (value.eventType === 'revoked' && + (value.deviceState !== 'revoked' || value.revokedAt === null)) || + (value.activatedAt !== null && + value.revokedAt !== null && + value.revokedAt < value.activatedAt) || + !isSafeEpoch(value.acceptedAt) || + !event.success + ) { + throw new NexusFault('INTERNAL_ERROR'); + } + + const parsedEvent = event.data; + if ( + parsedEvent.eventId !== value.eventId || + parsedEvent.operationId !== value.operationId || + parsedEvent.eventType !== value.eventType || + parsedEvent.subject !== value.subject || + parsedEvent.genesisHash !== value.genesisHash || + parsedEvent.identitySequence !== value.identitySequence || + parsedEvent.identityState !== value.identityState || + parsedEvent.deviceLedgerSequence !== value.deviceLedgerSequence || + parsedEvent.deviceId !== value.deviceId || + (parsedEvent.authorizationId ?? null) !== value.authorizationId || + parsedEvent.deviceState !== value.deviceState || + (parsedEvent.authorizationExpiresAt ?? null) !== value.authorizationExpiresAt || + parsedEvent.acceptedAt !== value.acceptedAt + ) { + throw new NexusFault('INTERNAL_ERROR'); + } + return value; +} + async function signedStatus( status: AuthoritativeStatus, env: Env, @@ -682,6 +1076,97 @@ async function signedMutation( return { receipt, status }; } +async function signedDeviceStatus( + status: AuthoritativeDeviceStatusV2, + env: Env, + dependencies: ResolvedDependencies, +): Promise { + const issuedAt = epochSeconds(dependencies); + const effectiveStatus: AuthoritativeDeviceStatusV2 = + status.deviceState === 'active' && + status.authorizationExpiresAt !== null && + issuedAt >= status.authorizationExpiresAt + ? { ...status, deviceState: 'expired' } + : status; + const expiresAt = + effectiveStatus.deviceState === 'active' && effectiveStatus.authorizationExpiresAt !== null + ? Math.min(issuedAt + statusTtl(env), effectiveStatus.authorizationExpiresAt) + : issuedAt + statusTtl(env); + const payload: DeviceStatusStatementPayloadV2 = { + protocol: DEVICE_STATUS_STATEMENT_PROTOCOL_V2, + subject: effectiveStatus.subject as NexusSubject, + genesisHash: effectiveStatus.genesisHash as DeviceStatusStatementPayloadV2['genesisHash'], + identityState: effectiveStatus.identityState, + identitySequence: effectiveStatus.identitySequence, + deviceLedgerSequence: effectiveStatus.deviceLedgerSequence, + deviceId: effectiveStatus.deviceId as DeviceStatusStatementPayloadV2['deviceId'], + authorizationId: + effectiveStatus.authorizationId as DeviceStatusStatementPayloadV2['authorizationId'], + deviceState: effectiveStatus.deviceState, + ...(effectiveStatus.activatedAt === null ? {} : { activatedAt: effectiveStatus.activatedAt }), + ...(effectiveStatus.revokedAt === null ? {} : { revokedAt: effectiveStatus.revokedAt }), + ...(effectiveStatus.authorizationExpiresAt === null + ? {} + : { authorizationExpiresAt: effectiveStatus.authorizationExpiresAt }), + iat: issuedAt, + exp: expiresAt, + signerKid: env.STATUS_SIGNING_KID, + }; + if (!Number.isSafeInteger(payload.exp)) throw new NexusFault('INTERNAL_ERROR'); + const statement = await dependencies.signDeviceStatus(payload, signingConfigs(env).status); + if (!deviceStatusStatementV2Schema.safeParse(statement).success) { + throw new NexusFault('INTERNAL_ERROR'); + } + const response = deviceRegistryStatusV2Schema.safeParse({ + ...effectiveStatus, + statusStatement: statement, + }); + if (!response.success) throw new NexusFault('INTERNAL_ERROR'); + return response.data; +} + +async function signedDeviceMutation( + mutation: DeviceRegistryMutationV2, + env: Env, + dependencies: ResolvedDependencies, +): Promise { + const receiptPayload: DeviceRegistryReceiptPayloadV2 = { + protocol: DEVICE_REGISTRY_RECEIPT_PROTOCOL_V2, + eventId: mutation.event.eventId, + operationId: mutation.event.operationId, + eventType: mutation.event.eventType, + subject: mutation.event.subject, + genesisHash: mutation.event.genesisHash, + identitySequence: mutation.event.identitySequence, + identityState: mutation.event.identityState, + deviceLedgerSequence: mutation.event.deviceLedgerSequence, + deviceId: mutation.event.deviceId, + ...(mutation.event.authorizationId === undefined + ? {} + : { authorizationId: mutation.event.authorizationId }), + deviceState: mutation.event.deviceState, + ...(mutation.event.authorizationExpiresAt === undefined + ? {} + : { authorizationExpiresAt: mutation.event.authorizationExpiresAt }), + acceptedAt: mutation.event.acceptedAt, + ...(mutation.event.revokedBy === undefined ? {} : { revokedBy: mutation.event.revokedBy }), + signerKid: env.RECEIPT_SIGNING_KID, + }; + const receipt = await dependencies.signDeviceReceipt(receiptPayload, signingConfigs(env).receipt); + if (!deviceRegistryReceiptV2Schema.safeParse(receipt).success) { + throw new NexusFault('INTERNAL_ERROR'); + } + return receipt; +} + +async function deviceMutationResponse( + mutation: DeviceRegistryMutationV2, + env: Env, + dependencies: ResolvedDependencies, +): Promise<{ readonly receipt: DeviceRegistryReceiptV2 }> { + return { receipt: await signedDeviceMutation(mutation, env, dependencies) }; +} + function publicPreflight(request: Request): Response { const origin = request.headers.get('Origin'); const requestedMethod = request.headers.get('Access-Control-Request-Method')?.toUpperCase(); @@ -754,6 +1239,30 @@ async function routeRequest( }; } + if (url.pathname === '/.well-known/nexus-v2.json') { + const apiOrigin = requireHttpsOrigin(env.PUBLIC_API_ORIGIN); + return { + response: new Response( + JSON.stringify({ + protocols: [ + IDENTITY_PROTOCOL_V1, + 'nexus.ownership-proof.v1', + 'nexus.device-authorization.v2', + OWNERSHIP_PROOF_PROTOCOL_V2, + ], + suites: [NEXUS_SUITE_V1], + registry: `${apiOrigin}/v1`, + deviceRegistry: `${apiOrigin}/v2/device`, + popupChannels: ['nexus.popup.v2', 'nexus.popup.v1'], + v1Discovery: `${apiOrigin}/.well-known/nexus.json`, + jwks: `${apiOrigin}/.well-known/jwks.json`, + wallet: requireHttpsOrigin(env.WALLET_ORIGIN), + }), + { status: 200, headers: createWellKnownHeaders() }, + ), + }; + } + if (url.pathname === '/.well-known/jwks.json') { let raw: unknown; try { @@ -856,6 +1365,108 @@ async function routeRequest( }; } + if (url.pathname === '/v2/device/activate') { + const parsed = parseDeviceActivation(body); + const result = await callRegistry(() => env.REGISTRY_SERVICE.activateDevice(parsed)); + const mutation = validateDeviceMutation( + unwrapDeviceRegistry(result), + parsed.payload.subject, + parsed.payload.deviceId, + parsed.payload.authorizationId, + ); + return { + response: jsonResponse( + await deviceMutationResponse(mutation, env, dependencies), + 200, + corsHeaders(route, request, env), + ), + }; + } + + if (url.pathname === '/v2/device/status') { + const parsed = parseDeviceStatus(body); + const result = await callRegistry(() => env.REGISTRY_SERVICE.deviceStatus(parsed)); + const status = validateDeviceStatus(unwrapDeviceRegistry(result), parsed); + return { + response: jsonResponse( + await signedDeviceStatus(status, env, dependencies), + 200, + corsHeaders(route, request, env), + ), + }; + } + + if (url.pathname === '/v2/device/status-batch') { + const parsed = parseDeviceStatusBatch(body); + const results = await callRegistry(() => + env.REGISTRY_SERVICE.deviceStatusBatch(parsed.devices), + ); + if (results.length !== parsed.devices.length) throw new NexusFault('INTERNAL_ERROR'); + const publicResults = await Promise.all( + results.map(async (result, index) => { + if (!result.ok) { + const code: NexusDeviceErrorCode = isNexusDeviceErrorCode(result.error.code) + ? result.error.code + : 'INTERNAL_ERROR'; + const expected = parsed.devices[index]; + if (expected === undefined) throw new NexusFault('INTERNAL_ERROR'); + return { + ok: false as const, + subject: expected.subject, + deviceId: expected.deviceId, + authorizationId: expected.authorizationId, + error: createNexusDeviceErrorBody(code).error, + }; + } + const expected = parsed.devices[index]; + if (expected === undefined) throw new NexusFault('INTERNAL_ERROR'); + const status = validateDeviceStatus(result.value, expected); + return { ok: true as const, status: await signedDeviceStatus(status, env, dependencies) }; + }), + ); + const responseBody = deviceStatusBatchResponseV2Schema.safeParse({ results: publicResults }); + if (!responseBody.success) throw new NexusFault('INTERNAL_ERROR'); + return { + response: jsonResponse(responseBody.data, 200, corsHeaders(route, request, env)), + batchSize: parsed.devices.length, + }; + } + + if (url.pathname === '/v2/device/revoke-self') { + const parsed = parseDeviceSelfRevoke(body); + const result = await callRegistry(() => env.REGISTRY_SERVICE.revokeDeviceSelf(parsed)); + const mutation = validateDeviceMutation( + unwrapDeviceRegistry(result), + parsed.payload.subject, + parsed.payload.deviceId, + parsed.payload.authorizationId, + ); + return { + response: jsonResponse( + await deviceMutationResponse(mutation, env, dependencies), + 200, + corsHeaders(route, request, env), + ), + }; + } + + if (url.pathname === '/v2/device/revoke-root') { + const parsed = parseDeviceRootRevoke(body); + const result = await callRegistry(() => env.REGISTRY_SERVICE.revokeDeviceRoot(parsed)); + const mutation = validateDeviceMutation( + unwrapDeviceRegistry(result), + parsed.payload.subject, + parsed.payload.deviceId, + ); + return { + response: jsonResponse( + await deviceMutationResponse(mutation, env, dependencies), + 200, + corsHeaders(route, request, env), + ), + }; + } + throw new NexusFault('BAD_REQUEST'); } @@ -869,6 +1480,8 @@ function resolveDependencies(overrides: EdgeDependencies): ResolvedDependencies rateLimitKey: overrides.rateLimitKey ?? createEphemeralRateLimitKey, signReceipt: overrides.signReceipt ?? signRegistryReceipt, signStatus: overrides.signStatus ?? signStatusStatement, + signDeviceReceipt: overrides.signDeviceReceipt ?? signDeviceReceipt, + signDeviceStatus: overrides.signDeviceStatus ?? signDeviceStatus, }; } @@ -897,14 +1510,16 @@ export function createEdgeApi(overrides: EdgeDependencies = {}): EdgeApi { } return routed.response; } catch (error) { - const code = faultCode(error); + const isDeviceRoute = + route !== undefined && new URL(request.url).pathname.startsWith('/v2/device/'); + const code = isDeviceRoute ? deviceFaultCode(error) : faultCode(error); if (route !== undefined && dependencies.metricSink !== null) { emitAggregateMetric(dependencies.metricSink, { operation: route.operation, result: code === 'INTERNAL_ERROR' || code === 'SERVICE_UNAVAILABLE' ? 'error' : 'rejected', latencyBucket: latencyBucket(dependencies.monotonicNow() - startedAt), - errorCode: code, + ...(isNexusErrorCode(code) ? { errorCode: code } : {}), }); } const headers = corsHeaders(route, request, env); @@ -914,10 +1529,9 @@ export function createEdgeApi(overrides: EdgeDependencies = {}): EdgeApi { if (code === 'RATE_LIMITED') { headers.set('Retry-After', String(RATE_LIMIT_PERIOD_SECONDS)); } - return toNexusErrorResponse(error, { - requestId, - headers, - }); + return isDeviceRoute + ? toNexusDeviceErrorResponse(error, { requestId, headers }) + : toNexusErrorResponse(error, { requestId, headers }); } }, }; diff --git a/workers/edge-api/test/edge-api.test.ts b/workers/edge-api/test/edge-api.test.ts index 5e39b8d..d37728b 100644 --- a/workers/edge-api/test/edge-api.test.ts +++ b/workers/edge-api/test/edge-api.test.ts @@ -2,13 +2,19 @@ import { describe, expect, it, vi } from 'vitest'; import { deriveGenesisHash, deriveSubject } from '@nexus/crypto'; import { + DEVICE_REGISTRY_EVENT_PROTOCOL_V2, REGISTRY_EVENT_PROTOCOL_V1, + deviceRegistryEventV2Schema, + deviceRegistryReceiptV2Schema, + deviceStatusStatementV2Schema, encodeBase64Url, identityGenesisV1Schema, registryEventV1Schema, registryReceiptV1Schema, statusStatementV1Schema, type RegistryReceiptPayloadV1, + type DeviceRegistryReceiptPayloadV2, + type DeviceStatusStatementPayloadV2, type StatusStatementPayloadV1, } from '@nexus/protocol'; @@ -91,7 +97,102 @@ async function fixture() { actionHash: B64_32_ZERO, }), }; - return { subject, active, activeMutation, revoked, revokedMutation }; + const deviceId = `nxd2_${B64_32_ONE}` as const; + const authorizationId = `nxa2_${B64_32_ONE}` as const; + const operationId = `nxo2_${B64_32_ONE}` as const; + const deviceEventId = `nxde2_${B64_32_ONE}` as const; + const deviceStatus = { + subject, + genesisHash, + identityState: 'active' as const, + identitySequence: 0, + deviceLedgerSequence: 1, + deviceId, + authorizationId, + deviceState: 'active' as const, + activatedAt: 1_700_000_010, + revokedAt: null, + authorizationExpiresAt: 1_730_000_000, + }; + const deviceActivationEvent = deviceRegistryEventV2Schema.parse({ + protocol: DEVICE_REGISTRY_EVENT_PROTOCOL_V2, + eventId: deviceEventId, + operationId, + eventType: 'activated', + subject, + genesisHash, + identitySequence: 0, + identityState: 'active', + deviceLedgerSequence: 1, + deviceId, + authorizationId, + deviceState: 'active', + authorizationExpiresAt: 1_730_000_000, + acceptedAt: 1_700_000_010, + actionHash: B64_32_ZERO, + }); + const deviceActivationMutation = { + ...deviceStatus, + operationId, + eventId: deviceEventId, + eventType: 'activated' as const, + acceptedAt: 1_700_000_010, + event: deviceActivationEvent, + }; + const deviceRevocationEvent = deviceRegistryEventV2Schema.parse({ + protocol: DEVICE_REGISTRY_EVENT_PROTOCOL_V2, + eventId: `nxde2_${B64_32_ZERO}`, + operationId: `nxo2_${B64_32_ZERO}`, + eventType: 'revoked', + subject, + genesisHash, + identitySequence: 0, + identityState: 'active', + deviceLedgerSequence: 2, + deviceId, + authorizationId, + deviceState: 'revoked', + authorizationExpiresAt: 1_730_000_000, + acceptedAt: 1_700_000_020, + actionHash: B64_32_ONE, + revokedBy: 'device', + }); + const deviceRevocationMutation = { + ...deviceStatus, + deviceLedgerSequence: 2, + deviceState: 'revoked' as const, + revokedAt: 1_700_000_020, + operationId: deviceRevocationEvent.operationId, + eventId: deviceRevocationEvent.eventId, + eventType: 'revoked' as const, + acceptedAt: deviceRevocationEvent.acceptedAt, + event: deviceRevocationEvent, + }; + const deviceRootRevocationEvent = deviceRegistryEventV2Schema.parse({ + ...deviceRevocationEvent, + eventId: `nxde2_${B64_32_ONE}`, + operationId: `nxo2_${B64_32_ONE}`, + revokedBy: 'root', + }); + const deviceRootRevocationMutation = { + ...deviceRevocationMutation, + operationId: deviceRootRevocationEvent.operationId, + eventId: deviceRootRevocationEvent.eventId, + event: deviceRootRevocationEvent, + }; + return { + subject, + active, + activeMutation, + revoked, + revokedMutation, + deviceId, + authorizationId, + deviceStatus, + deviceActivationMutation, + deviceRevocationMutation, + deviceRootRevocationMutation, + }; } function fakeSignReceipt(payload: RegistryReceiptPayloadV1) { @@ -102,7 +203,15 @@ function fakeSignStatus(payload: StatusStatementPayloadV1) { return Promise.resolve(statusStatementV1Schema.parse({ payload, signature: B64_64_ZERO })); } -async function setup(overrides: Pick = {}) { +function fakeSignDeviceReceipt(payload: DeviceRegistryReceiptPayloadV2) { + return Promise.resolve(deviceRegistryReceiptV2Schema.parse({ payload, signature: B64_64_ZERO })); +} + +function fakeSignDeviceStatus(payload: DeviceStatusStatementPayloadV2) { + return Promise.resolve(deviceStatusStatementV2Schema.parse({ payload, signature: B64_64_ZERO })); +} + +async function setup(overrides: Pick = {}) { const data = await fixture(); const calls = { register: vi.fn(() => @@ -123,6 +232,21 @@ async function setup(overrides: Pick = {}) { revokeBySecret: vi.fn(() => Promise.resolve({ ok: true as const, value: data.revokedMutation }), ), + activateDevice: vi.fn(() => + Promise.resolve({ ok: true as const, value: data.deviceActivationMutation }), + ), + deviceStatus: vi.fn(() => + Promise.resolve({ ok: true as const, value: data.deviceStatus }), + ), + deviceStatusBatch: vi.fn(() => + Promise.resolve([{ ok: true as const, value: data.deviceStatus }]), + ), + revokeDeviceSelf: vi.fn(() => + Promise.resolve({ ok: true as const, value: data.deviceRevocationMutation }), + ), + revokeDeviceRoot: vi.fn(() => + Promise.resolve({ ok: true as const, value: data.deviceRootRevocationMutation }), + ), rateLimit: vi.fn(() => Promise.resolve({ success: true })), }; const registry: RegistryService = { @@ -131,6 +255,11 @@ async function setup(overrides: Pick = {}) { statusBatch: calls.statusBatch, revokeBySignature: calls.revokeBySignature, revokeBySecret: calls.revokeBySecret, + activateDevice: calls.activateDevice, + deviceStatus: calls.deviceStatus, + deviceStatusBatch: calls.deviceStatusBatch, + revokeDeviceSelf: calls.revokeDeviceSelf, + revokeDeviceRoot: calls.revokeDeviceRoot, }; const env: Env = { REGISTRY_SERVICE: registry, @@ -170,6 +299,8 @@ async function setup(overrides: Pick = {}) { metricSink: null, signReceipt: fakeSignReceipt, signStatus: fakeSignStatus, + signDeviceReceipt: fakeSignDeviceReceipt, + signDeviceStatus: fakeSignDeviceStatus, ...overrides, }); return { ...data, calls, env, app }; @@ -210,10 +341,35 @@ describe('edge API', () => { expect(discovery.status).toBe(200); expect(discovery.headers.get('Cache-Control')).toBe('public, max-age=300, must-revalidate'); expect(discovery.headers.get('Access-Control-Allow-Origin')).toBe('*'); - expect(await discovery.json()).toEqual({ - protocols: ['nexus.identity.v1', 'nexus.ownership-proof.v1'], + expect(await discovery.text()).toBe( + JSON.stringify({ + protocols: ['nexus.identity.v1', 'nexus.ownership-proof.v1'], + suites: ['NX-25519-SHA256-JCS-v1'], + registry: `${API_ORIGIN}/v1`, + jwks: `${API_ORIGIN}/.well-known/jwks.json`, + wallet: WALLET_ORIGIN, + }), + ); + + const discoveryV2 = await app.fetch( + new Request(`${API_ORIGIN}/.well-known/nexus-v2.json`), + env, + ); + expect(discoveryV2.status).toBe(200); + expect(discoveryV2.headers.get('Cache-Control')).toBe('public, max-age=300, must-revalidate'); + expect(discoveryV2.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(await discoveryV2.json()).toEqual({ + protocols: [ + 'nexus.identity.v1', + 'nexus.ownership-proof.v1', + 'nexus.device-authorization.v2', + 'nexus.ownership-proof.v2', + ], suites: ['NX-25519-SHA256-JCS-v1'], registry: `${API_ORIGIN}/v1`, + deviceRegistry: `${API_ORIGIN}/v2/device`, + popupChannels: ['nexus.popup.v2', 'nexus.popup.v1'], + v1Discovery: `${API_ORIGIN}/.well-known/nexus.json`, jwks: `${API_ORIGIN}/.well-known/jwks.json`, wallet: WALLET_ORIGIN, }); @@ -518,6 +674,268 @@ describe('edge API', () => { expect(calls.revokeBySignature).toHaveBeenCalledOnce(); }); + it('activates a v2 device and returns only a service-signed receipt', async () => { + const { app, env, calls, subject, active, deviceId, authorizationId } = await setup(); + const authorization = { + payload: { + protocol: 'nexus.device-authorization.v2', + subject, + genesisHash: active.genesisHash, + deviceId, + signingKey: { alg: 'Ed25519', publicKey: B64_32_ONE }, + authorizationNonce: B64_32_ZERO, + validFrom: 1_700_000_000, + activationDeadline: 1_700_000_100, + expiresAt: 1_730_000_000, + }, + rootSignature: B64_64_ZERO, + }; + const request = { + authorization, + payload: { + protocol: 'nexus.device-activation.v2', + subject, + deviceId, + authorizationId, + requestId: B64_32_ONE, + iat: 1_700_000_010, + exp: 1_700_000_020, + }, + deviceSignature: B64_64_ZERO, + }; + const response = await app.fetch(post('/v2/device/activate', request, WALLET_ORIGIN), env); + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe(WALLET_ORIGIN); + expect(calls.activateDevice).toHaveBeenCalledWith(request); + const responseBody = await body(response); + expect(responseBody).toHaveProperty( + 'receipt.payload.protocol', + 'nexus.device-registry-receipt.v2', + ); + expect(responseBody).toHaveProperty('receipt.payload.deviceId', deviceId); + expect(responseBody).toHaveProperty('receipt.payload.authorizationId', authorizationId); + expect(responseBody).toHaveProperty('receipt.payload.eventType', 'activated'); + expect(responseBody).toHaveProperty('receipt.signature', B64_64_ZERO); + }); + + it('refuses to sign a registry mutation for a different device authorization', async () => { + const { + app, + env, + calls, + subject, + active, + deviceId, + authorizationId, + deviceActivationMutation, + } = await setup(); + const otherAuthorizationId = `nxa2_${B64_32_ZERO}` as const; + calls.activateDevice.mockResolvedValueOnce({ + ok: true, + value: { + ...deviceActivationMutation, + authorizationId: otherAuthorizationId, + event: deviceRegistryEventV2Schema.parse({ + ...deviceActivationMutation.event, + authorizationId: otherAuthorizationId, + }), + }, + }); + const response = await app.fetch( + post( + '/v2/device/activate', + { + authorization: { + payload: { + protocol: 'nexus.device-authorization.v2', + subject, + genesisHash: active.genesisHash, + deviceId, + signingKey: { alg: 'Ed25519', publicKey: B64_32_ONE }, + authorizationNonce: B64_32_ZERO, + validFrom: 1_700_000_000, + activationDeadline: 1_700_000_100, + expiresAt: 1_730_000_000, + }, + rootSignature: B64_64_ZERO, + }, + payload: { + protocol: 'nexus.device-activation.v2', + subject, + deviceId, + authorizationId, + requestId: B64_32_ONE, + iat: 1_700_000_010, + exp: 1_700_000_020, + }, + deviceSignature: B64_64_ZERO, + }, + WALLET_ORIGIN, + ), + env, + ); + expect(response.status).toBe(500); + expect(await body(response)).toHaveProperty('error.code', 'INTERNAL_ERROR'); + }); + + it('returns exact signed v2 device status and neutral ordered batch errors', async () => { + const { app, env, calls, subject, deviceId, authorizationId, deviceStatus } = await setup(); + const query = { subject, deviceId, authorizationId }; + const response = await app.fetch(post('/v2/device/status', query, 'https://rp.example'), env); + expect(response.status).toBe(200); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(calls.deviceStatus).toHaveBeenCalledWith(query); + expect(await body(response)).toHaveProperty( + 'statusStatement.payload.protocol', + 'nexus.device-status-statement.v2', + ); + + const otherAuthorizationId = `nxa2_${B64_32_ZERO}` as const; + calls.deviceStatusBatch.mockResolvedValueOnce([ + { ok: true, value: deviceStatus }, + { + ok: false, + error: { code: 'DEVICE_NOT_FOUND', message: `must not leak ${subject}` }, + }, + ]); + const batch = await app.fetch( + post('/v2/device/status-batch', { + devices: [query, { subject, deviceId, authorizationId: otherAuthorizationId }], + }), + env, + ); + expect(batch.status).toBe(200); + const batchText = await batch.text(); + const batchBody = JSON.parse(batchText) as { results: Array> }; + expect(batchBody.results.map((result) => result.ok)).toEqual([true, false]); + expect(batchBody.results[1]).toHaveProperty('authorizationId', otherAuthorizationId); + expect(batchBody.results[1]).toHaveProperty('error.code', 'DEVICE_NOT_FOUND'); + expect(batchText).not.toContain('must not leak'); + }); + + it('exposes device errors only on v2 routes', async () => { + const { app, env, calls, subject, deviceId, authorizationId } = await setup(); + calls.deviceStatus.mockResolvedValueOnce({ + ok: false, + error: { code: 'DEVICE_REVOKED', message: `must not leak ${subject}` }, + }); + const deviceResponse = await app.fetch( + post('/v2/device/status', { subject, deviceId, authorizationId }), + env, + ); + expect(deviceResponse.status).toBe(409); + const deviceText = await deviceResponse.text(); + const deviceBody: unknown = JSON.parse(deviceText); + expect(deviceBody).toMatchObject({ error: { code: 'DEVICE_REVOKED' } }); + expect(deviceText).not.toContain('must not leak'); + + calls.status.mockResolvedValueOnce({ + ok: false, + error: { code: 'DEVICE_NOT_FOUND', message: `must not leak ${subject}` }, + }); + const legacyResponse = await app.fetch(post('/v1/identity/status', { subject }), env); + expect(legacyResponse.status).toBe(500); + expect(await body(legacyResponse)).toHaveProperty('error.code', 'INTERNAL_ERROR'); + }); + + it('never signs active device status beyond authorization expiry', async () => { + const { app, env, calls, subject, deviceId, authorizationId, deviceStatus } = await setup(); + calls.deviceStatus.mockResolvedValueOnce({ + ok: true, + value: { ...deviceStatus, authorizationExpiresAt: 1_700_000_030 }, + }); + const query = { subject, deviceId, authorizationId }; + const active = await app.fetch(post('/v2/device/status', query), env); + expect(active.status).toBe(200); + expect(await body(active)).toMatchObject({ + deviceState: 'active', + statusStatement: { payload: { deviceState: 'active', exp: 1_700_000_030 } }, + }); + + const boundary = await setup({ now: () => 1_730_000_000 }); + const expired = await boundary.app.fetch( + post('/v2/device/status', { + subject: boundary.subject, + deviceId: boundary.deviceId, + authorizationId: boundary.authorizationId, + }), + boundary.env, + ); + expect(expired.status).toBe(200); + expect(await body(expired)).toMatchObject({ + deviceState: 'expired', + statusStatement: { payload: { deviceState: 'expired', iat: 1_730_000_000 } }, + }); + }); + + it('strictly parses and origin-protects both v2 device revocation routes', async () => { + const { app, env, calls, subject, active, deviceId, authorizationId } = await setup(); + const authorization = { + payload: { + protocol: 'nexus.device-authorization.v2', + subject, + genesisHash: active.genesisHash, + deviceId, + signingKey: { alg: 'Ed25519', publicKey: B64_32_ONE }, + authorizationNonce: B64_32_ZERO, + validFrom: 1_700_000_000, + activationDeadline: 1_700_000_100, + expiresAt: 1_730_000_000, + }, + rootSignature: B64_64_ZERO, + }; + const selfRequest = { + authorization, + payload: { + protocol: 'nexus.device-self-revoke.v2', + subject, + genesisHash: active.genesisHash, + deviceId, + authorizationId, + requestId: B64_32_ZERO, + issuedAt: 1_700_000_010, + }, + deviceSignature: B64_64_ZERO, + }; + const self = await app.fetch(post('/v2/device/revoke-self', selfRequest, WALLET_ORIGIN), env); + expect(self.status).toBe(200); + expect(calls.revokeDeviceSelf).toHaveBeenCalledWith(selfRequest); + expect(await body(self)).toHaveProperty( + 'receipt.payload.protocol', + 'nexus.device-registry-receipt.v2', + ); + + const rootRequest = { + payload: { + protocol: 'nexus.device-root-revoke.v2', + subject, + genesisHash: active.genesisHash, + deviceId, + requestId: B64_32_ONE, + issuedAt: 1_700_000_010, + }, + rootSignature: B64_64_ZERO, + }; + const wrongOrigin = await app.fetch( + post('/v2/device/revoke-root', rootRequest, 'https://evil.example'), + env, + ); + expect(wrongOrigin.status).toBe(403); + expect(calls.revokeDeviceRoot).not.toHaveBeenCalled(); + + const unknownField = await app.fetch( + post('/v2/device/revoke-root', { ...rootRequest, label: 'forbidden' }, WALLET_ORIGIN), + env, + ); + expect(unknownField.status).toBe(400); + expect(calls.revokeDeviceRoot).not.toHaveBeenCalled(); + + const root = await app.fetch(post('/v2/device/revoke-root', rootRequest, WALLET_ORIGIN), env); + expect(root.status).toBe(200); + expect(calls.revokeDeviceRoot).toHaveBeenCalledWith(rootRequest); + expect(await body(root)).toHaveProperty('receipt.payload.revokedBy', 'root'); + }); + it('maps thrown RPC failures and logs only aggregate dimensions', async () => { const { env, calls, subject } = await setup(); calls.status.mockRejectedValueOnce(new Error(`secret proof ${REVOCATION_SECRET}`)); diff --git a/workers/projector/src/device-event.ts b/workers/projector/src/device-event.ts new file mode 100644 index 0000000..ddfd349 --- /dev/null +++ b/workers/projector/src/device-event.ts @@ -0,0 +1,28 @@ +import { deriveDeviceRegistryEventIdV2 } from '@nexus/crypto'; +import { deviceRegistryEventV2Schema, type DeviceRegistryEventV2 } from '@nexus/protocol'; + +export class DeviceRegistryEventValidationError extends Error { + readonly code = 'INVALID_DEVICE_REGISTRY_EVENT'; + + constructor(reason: string) { + super(reason); + this.name = 'DeviceRegistryEventValidationError'; + } +} + +export async function validateDeviceRegistryEvent(value: unknown): Promise { + const parsed = deviceRegistryEventV2Schema.safeParse(value); + if (!parsed.success) { + throw new DeviceRegistryEventValidationError('event does not match DeviceRegistryEventV2'); + } + + const { eventId, ...eventWithoutEventId } = parsed.data; + const derived = await deriveDeviceRegistryEventIdV2(eventWithoutEventId); + if (derived.eventId !== eventId) { + throw new DeviceRegistryEventValidationError( + 'event id does not match the canonical device event payload', + ); + } + + return parsed.data; +} diff --git a/workers/projector/src/device-projection.ts b/workers/projector/src/device-projection.ts new file mode 100644 index 0000000..93b9808 --- /dev/null +++ b/workers/projector/src/device-projection.ts @@ -0,0 +1,241 @@ +import type { DeviceRegistryEventV2, RegistryEventV1 } from '@nexus/protocol'; + +interface GenesisRow { + genesis_hash: string; +} + +interface StoredDeviceEventRow { + event_id: DeviceRegistryEventV2['eventId']; + protocol: DeviceRegistryEventV2['protocol']; + operation_id: DeviceRegistryEventV2['operationId']; + event_type: DeviceRegistryEventV2['eventType']; + subject: DeviceRegistryEventV2['subject']; + genesis_hash: DeviceRegistryEventV2['genesisHash']; + identity_sequence: number; + identity_state: DeviceRegistryEventV2['identityState']; + device_ledger_sequence: number; + device_id: DeviceRegistryEventV2['deviceId']; + authorization_id: NonNullable | null; + device_state: DeviceRegistryEventV2['deviceState']; + authorization_expires_at: number | null; + accepted_at: number; + action_hash: DeviceRegistryEventV2['actionHash']; + revoked_by: NonNullable | null; +} + +export class DeviceProjectionInvariantError extends Error { + readonly code = 'DEVICE_PROJECTION_INVARIANT_VIOLATION'; + + constructor(reason: string) { + super(reason); + this.name = 'DeviceProjectionInvariantError'; + } +} + +/** + * Prevents an independently delivered v2 event from attaching device state to + * a conflicting v1 anchor. The subject hash should already make this + * impossible; retaining the check gives the rebuildable projection a fail- + * closed corruption boundary. + */ +async function assertDeviceEventGenesis( + database: D1Database, + event: DeviceRegistryEventV2, +): Promise { + const [identity, priorEvent] = await Promise.all([ + database + .prepare('SELECT genesis_hash FROM identities WHERE subject = ? LIMIT 1') + .bind(event.subject) + .first(), + database + .prepare( + `SELECT genesis_hash FROM device_registry_events + WHERE subject = ? LIMIT 1`, + ) + .bind(event.subject) + .first(), + ]); + + if (identity !== null && identity.genesis_hash !== event.genesisHash) { + throw new DeviceProjectionInvariantError('device event conflicts with identity genesis hash'); + } + if (priorEvent !== null && priorEvent.genesis_hash !== event.genesisHash) { + throw new DeviceProjectionInvariantError('subject has conflicting device-event genesis hash'); + } +} + +export async function assertRegistrationDeviceGenesis( + database: D1Database, + event: RegistryEventV1, +): Promise { + const priorEvent = await database + .prepare( + `SELECT genesis_hash FROM device_registry_events + WHERE subject = ? LIMIT 1`, + ) + .bind(event.subject) + .first(); + if (priorEvent !== null && priorEvent.genesis_hash !== event.genesisHash) { + throw new DeviceProjectionInvariantError( + 'registration conflicts with device-event genesis hash', + ); + } +} + +async function storeDeviceEvent(database: D1Database, event: DeviceRegistryEventV2): Promise { + await database + .prepare( + `INSERT INTO device_registry_events ( + event_id, protocol, operation_id, event_type, subject, genesis_hash, + identity_sequence, identity_state, device_ledger_sequence, device_id, + authorization_id, device_state, authorization_expires_at, accepted_at, + action_hash, revoked_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(event_id) DO NOTHING`, + ) + .bind( + event.eventId, + event.protocol, + event.operationId, + event.eventType, + event.subject, + event.genesisHash, + event.identitySequence, + event.identityState, + event.deviceLedgerSequence, + event.deviceId, + event.authorizationId ?? null, + event.deviceState, + event.authorizationExpiresAt ?? null, + event.acceptedAt, + event.actionHash, + event.revokedBy ?? null, + ) + .run(); +} + +async function materializeDeviceEvent( + database: D1Database, + event: DeviceRegistryEventV2, +): Promise { + const identity = await database + .prepare('SELECT genesis_hash FROM identities WHERE subject = ? LIMIT 1') + .bind(event.subject) + .first(); + if (identity === null) { + return; + } + if (identity.genesis_hash !== event.genesisHash) { + throw new DeviceProjectionInvariantError('device event conflicts with projected identity'); + } + + const activatedAt = event.eventType === 'activated' ? event.acceptedAt : null; + const revokedAt = event.eventType === 'revoked' ? event.acceptedAt : null; + + await database.batch([ + database + .prepare( + `INSERT INTO device_states ( + subject, genesis_hash, device_id, authorization_id, state, + event_sequence, authorization_expires_at, activated_at, revoked_at, + revoked_by, updated_at, latest_event_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(subject, device_id) DO UPDATE SET + authorization_id = COALESCE(excluded.authorization_id, device_states.authorization_id), + state = excluded.state, + event_sequence = excluded.event_sequence, + authorization_expires_at = COALESCE( + excluded.authorization_expires_at, + device_states.authorization_expires_at + ), + activated_at = COALESCE(device_states.activated_at, excluded.activated_at), + revoked_at = excluded.revoked_at, + revoked_by = excluded.revoked_by, + updated_at = excluded.updated_at, + latest_event_id = excluded.latest_event_id + WHERE device_states.event_sequence < excluded.event_sequence + AND device_states.state <> 'revoked'`, + ) + .bind( + event.subject, + event.genesisHash, + event.deviceId, + event.authorizationId ?? null, + event.deviceState, + event.deviceLedgerSequence, + event.authorizationExpiresAt ?? null, + activatedAt, + revokedAt, + event.revokedBy ?? null, + event.acceptedAt, + event.eventId, + ), + database + .prepare( + `INSERT INTO device_projection_heads ( + subject, genesis_hash, max_device_ledger_sequence, updated_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(subject) DO UPDATE SET + max_device_ledger_sequence = excluded.max_device_ledger_sequence, + updated_at = excluded.updated_at + WHERE device_projection_heads.max_device_ledger_sequence + < excluded.max_device_ledger_sequence`, + ) + .bind(event.subject, event.genesisHash, event.deviceLedgerSequence, event.acceptedAt), + ]); +} + +export async function applyDeviceRegistryEvent( + database: D1Database, + event: DeviceRegistryEventV2, +): Promise { + await assertDeviceEventGenesis(database, event); + await storeDeviceEvent(database, event); + await materializeDeviceEvent(database, event); +} + +/** Materialize v2 events that won their queue race against v1 registration. */ +export async function reconcileDeviceEventsForIdentity( + database: D1Database, + subject: RegistryEventV1['subject'], +): Promise { + const result = await database + .prepare( + `SELECT event_id, protocol, operation_id, event_type, subject, genesis_hash, + identity_sequence, identity_state, device_ledger_sequence, device_id, + authorization_id, device_state, authorization_expires_at, accepted_at, + action_hash, revoked_by + FROM device_registry_events + WHERE subject = ? + ORDER BY device_ledger_sequence ASC`, + ) + .bind(subject) + .all(); + + for (const row of result.results) { + await materializeDeviceEvent(database, deviceEventFromRow(row)); + } +} + +function deviceEventFromRow(row: StoredDeviceEventRow): DeviceRegistryEventV2 { + return { + protocol: row.protocol, + eventId: row.event_id, + operationId: row.operation_id, + eventType: row.event_type, + subject: row.subject, + genesisHash: row.genesis_hash, + identitySequence: row.identity_sequence, + identityState: row.identity_state, + deviceLedgerSequence: row.device_ledger_sequence, + deviceId: row.device_id, + ...(row.authorization_id === null ? {} : { authorizationId: row.authorization_id }), + deviceState: row.device_state, + ...(row.authorization_expires_at === null + ? {} + : { authorizationExpiresAt: row.authorization_expires_at }), + acceptedAt: row.accepted_at, + actionHash: row.action_hash, + ...(row.revoked_by === null ? {} : { revokedBy: row.revoked_by }), + }; +} diff --git a/workers/projector/src/index.ts b/workers/projector/src/index.ts index 469fdd1..5fee566 100644 --- a/workers/projector/src/index.ts +++ b/workers/projector/src/index.ts @@ -1,10 +1,16 @@ -import type { RegistryEventV1 } from '@nexus/protocol'; +import type { DeviceRegistryEventV2, RegistryEventV1 } from '@nexus/protocol'; +import { validateDeviceRegistryEvent } from './device-event'; +import { applyDeviceRegistryEvent } from './device-projection'; import { validateRegistryEvent } from './event'; import { applyRegistryEvent } from './projection'; +const REGISTRY_EVENTS_QUEUE = 'nexus-registry-events'; +const REGISTRY_DEVICE_EVENTS_QUEUE = 'nexus-registry-device-events'; + export interface TransparencyService { append(event: RegistryEventV1): Promise; + appendDevice(event: DeviceRegistryEventV2): Promise; } export interface Env { @@ -22,11 +28,32 @@ export async function projectAndAppend(rawEvent: unknown, env: Env): Promise { + const event = await validateDeviceRegistryEvent(rawEvent); + await applyDeviceRegistryEvent(env.INDEX_DB, event); + + // As for v1, D1 and the hash-only transparency log independently deduplicate + // a Queue replay by canonical event ID. + await env.TRANSPARENCY_SERVICE.appendDevice(event); +} + +async function processMessage(queue: string, body: unknown, env: Env): Promise { + if (queue === REGISTRY_EVENTS_QUEUE) { + await projectAndAppend(body, env); + return; + } + if (queue === REGISTRY_DEVICE_EVENTS_QUEUE) { + await projectDeviceAndAppend(body, env); + return; + } + throw new Error('UNSUPPORTED_PROJECTOR_QUEUE'); +} + export default { async queue(batch: MessageBatch, env: Env): Promise { for (const message of batch.messages) { try { - await projectAndAppend(message.body, env); + await processMessage(batch.queue, message.body, env); message.ack(); } catch (error: unknown) { const kind = @@ -49,5 +76,12 @@ export default { } satisfies ExportedHandler; export type { RegistryEventV1 } from '@nexus/protocol'; +export type { DeviceRegistryEventV2 } from '@nexus/protocol'; +export { DeviceRegistryEventValidationError, validateDeviceRegistryEvent } from './device-event'; +export { + DeviceProjectionInvariantError, + applyDeviceRegistryEvent, + reconcileDeviceEventsForIdentity, +} from './device-projection'; export { RegistryEventValidationError, validateRegistryEvent } from './event'; export { ProjectionInvariantError, applyRegistryEvent } from './projection'; diff --git a/workers/projector/src/projection.ts b/workers/projector/src/projection.ts index c2539be..dec7432 100644 --- a/workers/projector/src/projection.ts +++ b/workers/projector/src/projection.ts @@ -1,5 +1,10 @@ import { IDENTITY_PROTOCOL_V1, NEXUS_SUITE_V1, type RegistryEventV1 } from '@nexus/protocol'; +import { + assertRegistrationDeviceGenesis, + reconcileDeviceEventsForIdentity, +} from './device-projection'; + interface IdentityGenesisRow { genesis_hash: string; } @@ -26,6 +31,7 @@ async function assertIdentityGenesis(database: D1Database, event: RegistryEventV async function applyRegistration(database: D1Database, event: RegistryEventV1): Promise { await assertIdentityGenesis(database, event); + await assertRegistrationDeviceGenesis(database, event); await database.batch([ database @@ -130,6 +136,10 @@ async function applyRegistration(database: D1Database, event: RegistryEventV1): ) .bind(event.subject), ]); + + // Device events use a separate Queue and may arrive first. Reconcile only + // after the matching v1 anchor has been projected. + await reconcileDeviceEventsForIdentity(database, event.subject); } async function applyRevocation(database: D1Database, event: RegistryEventV1): Promise { diff --git a/workers/projector/test/projector.test.ts b/workers/projector/test/projector.test.ts index 4df0d62..105ab15 100644 --- a/workers/projector/test/projector.test.ts +++ b/workers/projector/test/projector.test.ts @@ -1,10 +1,13 @@ import { env } from 'cloudflare:workers'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { deriveRegistryEventId } from '@nexus/crypto'; +import { deriveDeviceRegistryEventIdV2, deriveRegistryEventId } from '@nexus/crypto'; import { + deviceRegistryEventV2Schema, + deviceRegistryEventWithoutEventIdV2Schema, registryEventV1Schema, registryEventWithoutEventIdV1Schema, + type DeviceRegistryEventV2, type RegistryEventV1, } from '@nexus/protocol'; @@ -13,6 +16,11 @@ import worker, { type Env, type TransparencyService, validateRegistryEvent } fro const GENESIS_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; const REGISTER_ACTION_HASH = 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE'; const REVOKE_ACTION_HASH = 'AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI'; +const DEVICE_ID = `nxd2_${GENESIS_HASH}` as const; +const SECOND_DEVICE_ID = `nxd2_${REGISTER_ACTION_HASH}` as const; +const AUTHORIZATION_ID = `nxa2_${GENESIS_HASH}` as const; +const DEVICE_OPERATION_ID = `nxo2_${GENESIS_HASH}` as const; +const SECOND_DEVICE_OPERATION_ID = `nxo2_${REGISTER_ACTION_HASH}` as const; interface IdentityRow { state: 'active' | 'revoked'; @@ -22,6 +30,17 @@ interface IdentityRow { updated_at: number; } +interface DeviceRow { + authorization_id: string | null; + state: 'active' | 'revoked'; + event_sequence: number; + authorization_expires_at: number | null; + activated_at: number | null; + revoked_at: number | null; + revoked_by: 'root' | 'device' | null; + updated_at: number; +} + async function event( eventType: RegistryEventV1['eventType'], acceptedAt: number, @@ -40,6 +59,40 @@ async function event( return registryEventV1Schema.parse({ ...eventWithoutId, eventId }); } +async function deviceEvent( + eventType: DeviceRegistryEventV2['eventType'], + deviceLedgerSequence: number, + acceptedAt: number, + overrides: Partial> = {}, +): Promise { + const eventWithoutId = deviceRegistryEventWithoutEventIdV2Schema.parse({ + protocol: 'nexus.device-registry-event.v2', + operationId: DEVICE_OPERATION_ID, + eventType, + subject: `nx1_${GENESIS_HASH}`, + genesisHash: GENESIS_HASH, + identitySequence: 0, + identityState: 'active', + deviceLedgerSequence, + deviceId: DEVICE_ID, + ...(eventType === 'activated' + ? { + authorizationId: AUTHORIZATION_ID, + deviceState: 'active', + authorizationExpiresAt: acceptedAt + 86_400, + } + : { + deviceState: 'revoked', + revokedBy: 'root', + }), + acceptedAt, + actionHash: eventType === 'activated' ? REGISTER_ACTION_HASH : REVOKE_ACTION_HASH, + ...overrides, + }); + const { eventId } = await deriveDeviceRegistryEventIdV2(eventWithoutId); + return deviceRegistryEventV2Schema.parse({ ...eventWithoutId, eventId }); +} + function queueMessage(body: unknown, attempts = 1) { const ack = vi.fn(); const retry = vi.fn(); @@ -64,10 +117,23 @@ function batch(...messages: Message[]): MessageBatch { }; } -function workerEnv(transparency: TransparencyService): Env { +function deviceBatch(...messages: Message[]): MessageBatch { + return { + queue: 'nexus-registry-device-events', + messages, + ackAll: vi.fn(), + retryAll: vi.fn(), + }; +} + +function workerEnv(transparency: Partial): Env { return { INDEX_DB: env.INDEX_DB, - TRANSPARENCY_SERVICE: transparency, + TRANSPARENCY_SERVICE: { + append: async () => undefined, + appendDevice: async () => undefined, + ...transparency, + }, }; } @@ -80,6 +146,9 @@ async function count(table: string): Promise { beforeEach(async () => { await env.INDEX_DB.batch([ + env.INDEX_DB.prepare('DELETE FROM device_projection_heads'), + env.INDEX_DB.prepare('DELETE FROM device_states'), + env.INDEX_DB.prepare('DELETE FROM device_registry_events'), env.INDEX_DB.prepare('DELETE FROM registry_events'), env.INDEX_DB.prepare('DELETE FROM pending_registry_events'), env.INDEX_DB.prepare('DELETE FROM identities'), @@ -215,4 +284,166 @@ describe('projector queue consumer', () => { expect(await count('identities')).toBe(0); expect(await count('registry_events')).toBe(0); }); + + it('projects an activated device without changing v1 identity sequence', async () => { + const registered = await event('registered', 1_788_801_000); + await worker.queue( + batch(queueMessage(registered).message), + workerEnv({ append: vi.fn(async () => undefined) }), + ); + const activated = await deviceEvent('activated', 1, 1_788_801_010); + const message = queueMessage(activated); + const appendDevice = vi.fn(async () => undefined); + + await worker.queue(deviceBatch(message.message), workerEnv({ appendDevice })); + + const device = await env.INDEX_DB.prepare( + `SELECT authorization_id, state, event_sequence, authorization_expires_at, + activated_at, revoked_at, revoked_by, updated_at + FROM device_states WHERE subject = ? AND device_id = ?`, + ) + .bind(activated.subject, activated.deviceId) + .first(); + const identity = await env.INDEX_DB.prepare( + 'SELECT state, sequence, registered_at, revoked_at, updated_at FROM identities WHERE subject = ?', + ) + .bind(activated.subject) + .first(); + + expect(message.ack).toHaveBeenCalledOnce(); + expect(message.retry).not.toHaveBeenCalled(); + expect(appendDevice).toHaveBeenCalledWith(activated); + expect(device).toEqual({ + authorization_id: AUTHORIZATION_ID, + state: 'active', + event_sequence: 1, + authorization_expires_at: activated.authorizationExpiresAt, + activated_at: activated.acceptedAt, + revoked_at: null, + revoked_by: null, + updated_at: activated.acceptedAt, + }); + expect(identity?.state).toBe('active'); + expect(identity?.sequence).toBe(0); + }); + + it('deduplicates v2 Queue delivery by event ID and ledger sequence', async () => { + const registered = await event('registered', 1_788_801_100); + await worker.queue(batch(queueMessage(registered).message), workerEnv({})); + const activated = await deviceEvent('activated', 1, 1_788_801_110); + const first = queueMessage(activated); + const duplicate = queueMessage(activated); + const appendDevice = vi.fn(async () => undefined); + + await worker.queue(deviceBatch(first.message, duplicate.message), workerEnv({ appendDevice })); + + expect(first.ack).toHaveBeenCalledOnce(); + expect(duplicate.ack).toHaveBeenCalledOnce(); + expect(await count('device_registry_events')).toBe(1); + expect(await count('device_states')).toBe(1); + // A duplicate delivery may recover a prior transparency failure. + expect(appendDevice).toHaveBeenCalledTimes(2); + }); + + it('stores an early device event and reconciles it after v1 registration', async () => { + const activated = await deviceEvent('activated', 1, 1_788_801_210); + const early = queueMessage(activated); + + await worker.queue(deviceBatch(early.message), workerEnv({})); + + expect(early.ack).toHaveBeenCalledOnce(); + expect(await count('device_registry_events')).toBe(1); + expect(await count('device_states')).toBe(0); + + const registered = await event('registered', 1_788_801_200); + await worker.queue(batch(queueMessage(registered).message), workerEnv({})); + + expect(await count('device_states')).toBe(1); + const device = await env.INDEX_DB.prepare( + `SELECT authorization_id, state, event_sequence, authorization_expires_at, + activated_at, revoked_at, revoked_by, updated_at + FROM device_states WHERE subject = ? AND device_id = ?`, + ) + .bind(activated.subject, activated.deviceId) + .first(); + expect(device?.state).toBe('active'); + expect(device?.event_sequence).toBe(1); + }); + + it('keeps root-created revoked tombstones terminal under stale activation', async () => { + const registered = await event('registered', 1_788_801_300); + await worker.queue(batch(queueMessage(registered).message), workerEnv({})); + const revoked = await deviceEvent('revoked', 2, 1_788_801_320); + const activated = await deviceEvent('activated', 1, 1_788_801_310, { + operationId: SECOND_DEVICE_OPERATION_ID, + }); + + await worker.queue(deviceBatch(queueMessage(revoked).message), workerEnv({})); + await worker.queue(deviceBatch(queueMessage(activated).message), workerEnv({})); + + const device = await env.INDEX_DB.prepare( + `SELECT authorization_id, state, event_sequence, authorization_expires_at, + activated_at, revoked_at, revoked_by, updated_at + FROM device_states WHERE subject = ? AND device_id = ?`, + ) + .bind(revoked.subject, revoked.deviceId) + .first(); + expect(device).toEqual({ + authorization_id: null, + state: 'revoked', + event_sequence: 2, + authorization_expires_at: null, + activated_at: null, + revoked_at: revoked.acceptedAt, + revoked_by: 'root', + updated_at: revoked.acceptedAt, + }); + expect(await count('device_registry_events')).toBe(2); + }); + + it('does not persist private keys, device labels, RP scopes, or installation IDs', async () => { + const registered = await event('registered', 1_788_801_400); + await worker.queue(batch(queueMessage(registered).message), workerEnv({})); + const activated = await deviceEvent('activated', 1, 1_788_801_410); + const invalid = { + ...activated, + devicePrivateKey: 'must-not-be-stored', + label: 'Alice laptop', + rpScopes: ['https://rp.example'], + installationId: 'browser-profile-1', + }; + const message = queueMessage(invalid); + const appendDevice = vi.fn(async () => undefined); + + await worker.queue(deviceBatch(message.message), workerEnv({ appendDevice })); + + expect(message.retry).toHaveBeenCalledOnce(); + expect(message.ack).not.toHaveBeenCalled(); + expect(appendDevice).not.toHaveBeenCalled(); + expect(await count('device_registry_events')).toBe(0); + expect(await count('device_states')).toBe(0); + }); + + it('projects independent device keys for one stable v1 subject', async () => { + const registered = await event('registered', 1_788_801_500); + await worker.queue(batch(queueMessage(registered).message), workerEnv({})); + const first = await deviceEvent('activated', 1, 1_788_801_510); + const second = await deviceEvent('activated', 2, 1_788_801_520, { + operationId: SECOND_DEVICE_OPERATION_ID, + deviceId: SECOND_DEVICE_ID, + }); + + await worker.queue( + deviceBatch(queueMessage(first).message, queueMessage(second).message), + workerEnv({}), + ); + + expect(await count('device_states')).toBe(2); + const head = await env.INDEX_DB.prepare( + `SELECT max_device_ledger_sequence FROM device_projection_heads WHERE subject = ?`, + ) + .bind(first.subject) + .first<{ max_device_ledger_sequence: number }>(); + expect(head?.max_device_ledger_sequence).toBe(2); + }); }); diff --git a/workers/projector/wrangler.jsonc b/workers/projector/wrangler.jsonc index d3f9181..dfb494d 100644 --- a/workers/projector/wrangler.jsonc +++ b/workers/projector/wrangler.jsonc @@ -21,6 +21,13 @@ "max_retries": 5, "dead_letter_queue": "nexus-registry-events-dlq", }, + { + "queue": "nexus-registry-device-events", + "max_batch_size": 100, + "max_batch_timeout": 5, + "max_retries": 5, + "dead_letter_queue": "nexus-registry-device-events-dlq", + }, ], }, "services": [ diff --git a/workers/registry/package.json b/workers/registry/package.json index a382b3b..e1b8393 100644 --- a/workers/registry/package.json +++ b/workers/registry/package.json @@ -13,12 +13,12 @@ }, "dependencies": { "@nexus/crypto": "workspace:*", - "@nexus/protocol": "workspace:*", - "@nexus/verifier": "workspace:*" + "@nexus/protocol": "workspace:*" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.21.0", "@cloudflare/workers-types": "^5.20260811.1", + "@nexus/verifier": "workspace:*", "typescript": "^5.9.2", "vitest": "^4.1.0", "wrangler": "^4.120.1" diff --git a/workers/registry/src/errors.ts b/workers/registry/src/errors.ts index a0f29cb..10062f2 100644 --- a/workers/registry/src/errors.ts +++ b/workers/registry/src/errors.ts @@ -9,6 +9,9 @@ const MESSAGES: Readonly> = { INVALID_REVOCATION_SECRET: 'The revocation secret is invalid.', IDENTITY_NOT_FOUND: 'The identity was not found.', IDENTITY_REVOKED: 'The identity is revoked.', + DEVICE_NOT_FOUND: 'The device authorization was not found.', + DEVICE_REVOKED: 'The device is revoked.', + DEVICE_AUTHORIZATION_CONFLICT: 'The device is bound to a different authorization.', SEQUENCE_CONFLICT: 'The lifecycle sequence does not match.', SUBJECT_GENESIS_CONFLICT: 'The subject is bound to different genesis bytes.', INTERNAL_ERROR: 'The registry operation failed.', diff --git a/workers/registry/src/events.ts b/workers/registry/src/events.ts index 37fa548..87d76fa 100644 --- a/workers/registry/src/events.ts +++ b/workers/registry/src/events.ts @@ -1,7 +1,16 @@ -import { canonicalize, canonicalizeToBytes, encodeBase64Url } from '@nexus/protocol'; +import { + canonicalize, + canonicalizeToBytes, + createDeviceRegistryEventHashPreimageV2, + encodeBase64Url, + formatDeviceRegistryEventIdV2, +} from '@nexus/protocol'; import type { Base64Url32, + DeviceRegistryEventV2, + DeviceRegistryEventWithoutEventIdV2, RegistryEventV1, + DeviceOperationPayloadV2, RevokeBySecretV1, RevokeBySignaturePayloadV1, } from '@nexus/protocol'; @@ -19,6 +28,12 @@ export interface MaterializedRegistryEvent { payloadJcs: string; } +export interface MaterializedDeviceRegistryEvent { + event: DeviceRegistryEventV2; + eventHash: Uint8Array; + payloadJcs: string; +} + export async function materializeRegistryEvent( eventWithoutId: RegistryEventWithoutId, ): Promise { @@ -27,6 +42,26 @@ export async function materializeRegistryEvent( return { event, eventHash, payloadJcs: canonicalize(event) }; } +export async function materializeDeviceRegistryEvent( + eventWithoutId: DeviceRegistryEventWithoutEventIdV2, +): Promise { + const eventHash = await getDefaultCryptoProvider().sha256( + createDeviceRegistryEventHashPreimageV2(eventWithoutId), + ); + const event: DeviceRegistryEventV2 = { + ...eventWithoutId, + eventId: formatDeviceRegistryEventIdV2(eventHash), + }; + return { event, eventHash, payloadJcs: canonicalize(event) }; +} + +export async function signedDeviceActionHash( + payload: DeviceOperationPayloadV2, +): Promise { + const bytes = createProtocolSignaturePreimage(payload); + return encodeBase64Url(await getDefaultCryptoProvider().sha256(bytes)) as Base64Url32; +} + /** * NEXUS_SPEC v1 defines actionHash's field but not a new action hash domain. * The local v1 convention is deliberately limited to already-defined bytes: diff --git a/workers/registry/src/identity-state-do.ts b/workers/registry/src/identity-state-do.ts index e6c4ed4..5c3adbc 100644 --- a/workers/registry/src/identity-state-do.ts +++ b/workers/registry/src/identity-state-do.ts @@ -1,6 +1,38 @@ import { DurableObject } from 'cloudflare:workers'; -import { encodeBase64Url, identityGenesisV1Schema, registryEventV1Schema } from '@nexus/protocol'; -import type { Base64Url32, NexusSubject, RegistryEventV1, RevokeBySecretV1 } from '@nexus/protocol'; +import { + canonicalize, + decodeBase64UrlExact, + deviceActivationRequestV2Schema, + deviceRegistryEventV2Schema, + deviceRootRevokeRequestV2Schema, + deviceSelfRevokeRequestV2Schema, + encodeBase64Url, + identityGenesisV1Schema, + registryEventV1Schema, +} from '@nexus/protocol'; +import type { + Base64Url32, + DeviceActivationRequestV2, + DeviceAuthorizationV2, + DeviceRegistryEventV2, + DeviceRootRevokeRequestV2, + DeviceSelfRevokeRequestV2, + DeviceOperationPayloadV2, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, + NexusDeviceOperationIdV2, + NexusSubject, + RegistryEventV1, + RevokeBySecretV1, +} from '@nexus/protocol'; +import { + constantTimeEqual, + deriveDeviceAuthorizationIdV2, + deriveDeviceIdV2, + deriveDeviceOperationIdV2, + getDefaultCryptoProvider, + verifyProtocolPayload, +} from '@nexus/crypto'; import { NexusVerificationError, verifyRevocationSecret, @@ -10,12 +42,16 @@ import { import { fail, succeed } from './errors'; import { materializeRegistryEvent, + materializeDeviceRegistryEvent, secretRevocationActionHash, + signedDeviceActionHash, signatureRevocationActionHash, } from './events'; import { migrateSchema } from './schema'; import type { AuthoritativeStatus, + AuthoritativeDeviceStatusV2, + DeviceRegistryMutationV2, PreparedRegistration, RegistryEnv, RegistryErrorCode, @@ -23,6 +59,7 @@ import type { RegistryResult, RevokeBySignatureCommand, StoredIdentityRow, + StoredDeviceRow, StoredOutboxRow, } from './types'; import { @@ -35,12 +72,24 @@ const BASE_RETRY_DELAY_MS = 2_000; const MAX_RETRY_DELAY_MS = 5 * 60_000; const MAX_CLOCK_SKEW_SECONDS = 60; const MAX_REVOCATION_AGE_SECONDS = 300; +const MAX_DEVICE_REQUEST_LIFETIME_SECONDS = 300; +const MAX_DEVICE_ACTIVATION_WINDOW_SECONDS = 30 * 24 * 60 * 60; +const MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS = 366 * 24 * 60 * 60; interface EventPayloadRow { [key: string]: SqlStorageValue; payload_jcs: string; } +interface DeviceLedgerRow { + [key: string]: SqlStorageValue; + sequence: number; +} + +interface PendingOutboxRow extends StoredOutboxRow { + kind: 'identity' | 'device'; +} + function nowSeconds(): number { return Math.floor(Date.now() / 1_000); } @@ -179,6 +228,7 @@ export class IdentityState extends DurableObject { if (row === null) { return fail('IDENTITY_NOT_FOUND'); } + migrateSchema(this.ctx); this.#kickOutbox(); return succeed(this.#statusFromRow(row)); } catch { @@ -186,6 +236,300 @@ export class IdentityState extends DurableObject { } } + async activateDevice( + input: DeviceActivationRequestV2, + ): Promise> { + return await this.ctx.blockConcurrencyWhile(async () => await this.#activateDevice(input)); + } + + deviceStatus(input: { + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + }): RegistryResult { + try { + const initial = this.#readIdentity(); + if (initial === null) { + return fail('IDENTITY_NOT_FOUND'); + } + migrateSchema(this.ctx); + const row = this.#readDevice(input.deviceId); + this.#kickOutbox(); + return succeed( + this.#deviceStatusFromRow(initial, row, input.deviceId, input.authorizationId), + ); + } catch { + return fail('INTERNAL_ERROR'); + } + } + + async revokeDeviceSelf( + input: DeviceSelfRevokeRequestV2, + ): Promise> { + return await this.ctx.blockConcurrencyWhile(async () => await this.#revokeDeviceSelf(input)); + } + + async revokeDeviceRoot( + input: DeviceRootRevokeRequestV2, + ): Promise> { + return await this.ctx.blockConcurrencyWhile(async () => await this.#revokeDeviceRoot(input)); + } + + async #activateDevice( + input: DeviceActivationRequestV2, + ): Promise> { + const parsed = deviceActivationRequestV2Schema.safeParse(input); + if (!parsed.success) return fail('BAD_REQUEST'); + + try { + const identity = this.#readIdentity(); + if (identity === null) return fail('IDENTITY_NOT_FOUND'); + migrateSchema(this.ctx); + + const verified = await this.#verifyAuthorization(identity, parsed.data.authorization); + if (!verified.ok) return verified; + const { authorizationId, deviceId } = verified.value; + const payload = parsed.data.payload; + if ( + payload.subject !== identity.subject || + payload.deviceId !== deviceId || + payload.authorizationId !== authorizationId + ) { + return fail('BAD_REQUEST'); + } + if ( + !(await this.#verifyDeviceSignature( + parsed.data.authorization, + payload, + parsed.data.deviceSignature, + )) + ) { + return fail('INVALID_SIGNATURE'); + } + if (identity.state === 'revoked') return fail('IDENTITY_REVOKED'); + const existing = this.#readDevice(deviceId); + if (existing?.state === 'revoked') return fail('DEVICE_REVOKED'); + if (existing !== null && existing.authorization_id !== authorizationId) { + return fail('DEVICE_AUTHORIZATION_CONFLICT'); + } + const operationId = await deriveDeviceOperationIdV2(payload); + const replay = this.#readDeviceOperation(operationId); + if (replay !== null) { + const row = this.#readDevice(deviceId); + if (row === null) throw new Error('Device operation target is missing.'); + this.#kickOutbox(); + return succeed(this.#deviceMutationFromEvent(identity, row, replay.event_id)); + } + const now = nowSeconds(); + const authorization = parsed.data.authorization.payload; + if ( + authorization.validFrom > authorization.activationDeadline || + authorization.activationDeadline > authorization.expiresAt || + authorization.activationDeadline - authorization.validFrom > + MAX_DEVICE_ACTIVATION_WINDOW_SECONDS || + authorization.expiresAt - authorization.validFrom > + MAX_DEVICE_AUTHORIZATION_LIFETIME_SECONDS || + now + MAX_CLOCK_SKEW_SECONDS < authorization.validFrom || + now - MAX_CLOCK_SKEW_SECONDS > authorization.activationDeadline || + now >= authorization.expiresAt || + payload.iat > payload.exp || + payload.exp - payload.iat > MAX_DEVICE_REQUEST_LIFETIME_SECONDS || + now + MAX_CLOCK_SKEW_SECONDS < payload.iat || + now - MAX_CLOCK_SKEW_SECONDS > payload.exp + ) { + return fail('BAD_REQUEST'); + } + if (existing !== null) { + if (existing.activation_event_id === null) + throw new Error('Active device lacks activation event.'); + this.#kickOutbox(); + return succeed( + this.#deviceMutationFromEvent(identity, existing, existing.activation_event_id), + ); + } + + const acceptedAt = now; + const nextSequence = this.#readDeviceLedgerSequence() + 1; + const actionHash = await signedDeviceActionHash(payload); + const materialized = await materializeDeviceRegistryEvent({ + protocol: 'nexus.device-registry-event.v2', + operationId, + eventType: 'activated', + subject: identity.subject, + genesisHash: encodeBase64Url(new Uint8Array(identity.genesis_hash)) as Base64Url32, + identitySequence: identity.sequence, + identityState: 'active', + deviceLedgerSequence: nextSequence, + deviceId, + authorizationId, + deviceState: 'active', + authorizationExpiresAt: authorization.expiresAt, + acceptedAt, + actionHash, + }); + + const result = this.ctx.storage.transactionSync>( + () => { + const currentIdentity = this.#readIdentity(); + if (currentIdentity === null) return fail('IDENTITY_NOT_FOUND'); + if (currentIdentity.state === 'revoked') return fail('IDENTITY_REVOKED'); + const current = this.#readDevice(deviceId); + if (current?.state === 'revoked') return fail('DEVICE_REVOKED'); + if (current !== null) { + if (current.authorization_id !== authorizationId) + return fail('DEVICE_AUTHORIZATION_CONFLICT'); + if (current.activation_event_id === null) + throw new Error('Active device lacks activation event.'); + return succeed( + this.#deviceMutationFromEvent(currentIdentity, current, current.activation_event_id), + ); + } + if (this.#readDeviceLedgerSequence() + 1 !== nextSequence) + return fail('SEQUENCE_CONFLICT'); + + this.ctx.storage.sql.exec( + `INSERT INTO device_state ( + device_id, authorization_id, authorization_jcs, signing_public_key, state, + authorization_expires_at, activated_at, revoked_at, revoked_by, + activation_event_id, revocation_event_id + ) VALUES (?, ?, ?, ?, 'active', ?, ?, NULL, NULL, ?, NULL)`, + deviceId, + authorizationId, + canonicalize(parsed.data.authorization), + copyBuffer(decodeBase64UrlExact(authorization.signingKey.publicKey, 32)), + authorization.expiresAt, + acceptedAt, + materialized.event.eventId, + ); + this.#writeDeviceLedgerSequence(nextSequence); + this.#insertDeviceOperationAndOutbox( + operationId, + 'activate', + deviceId, + authorizationId, + materialized.event.eventId, + materialized.eventHash, + acceptedAt, + materialized.payloadJcs, + ); + const inserted = this.#readDevice(deviceId); + if (inserted === null) throw new Error('Activated device was not persisted.'); + return succeed( + this.#deviceMutationFromEvent(currentIdentity, inserted, materialized.event.eventId), + ); + }, + ); + this.#kickOutbox(); + return result; + } catch { + return fail('INTERNAL_ERROR'); + } + } + + async #revokeDeviceSelf( + input: DeviceSelfRevokeRequestV2, + ): Promise> { + const parsed = deviceSelfRevokeRequestV2Schema.safeParse(input); + if (!parsed.success) return fail('BAD_REQUEST'); + try { + const identity = this.#readIdentity(); + if (identity === null) return fail('IDENTITY_NOT_FOUND'); + migrateSchema(this.ctx); + const verified = await this.#verifyAuthorization(identity, parsed.data.authorization); + if (!verified.ok) return verified; + const payload = parsed.data.payload; + if ( + payload.subject !== identity.subject || + payload.genesisHash !== encodeBase64Url(new Uint8Array(identity.genesis_hash)) || + payload.deviceId !== verified.value.deviceId || + payload.authorizationId !== verified.value.authorizationId + ) + return fail('BAD_REQUEST'); + if ( + !(await this.#verifyDeviceSignature( + parsed.data.authorization, + payload, + parsed.data.deviceSignature, + )) + ) { + return fail('INVALID_SIGNATURE'); + } + if (identity.state === 'revoked') return fail('IDENTITY_REVOKED'); + const operationId = await deriveDeviceOperationIdV2(payload); + const replay = this.#readDeviceOperation(operationId); + if (replay !== null) { + const row = this.#readDevice(payload.deviceId); + if (row === null) throw new Error('Device operation target is missing.'); + this.#kickOutbox(); + return succeed(this.#deviceMutationFromEvent(identity, row, replay.event_id)); + } + const now = nowSeconds(); + if ( + payload.issuedAt > now + MAX_CLOCK_SKEW_SECONDS || + now - payload.issuedAt > MAX_REVOCATION_AGE_SECONDS + ) + return fail('BAD_REQUEST'); + return await this.#commitDeviceRevocation({ + identity, + deviceId: verified.value.deviceId, + authorizationId: verified.value.authorizationId, + authorization: parsed.data.authorization, + payload, + revokedBy: 'device', + }); + } catch { + return fail('INTERNAL_ERROR'); + } + } + + async #revokeDeviceRoot( + input: DeviceRootRevokeRequestV2, + ): Promise> { + const parsed = deviceRootRevokeRequestV2Schema.safeParse(input); + if (!parsed.success) return fail('BAD_REQUEST'); + try { + const identity = this.#readIdentity(); + if (identity === null) return fail('IDENTITY_NOT_FOUND'); + migrateSchema(this.ctx); + const payload = parsed.data.payload; + if ( + payload.subject !== identity.subject || + payload.genesisHash !== encodeBase64Url(new Uint8Array(identity.genesis_hash)) + ) + return fail('BAD_REQUEST'); + const rootKey = await getDefaultCryptoProvider().importEd25519PublicKey( + new Uint8Array(identity.signing_public_key), + ); + if (!(await verifyProtocolPayload(payload, parsed.data.rootSignature, rootKey))) { + return fail('INVALID_SIGNATURE'); + } + if (identity.state === 'revoked') return fail('IDENTITY_REVOKED'); + const operationId = await deriveDeviceOperationIdV2(payload); + const replay = this.#readDeviceOperation(operationId); + if (replay !== null) { + const row = this.#readDevice(payload.deviceId); + if (row === null) throw new Error('Device operation target is missing.'); + this.#kickOutbox(); + return succeed(this.#deviceMutationFromEvent(identity, row, replay.event_id)); + } + const now = nowSeconds(); + if ( + payload.issuedAt > now + MAX_CLOCK_SKEW_SECONDS || + now - payload.issuedAt > MAX_REVOCATION_AGE_SECONDS + ) + return fail('BAD_REQUEST'); + return await this.#commitDeviceRevocation({ + identity, + deviceId: payload.deviceId, + authorizationId: null, + authorization: null, + payload, + revokedBy: 'root', + }); + } catch { + return fail('INTERNAL_ERROR'); + } + } + async revokeBySignature( input: RevokeBySignatureCommand, ): Promise> { @@ -283,6 +627,7 @@ export class IdentityState extends DurableObject { return; } try { + migrateSchema(this.ctx); await this.#requestFlush(); } catch { // Cloudflare's built-in alarm retries stop after six attempts. Explicitly @@ -349,6 +694,226 @@ export class IdentityState extends DurableObject { return result; } + async #verifyAuthorization( + identity: StoredIdentityRow, + authorization: DeviceAuthorizationV2, + ): Promise< + RegistryResult<{ + authorizationId: NexusDeviceAuthorizationIdV2; + deviceId: NexusDeviceIdV2; + }> + > { + const payload = authorization.payload; + const genesisHash = encodeBase64Url(new Uint8Array(identity.genesis_hash)); + const signingPublicKey = decodeBase64UrlExact(payload.signingKey.publicKey, 32); + if ( + payload.subject !== identity.subject || + payload.genesisHash !== genesisHash || + constantTimeEqual(signingPublicKey, new Uint8Array(identity.signing_public_key)) + ) { + return fail('BAD_REQUEST'); + } + const deviceId = await deriveDeviceIdV2({ + subject: identity.subject, + signingKey: payload.signingKey, + }); + const authorizationId = await deriveDeviceAuthorizationIdV2(payload); + if (payload.deviceId !== deviceId) return fail('BAD_REQUEST'); + const rootKey = await getDefaultCryptoProvider().importEd25519PublicKey( + new Uint8Array(identity.signing_public_key), + ); + if (!(await verifyProtocolPayload(payload, authorization.rootSignature, rootKey))) { + return fail('INVALID_SIGNATURE'); + } + return succeed({ authorizationId, deviceId }); + } + + async #verifyDeviceSignature( + authorization: DeviceAuthorizationV2, + payload: { readonly protocol: string }, + signature: string, + ): Promise { + const publicKey = await getDefaultCryptoProvider().importEd25519PublicKey( + decodeBase64UrlExact(authorization.payload.signingKey.publicKey, 32), + ); + return await verifyProtocolPayload(payload, signature, publicKey); + } + + async #commitDeviceRevocation(input: { + identity: StoredIdentityRow; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2 | null; + authorization: DeviceAuthorizationV2 | null; + payload: DeviceOperationPayloadV2; + revokedBy: 'root' | 'device'; + }): Promise> { + const existing = this.#readDevice(input.deviceId); + if (existing?.state === 'revoked') { + if (existing.revocation_event_id === null) throw new Error('Revoked device lacks event.'); + this.#kickOutbox(); + return succeed( + this.#deviceMutationFromEvent(input.identity, existing, existing.revocation_event_id), + ); + } + if ( + input.revokedBy === 'device' && + existing !== null && + existing.authorization_id !== input.authorizationId + ) { + return fail('DEVICE_AUTHORIZATION_CONFLICT'); + } + + const operationId = await deriveDeviceOperationIdV2(input.payload); + const previous = this.#readDeviceOperation(operationId); + if (previous !== null) { + const row = this.#readDevice(input.deviceId); + if (row === null) throw new Error('Device operation target is missing.'); + return succeed(this.#deviceMutationFromEvent(input.identity, row, previous.event_id)); + } + + const acceptedAt = nowSeconds(); + const nextSequence = this.#readDeviceLedgerSequence() + 1; + const effectiveAuthorizationId = + (existing?.authorization_id as NexusDeviceAuthorizationIdV2 | null | undefined) ?? + input.authorizationId; + const expiresAt = + existing?.authorization_expires_at ?? input.authorization?.payload.expiresAt ?? null; + const actionHash = await signedDeviceActionHash(input.payload); + const materialized = await materializeDeviceRegistryEvent({ + protocol: 'nexus.device-registry-event.v2', + operationId, + eventType: 'revoked', + subject: input.identity.subject as NexusSubject, + genesisHash: encodeBase64Url(new Uint8Array(input.identity.genesis_hash)) as Base64Url32, + identitySequence: input.identity.sequence, + identityState: 'active', + deviceLedgerSequence: nextSequence, + deviceId: input.deviceId, + ...(effectiveAuthorizationId === null ? {} : { authorizationId: effectiveAuthorizationId }), + deviceState: 'revoked', + ...(expiresAt === null ? {} : { authorizationExpiresAt: expiresAt }), + acceptedAt, + actionHash, + revokedBy: input.revokedBy, + }); + + const result = this.ctx.storage.transactionSync>( + () => { + const identity = this.#readIdentity(); + if (identity === null) return fail('IDENTITY_NOT_FOUND'); + if (identity.state === 'revoked') return fail('IDENTITY_REVOKED'); + const current = this.#readDevice(input.deviceId); + if (current?.state === 'revoked') { + if (current.revocation_event_id === null) throw new Error('Revoked device lacks event.'); + return succeed( + this.#deviceMutationFromEvent(identity, current, current.revocation_event_id), + ); + } + if ( + input.revokedBy === 'device' && + current !== null && + current.authorization_id !== input.authorizationId + ) + return fail('DEVICE_AUTHORIZATION_CONFLICT'); + if (this.#readDeviceLedgerSequence() + 1 !== nextSequence) return fail('SEQUENCE_CONFLICT'); + + if (current === null) { + this.ctx.storage.sql.exec( + `INSERT INTO device_state ( + device_id, authorization_id, authorization_jcs, signing_public_key, state, + authorization_expires_at, activated_at, revoked_at, revoked_by, + activation_event_id, revocation_event_id + ) VALUES (?, ?, ?, ?, 'revoked', ?, NULL, ?, ?, NULL, ?)`, + input.deviceId, + input.authorizationId, + input.authorization === null ? null : canonicalize(input.authorization), + input.authorization === null + ? null + : copyBuffer( + decodeBase64UrlExact(input.authorization.payload.signingKey.publicKey, 32), + ), + expiresAt, + acceptedAt, + input.revokedBy, + materialized.event.eventId, + ); + } else { + this.ctx.storage.sql.exec( + `UPDATE device_state + SET state = 'revoked', revoked_at = ?, revoked_by = ?, revocation_event_id = ? + WHERE device_id = ? AND state = 'active'`, + acceptedAt, + input.revokedBy, + materialized.event.eventId, + input.deviceId, + ); + } + this.#writeDeviceLedgerSequence(nextSequence); + this.#insertDeviceOperationAndOutbox( + operationId, + input.revokedBy === 'root' ? 'revoke-root' : 'revoke-self', + input.deviceId, + effectiveAuthorizationId, + materialized.event.eventId, + materialized.eventHash, + acceptedAt, + materialized.payloadJcs, + ); + const revoked = this.#readDevice(input.deviceId); + if (revoked === null) throw new Error('Revoked device was not persisted.'); + return succeed( + this.#deviceMutationFromEvent(identity, revoked, materialized.event.eventId), + ); + }, + ); + this.#kickOutbox(); + return result; + } + + #readDevice(deviceId: string): StoredDeviceRow | null { + if (!this.#hasDeviceSchema()) return null; + return ( + this.ctx.storage.sql + .exec('SELECT * FROM device_state WHERE device_id = ?', deviceId) + .toArray()[0] ?? null + ); + } + + #readDeviceOperation(operationId: string): { event_id: string } | null { + if (!this.#hasDeviceSchema()) return null; + return ( + this.ctx.storage.sql + .exec<{ event_id: string }>( + 'SELECT event_id FROM device_operations WHERE operation_id = ?', + operationId, + ) + .toArray()[0] ?? null + ); + } + + #readDeviceLedgerSequence(): number { + return this.ctx.storage.sql + .exec('SELECT sequence FROM device_ledger WHERE singleton = 1') + .one().sequence; + } + + #writeDeviceLedgerSequence(sequence: number): void { + this.ctx.storage.sql.exec( + 'UPDATE device_ledger SET sequence = ? WHERE singleton = 1', + sequence, + ); + } + + #hasDeviceSchema(): boolean { + return ( + this.ctx.storage.sql + .exec<{ present: number }>( + `SELECT COUNT(*) AS present FROM sqlite_master WHERE type = 'table' AND name = 'device_state'`, + ) + .one().present === 1 + ); + } + #readIdentity(): StoredIdentityRow | null { if (!this.#hasLifecycleSchema()) { return null; @@ -404,6 +969,77 @@ export class IdentityState extends DurableObject { }; } + #deviceStatusFromRow( + identity: StoredIdentityRow, + row: StoredDeviceRow | null, + deviceId: NexusDeviceIdV2, + authorizationId: NexusDeviceAuthorizationIdV2, + ): AuthoritativeDeviceStatusV2 { + const authorizationMatches = row?.authorization_id === authorizationId; + const state = + identity.state === 'revoked' + ? 'revoked' + : row?.state === 'revoked' + ? 'revoked' + : row === null || !authorizationMatches + ? 'unknown' + : row.authorization_expires_at !== null && nowSeconds() >= row.authorization_expires_at + ? 'expired' + : 'active'; + return { + subject: identity.subject as NexusSubject, + genesisHash: encodeBase64Url(new Uint8Array(identity.genesis_hash)) as Base64Url32, + identityState: identity.state, + identitySequence: identity.sequence, + deviceLedgerSequence: this.#readDeviceLedgerSequence(), + deviceId, + authorizationId, + deviceState: state, + activatedAt: authorizationMatches ? (row?.activated_at ?? null) : null, + revokedAt: + identity.state === 'revoked' + ? identity.revoked_at + : row?.state === 'revoked' + ? (row?.revoked_at ?? null) + : null, + authorizationExpiresAt: authorizationMatches ? (row?.authorization_expires_at ?? null) : null, + }; + } + + #deviceEvent(eventId: string): DeviceRegistryEventV2 { + const row = this.ctx.storage.sql + .exec('SELECT payload_jcs FROM device_outbox WHERE event_id = ?', eventId) + .toArray()[0]; + if (row === undefined) throw new Error('Device event is missing.'); + return deviceRegistryEventV2Schema.parse(JSON.parse(row.payload_jcs)); + } + + #deviceMutationFromEvent( + identity: StoredIdentityRow, + row: StoredDeviceRow, + eventId: string, + ): DeviceRegistryMutationV2 { + const event = this.#deviceEvent(eventId); + return { + subject: identity.subject as NexusSubject, + genesisHash: encodeBase64Url(new Uint8Array(identity.genesis_hash)) as Base64Url32, + identityState: event.identityState, + identitySequence: event.identitySequence, + deviceLedgerSequence: event.deviceLedgerSequence, + deviceId: event.deviceId, + authorizationId: event.authorizationId ?? null, + deviceState: event.deviceState, + activatedAt: event.eventType === 'activated' ? event.acceptedAt : row.activated_at, + revokedAt: event.eventType === 'revoked' ? event.acceptedAt : null, + authorizationExpiresAt: event.authorizationExpiresAt ?? null, + operationId: event.operationId, + eventId: event.eventId, + eventType: event.eventType, + acceptedAt: event.acceptedAt, + event, + }; + } + #mutationFromRow(row: StoredIdentityRow): RegistryMutation { const event = this.#eventForSequence(row.sequence); return { ...this.#statusFromRow(row), event }; @@ -435,6 +1071,37 @@ export class IdentityState extends DurableObject { ); } + #insertDeviceOperationAndOutbox( + operationId: NexusDeviceOperationIdV2, + operationType: 'activate' | 'revoke-root' | 'revoke-self', + deviceId: NexusDeviceIdV2, + authorizationId: NexusDeviceAuthorizationIdV2 | null, + eventId: string, + eventHash: Uint8Array, + acceptedAt: number, + payloadJcs: string, + ): void { + this.ctx.storage.sql.exec( + `INSERT INTO device_operations ( + operation_id, event_id, operation_type, device_id, authorization_id, event_hash, accepted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + operationId, + eventId, + operationType, + deviceId, + authorizationId, + copyBuffer(eventHash), + acceptedAt, + ); + this.ctx.storage.sql.exec( + `INSERT INTO device_outbox (event_id, payload_jcs, created_at, published_at, attempt_count) + VALUES (?, ?, ?, NULL, 0)`, + eventId, + payloadJcs, + acceptedAt, + ); + } + #recordGenesisConflict(): void { // Privacy-safe critical signal: no subject, key, request, or network data. try { @@ -468,10 +1135,17 @@ export class IdentityState extends DurableObject { async #flushPendingOutbox(): Promise { for (;;) { const pending = this.ctx.storage.sql - .exec( - `SELECT event_id, payload_jcs, created_at, published_at, attempt_count - FROM outbox - WHERE published_at IS NULL + .exec( + `SELECT kind, event_id, payload_jcs, created_at, published_at, attempt_count + FROM ( + SELECT 'identity' AS kind, event_id, payload_jcs, created_at, published_at, attempt_count + FROM outbox + WHERE published_at IS NULL + UNION ALL + SELECT 'device' AS kind, event_id, payload_jcs, created_at, published_at, attempt_count + FROM device_outbox + WHERE published_at IS NULL + ) ORDER BY created_at, event_id LIMIT 1`, ) @@ -483,7 +1157,9 @@ export class IdentityState extends DurableObject { // yielding. A new pending row always gets either this loop or an alarm. const remaining = this.ctx.storage.sql .exec<{ pending: number }>( - 'SELECT COUNT(*) AS pending FROM outbox WHERE published_at IS NULL', + `SELECT + (SELECT COUNT(*) FROM outbox WHERE published_at IS NULL) + + (SELECT COUNT(*) FROM device_outbox WHERE published_at IS NULL) AS pending`, ) .one().pending; if (remaining === 0) { @@ -494,21 +1170,29 @@ export class IdentityState extends DurableObject { } const attempt = this.ctx.storage.transactionSync(() => { + const table = pending.kind === 'identity' ? 'outbox' : 'device_outbox'; this.ctx.storage.sql.exec( - 'UPDATE outbox SET attempt_count = attempt_count + 1 WHERE event_id = ?', + `UPDATE ${table} SET attempt_count = attempt_count + 1 WHERE event_id = ?`, pending.event_id, ); return this.ctx.storage.sql .exec<{ attempt_count: number }>( - 'SELECT attempt_count FROM outbox WHERE event_id = ?', + `SELECT attempt_count FROM ${table} WHERE event_id = ?`, pending.event_id, ) .one().attempt_count; }); - const event = registryEventV1Schema.parse(JSON.parse(pending.payload_jcs)); try { - await this.env.REGISTRY_EVENTS.send(event); + if (pending.kind === 'identity') { + await this.env.REGISTRY_EVENTS.send( + registryEventV1Schema.parse(JSON.parse(pending.payload_jcs)), + ); + } else { + await this.env.REGISTRY_DEVICE_EVENTS.send( + deviceRegistryEventV2Schema.parse(JSON.parse(pending.payload_jcs)), + ); + } } catch { const exponent = Math.min(attempt - 1, 20); const delay = Math.min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS * 2 ** exponent); @@ -519,8 +1203,9 @@ export class IdentityState extends DurableObject { // A crash after send and before this write can duplicate the queue event; // this is intentional at-least-once behavior and eventId is stable. this.ctx.storage.transactionSync(() => { + const table = pending.kind === 'identity' ? 'outbox' : 'device_outbox'; this.ctx.storage.sql.exec( - `UPDATE outbox + `UPDATE ${table} SET published_at = ? WHERE event_id = ? AND published_at IS NULL`, nowSeconds(), diff --git a/workers/registry/src/index.ts b/workers/registry/src/index.ts index f9c3db5..0f0655a 100644 --- a/workers/registry/src/index.ts +++ b/workers/registry/src/index.ts @@ -4,6 +4,9 @@ import { fail } from './errors'; export { IdentityState } from './identity-state-do'; import type { AuthoritativeStatus, + AuthoritativeDeviceStatusV2, + DeviceRegistryMutationV2, + DeviceStatusCommand, RegisterCommand, RegistryEnv, RegistryMutation, @@ -11,7 +14,16 @@ import type { RevokeBySecretCommand, RevokeBySignatureCommand, } from './types'; +import type { + DeviceActivationRequestV2, + DeviceRootRevokeRequestV2, + DeviceSelfRevokeRequestV2, +} from '@nexus/protocol'; import { + parseDeviceActivationCommand, + parseDeviceRootRevokeCommand, + parseDeviceSelfRevokeCommand, + parseDeviceStatusCommand, parseSecretCommand, parseSignatureCommand, parseStatusSubject, @@ -93,10 +105,80 @@ export default class RegistryService extends WorkerEntrypoint { return fail('INTERNAL_ERROR'); } } + + async activateDevice( + input: DeviceActivationRequestV2, + ): Promise> { + const parsed = parseDeviceActivationCommand(input); + if (!parsed.ok) return parsed; + try { + return await this.env.IDENTITY_STATE.getByName(parsed.value.payload.subject).activateDevice( + parsed.value, + ); + } catch { + return fail('INTERNAL_ERROR'); + } + } + + async deviceStatus( + input: DeviceStatusCommand, + ): Promise> { + const parsed = parseDeviceStatusCommand(input); + if (!parsed.ok) return parsed; + try { + return await this.env.IDENTITY_STATE.getByName(parsed.value.subject).deviceStatus({ + deviceId: parsed.value.deviceId, + authorizationId: parsed.value.authorizationId, + }); + } catch { + return fail('INTERNAL_ERROR'); + } + } + + async deviceStatusBatch( + inputs: DeviceStatusCommand[], + ): Promise>> { + if (!Array.isArray(inputs) || inputs.length > MAX_STATUS_BATCH) { + const size = Array.isArray(inputs) ? inputs.length : 1; + return Array.from({ length: size }, () => fail('BAD_REQUEST')); + } + return await Promise.all(inputs.map(async (input) => await this.deviceStatus(input))); + } + + async revokeDeviceSelf( + input: DeviceSelfRevokeRequestV2, + ): Promise> { + const parsed = parseDeviceSelfRevokeCommand(input); + if (!parsed.ok) return parsed; + try { + return await this.env.IDENTITY_STATE.getByName(parsed.value.payload.subject).revokeDeviceSelf( + parsed.value, + ); + } catch { + return fail('INTERNAL_ERROR'); + } + } + + async revokeDeviceRoot( + input: DeviceRootRevokeRequestV2, + ): Promise> { + const parsed = parseDeviceRootRevokeCommand(input); + if (!parsed.ok) return parsed; + try { + return await this.env.IDENTITY_STATE.getByName(parsed.value.payload.subject).revokeDeviceRoot( + parsed.value, + ); + } catch { + return fail('INTERNAL_ERROR'); + } + } } export type { AuthoritativeStatus, + AuthoritativeDeviceStatusV2, + DeviceRegistryMutationV2, + DeviceStatusCommand, RegisterCommand, RegistryErrorCode, RegistryFault, diff --git a/workers/registry/src/schema.ts b/workers/registry/src/schema.ts index 24b2bf2..bf95e38 100644 --- a/workers/registry/src/schema.ts +++ b/workers/registry/src/schema.ts @@ -1,4 +1,4 @@ -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; interface SchemaVersionRow { [key: string]: SqlStorageValue; @@ -6,9 +6,9 @@ interface SchemaVersionRow { } /** - * Runs synchronously inside the constructor's blockConcurrencyWhile gate. The - * three protocol tables intentionally match NEXUS_SPEC section 21 exactly; - * schema_meta is only the local SQLite migration ledger. + * Runs synchronously at an authoritative method boundary. The three v1 + * protocol tables stay byte-for-byte unchanged; v2 device authority uses + * separate additive tables and a separate monotonic sequence. */ export function migrateSchema(state: DurableObjectState): void { state.storage.transactionSync(() => { @@ -24,15 +24,12 @@ export function migrateSchema(state: DurableObjectState): void { .exec('SELECT version FROM schema_meta WHERE singleton = 1') .toArray()[0]; - if (current !== undefined && current.version !== SCHEMA_VERSION) { + if (current !== undefined && current.version !== 1 && current.version !== SCHEMA_VERSION) { throw new Error(`Unsupported IdentityState schema version ${current.version}.`); } - if (current !== undefined) { - return; - } - - sql.exec(` - CREATE TABLE IF NOT EXISTS identity_state ( + if (current === undefined) { + sql.exec(` + CREATE TABLE IF NOT EXISTS identity_state ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), subject TEXT NOT NULL UNIQUE, protocol TEXT NOT NULL, @@ -48,18 +45,65 @@ export function migrateSchema(state: DurableObjectState): void { revoked_at INTEGER, revocation_event_id TEXT ) - `); - sql.exec(` - CREATE TABLE IF NOT EXISTS actions ( + `); + sql.exec(` + CREATE TABLE IF NOT EXISTS actions ( sequence INTEGER PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, action_type TEXT NOT NULL, event_hash BLOB NOT NULL, accepted_at INTEGER NOT NULL ) + `); + sql.exec(` + CREATE TABLE IF NOT EXISTS outbox ( + event_id TEXT PRIMARY KEY, + payload_jcs TEXT NOT NULL, + created_at INTEGER NOT NULL, + published_at INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0 + ) + `); + sql.exec('INSERT INTO schema_meta (singleton, version) VALUES (1, 1)'); + } + + // Device authority is deliberately stored beside, rather than folded into, + // the v1 lifecycle tables. In particular, no device mutation may alter the + // v1 identity_state.sequence or the exact actions/outbox schemas. + sql.exec(` + CREATE TABLE IF NOT EXISTS device_ledger ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + sequence INTEGER NOT NULL + ) + `); + sql.exec(` + CREATE TABLE IF NOT EXISTS device_state ( + device_id TEXT PRIMARY KEY, + authorization_id TEXT, + authorization_jcs TEXT, + signing_public_key BLOB, + state TEXT NOT NULL CHECK (state IN ('active', 'revoked')), + authorization_expires_at INTEGER, + activated_at INTEGER, + revoked_at INTEGER, + revoked_by TEXT CHECK (revoked_by IS NULL OR revoked_by IN ('root', 'device')), + activation_event_id TEXT, + revocation_event_id TEXT + ) + `); + sql.exec(` + CREATE TABLE IF NOT EXISTS device_operations ( + operation_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + operation_type TEXT NOT NULL CHECK (operation_type IN ('activate', 'revoke-root', 'revoke-self')), + device_id TEXT NOT NULL, + authorization_id TEXT, + event_hash BLOB NOT NULL, + accepted_at INTEGER NOT NULL + ) `); sql.exec(` - CREATE TABLE IF NOT EXISTS outbox ( + CREATE TABLE IF NOT EXISTS device_outbox ( event_id TEXT PRIMARY KEY, payload_jcs TEXT NOT NULL, created_at INTEGER NOT NULL, @@ -67,6 +111,7 @@ export function migrateSchema(state: DurableObjectState): void { attempt_count INTEGER NOT NULL DEFAULT 0 ) `); - sql.exec('INSERT INTO schema_meta (singleton, version) VALUES (1, ?)', SCHEMA_VERSION); + sql.exec('INSERT OR IGNORE INTO device_ledger (singleton, sequence) VALUES (1, 0)'); + sql.exec('UPDATE schema_meta SET version = ? WHERE singleton = 1', SCHEMA_VERSION); }); } diff --git a/workers/registry/src/types.ts b/workers/registry/src/types.ts index e8f792a..87c612d 100644 --- a/workers/registry/src/types.ts +++ b/workers/registry/src/types.ts @@ -1,6 +1,12 @@ import type { Base64Url32, + DeviceActivationRequestV2, + DeviceRegistryEventV2, + DeviceRootRevokeRequestV2, + DeviceSelfRevokeRequestV2, IdentityGenesisV1, + NexusDeviceAuthorizationIdV2, + NexusDeviceIdV2, NexusSubject, RegistryEventV1, RevokeBySecretV1, @@ -17,6 +23,9 @@ export type RegistryErrorCode = | 'INVALID_REVOCATION_SECRET' | 'IDENTITY_NOT_FOUND' | 'IDENTITY_REVOKED' + | 'DEVICE_NOT_FOUND' + | 'DEVICE_REVOKED' + | 'DEVICE_AUTHORIZATION_CONFLICT' | 'SEQUENCE_CONFLICT' | 'SUBJECT_GENESIS_CONFLICT' | 'INTERNAL_ERROR'; @@ -57,6 +66,47 @@ export interface RegistryMutation extends AuthoritativeStatus { event: RegistryEventV1; } +export interface DeviceStatusCommand { + subject: NexusSubject; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; +} + +export type DeviceStatusState = 'active' | 'revoked' | 'expired' | 'unknown'; + +export interface AuthoritativeDeviceStatusV2 { + subject: NexusSubject; + genesisHash: Base64Url32; + identityState: 'active' | 'revoked'; + identitySequence: number; + deviceLedgerSequence: number; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2; + deviceState: DeviceStatusState; + activatedAt: number | null; + revokedAt: number | null; + authorizationExpiresAt: number | null; +} + +export interface DeviceRegistryMutationV2 { + subject: NexusSubject; + genesisHash: Base64Url32; + identityState: 'active' | 'revoked'; + identitySequence: number; + deviceLedgerSequence: number; + deviceId: NexusDeviceIdV2; + authorizationId: NexusDeviceAuthorizationIdV2 | null; + deviceState: 'active' | 'revoked'; + activatedAt: number | null; + revokedAt: number | null; + authorizationExpiresAt: number | null; + operationId: string; + eventId: string; + eventType: 'activated' | 'revoked'; + acceptedAt: number; + event: DeviceRegistryEventV2; +} + export interface PreparedRegistration { subject: NexusSubject; genesis: IdentityGenesisV1; @@ -73,11 +123,24 @@ export interface IdentityStateRpc { status(): Promise>; revokeBySignature(input: RevokeBySignatureCommand): Promise>; revokeBySecret(input: RevokeBySecretCommand): Promise>; + activateDevice( + input: DeviceActivationRequestV2, + ): Promise>; + deviceStatus( + input: Omit, + ): Promise>; + revokeDeviceSelf( + input: DeviceSelfRevokeRequestV2, + ): Promise>; + revokeDeviceRoot( + input: DeviceRootRevokeRequestV2, + ): Promise>; } export interface RegistryEnv { IDENTITY_STATE: DurableObjectNamespace; REGISTRY_EVENTS: Pick, 'send'>; + REGISTRY_DEVICE_EVENTS: Pick, 'send'>; METRICS?: AnalyticsEngineDataset; } @@ -106,3 +169,18 @@ export interface StoredOutboxRow { published_at: number | null; attempt_count: number; } + +export interface StoredDeviceRow { + [key: string]: SqlStorageValue; + device_id: string; + authorization_id: string | null; + authorization_jcs: string | null; + signing_public_key: ArrayBuffer | null; + state: 'active' | 'revoked'; + authorization_expires_at: number | null; + activated_at: number | null; + revoked_at: number | null; + revoked_by: 'root' | 'device' | null; + activation_event_id: string | null; + revocation_event_id: string | null; +} diff --git a/workers/registry/src/validation.ts b/workers/registry/src/validation.ts index 4091fbc..0c547fb 100644 --- a/workers/registry/src/validation.ts +++ b/workers/registry/src/validation.ts @@ -4,18 +4,30 @@ import { base64Url64Schema, canonicalize, decodeBase64UrlExact, + deviceActivationRequestV2Schema, + deviceRootRevokeRequestV2Schema, + deviceSelfRevokeRequestV2Schema, encodeBase64Url, identityGenesisV1Schema, nexusSubjectSchema, + nexusDeviceAuthorizationIdV2Schema, + nexusDeviceIdV2Schema, revokeBySecretV1Schema, revokeBySignaturePayloadV1Schema, } from '@nexus/protocol'; -import type { RevokeBySecretV1, RevokeBySignaturePayloadV1 } from '@nexus/protocol'; +import type { + DeviceActivationRequestV2, + DeviceRootRevokeRequestV2, + DeviceSelfRevokeRequestV2, + RevokeBySecretV1, + RevokeBySignaturePayloadV1, +} from '@nexus/protocol'; import { deriveGenesisHash, deriveSubject } from '@nexus/crypto'; import { fail, succeed } from './errors'; import type { PreparedRegistration, + DeviceStatusCommand, RegisterCommand, RegistryResult, RevokeBySignatureCommand, @@ -100,6 +112,44 @@ export function parseStatusSubject(value: unknown): RegistryResult { return result.success ? succeed(result.data) : fail('INVALID_SUBJECT'); } +export function parseDeviceActivationCommand( + value: unknown, +): RegistryResult { + const result = deviceActivationRequestV2Schema.safeParse(value); + return result.success ? succeed(result.data) : fail('BAD_REQUEST'); +} + +export function parseDeviceSelfRevokeCommand( + value: unknown, +): RegistryResult { + const result = deviceSelfRevokeRequestV2Schema.safeParse(value); + return result.success ? succeed(result.data) : fail('BAD_REQUEST'); +} + +export function parseDeviceRootRevokeCommand( + value: unknown, +): RegistryResult { + const result = deviceRootRevokeRequestV2Schema.safeParse(value); + return result.success ? succeed(result.data) : fail('BAD_REQUEST'); +} + +export function parseDeviceStatusCommand(value: unknown): RegistryResult { + if (!isExactRecord(value, ['authorizationId', 'deviceId', 'subject'])) { + return fail('BAD_REQUEST'); + } + const subject = nexusSubjectSchema.safeParse(value.subject); + const deviceId = nexusDeviceIdV2Schema.safeParse(value.deviceId); + const authorizationId = nexusDeviceAuthorizationIdV2Schema.safeParse(value.authorizationId); + if (!subject.success || !deviceId.success || !authorizationId.success) { + return fail('BAD_REQUEST'); + } + return succeed({ + subject: subject.data, + deviceId: deviceId.data, + authorizationId: authorizationId.data, + }); +} + export function assertPreparedRegistration( input: PreparedRegistration, ): Promise> { diff --git a/workers/registry/test/fixtures.ts b/workers/registry/test/fixtures.ts index ed0d332..9d523e9 100644 --- a/workers/registry/test/fixtures.ts +++ b/workers/registry/test/fixtures.ts @@ -1,6 +1,12 @@ import { REVOKE_PROTOCOL_V1, REVOKE_SECRET_PROTOCOL_V1, + base64Url32Schema, + base64Url64Schema, + deviceActivationRequestV2Schema, + deviceAuthorizationPayloadV2Schema, + deviceRootRevokeRequestV2Schema, + deviceSelfRevokeRequestV2Schema, encodeBase64Url, identityGenesisV1Schema, revokeBySecretV1Schema, @@ -8,10 +14,21 @@ import { } from '@nexus/protocol'; import type { IdentityGenesisV1, + DeviceActivationRequestV2, + DeviceAuthorizationV2, + DeviceRootRevokeRequestV2, + DeviceSelfRevokeRequestV2, + NexusDeviceIdV2, RevokeBySecretV1, RevokeBySignaturePayloadV1, } from '@nexus/protocol'; -import { computeRevocationCommitment, deriveSubject, signProtocolPayload } from '@nexus/crypto'; +import { + computeRevocationCommitment, + deriveDeviceAuthorizationIdV2, + deriveDeviceIdV2, + deriveSubject, + signProtocolPayload, +} from '@nexus/crypto'; import { prepareRegistration } from '../src/validation'; import type { PreparedRegistration } from '../src/types'; @@ -23,6 +40,12 @@ export interface IdentityFixture { secret: Uint8Array; } +export interface DeviceFixture { + authorization: DeviceAuthorizationV2; + activation: DeviceActivationRequestV2; + privateKey: CryptoKey; +} + export async function createIdentityFixture(): Promise { const keyPair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); const publicKey = new Uint8Array(await crypto.subtle.exportKey('raw', keyPair.publicKey)); @@ -67,3 +90,89 @@ export function createSecretRevocation(fixture: IdentityFixture): RevokeBySecret revocationSecret: encodeBase64Url(fixture.secret), }); } + +export async function createDeviceFixture(fixture: IdentityFixture): Promise { + const pair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']); + const publicKey = base64Url32Schema.parse( + encodeBase64Url(new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey))), + ); + const now = Math.floor(Date.now() / 1_000); + const deviceId = await deriveDeviceIdV2({ + subject: fixture.prepared.subject, + signingKey: { alg: 'Ed25519', publicKey }, + }); + const payload = deviceAuthorizationPayloadV2Schema.parse({ + protocol: 'nexus.device-authorization.v2', + subject: fixture.prepared.subject, + genesisHash: fixture.prepared.genesisHash, + deviceId, + signingKey: { alg: 'Ed25519', publicKey }, + authorizationNonce: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + validFrom: now - 1, + activationDeadline: now + 300, + expiresAt: now + 3600, + }); + const authorization: DeviceAuthorizationV2 = { + payload, + rootSignature: base64Url64Schema.parse(await signProtocolPayload(payload, fixture.privateKey)), + }; + const authorizationId = await deriveDeviceAuthorizationIdV2(payload); + const activationPayload = { + protocol: 'nexus.device-activation.v2', + subject: fixture.prepared.subject, + deviceId, + authorizationId, + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + iat: now, + exp: now + 60, + } as const; + const activation = deviceActivationRequestV2Schema.parse({ + authorization, + payload: activationPayload, + deviceSignature: await signProtocolPayload(activationPayload, pair.privateKey), + }); + return { authorization, activation, privateKey: pair.privateKey }; +} + +export async function createDeviceSelfRevocation( + fixture: IdentityFixture, + device: DeviceFixture, + issuedAt = Math.floor(Date.now() / 1_000), +): Promise { + const authorizationId = await deriveDeviceAuthorizationIdV2(device.authorization.payload); + const payload = { + protocol: 'nexus.device-self-revoke.v2', + subject: fixture.prepared.subject, + genesisHash: fixture.prepared.genesisHash, + deviceId: device.authorization.payload.deviceId, + authorizationId, + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + issuedAt, + reasonCode: 'dispose', + } as const; + return deviceSelfRevokeRequestV2Schema.parse({ + authorization: device.authorization, + payload, + deviceSignature: await signProtocolPayload(payload, device.privateKey), + }); +} + +export async function createDeviceRootRevocation( + fixture: IdentityFixture, + deviceId: NexusDeviceIdV2, + issuedAt = Math.floor(Date.now() / 1_000), +): Promise { + const payload = { + protocol: 'nexus.device-root-revoke.v2', + subject: fixture.prepared.subject, + genesisHash: fixture.prepared.genesisHash, + deviceId, + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + issuedAt, + reasonCode: 'lost-device', + } as const; + return deviceRootRevokeRequestV2Schema.parse({ + payload, + rootSignature: await signProtocolPayload(payload, fixture.privateKey), + }); +} diff --git a/workers/registry/test/identity-state.worker.test.ts b/workers/registry/test/identity-state.worker.test.ts index f1b003f..0ca5512 100644 --- a/workers/registry/test/identity-state.worker.test.ts +++ b/workers/registry/test/identity-state.worker.test.ts @@ -1,20 +1,50 @@ import { env } from 'cloudflare:workers'; import { runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IdentityState } from '../src/identity-state-do'; -import { encodeBase64Url, revokeBySecretV1Schema } from '@nexus/protocol'; -import type { RegistryEventV1 } from '@nexus/protocol'; import { + base64Url64Schema, + base64UrlAtLeast16Schema, + deviceActivationPayloadV2Schema, + deviceActivationRequestV2Schema, + deviceAuthorizationPayloadV2Schema, + deviceRootRevokeRequestV2Schema, + deviceSelfRevokeRequestV2Schema, + encodeBase64Url, + ownershipProofPayloadV2Schema, + ownershipProofV2Schema, + revokeBySecretV1Schema, +} from '@nexus/protocol'; +import type { + DeviceActivationRequestV2, + DeviceRegistryEventV2, + NexusSubject, + OwnershipProofV2, + RegistryEventV1, + VerificationExpectationV2, +} from '@nexus/protocol'; +import { + deriveDeviceAuthorizationIdV2, + deriveDeviceIdV2, + signProtocolPayload, +} from '@nexus/crypto'; +import { verifyRpOperationV2 } from '@nexus/verifier'; +import type { ChallengeStore, DeviceLifecycleProvider, LifecycleProvider } from '@nexus/verifier'; +import { + createDeviceFixture, + createDeviceRootRevocation, + createDeviceSelfRevocation, createIdentityFixture, createSecretRevocation, createSignatureRevocation, } from './fixtures'; +import type { DeviceFixture, IdentityFixture } from './fixtures'; interface QueueControl { setFailure(value: boolean): Promise; reset(value?: boolean): Promise; - getMessages(): Promise; + getMessages(): Promise>; } declare global { @@ -34,11 +64,163 @@ async function inspect( return await runInDurableObject(stub, (_instance, state) => query(state)); } +async function createTimedActivation( + fixture: IdentityFixture, + device: DeviceFixture, + authorizationTimes: { validFrom: number; activationDeadline: number; expiresAt: number }, + requestTimes?: { iat: number; exp: number }, +): Promise { + const authorizationPayload = deviceAuthorizationPayloadV2Schema.parse({ + ...device.authorization.payload, + ...authorizationTimes, + authorizationNonce: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + }); + const authorization = { + payload: authorizationPayload, + rootSignature: base64Url64Schema.parse( + await signProtocolPayload(authorizationPayload, fixture.privateKey), + ), + }; + const authorizationId = await deriveDeviceAuthorizationIdV2(authorizationPayload); + const now = Math.floor(Date.now() / 1_000); + const activationPayload = deviceActivationPayloadV2Schema.parse({ + ...device.activation.payload, + authorizationId, + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + iat: requestTimes?.iat ?? now, + exp: requestTimes?.exp ?? now + 60, + }); + return deviceActivationRequestV2Schema.parse({ + authorization, + payload: activationPayload, + deviceSignature: await signProtocolPayload(activationPayload, device.privateKey), + }); +} + +async function createOwnershipProof( + fixture: IdentityFixture, + device: DeviceFixture, + nonce: VerificationExpectationV2['nonce'], +): Promise { + const now = Math.floor(Date.now() / 1_000); + const payload = ownershipProofPayloadV2Schema.parse({ + protocol: 'nexus.ownership-proof.v2', + subject: fixture.prepared.subject, + genesis: fixture.genesis, + deviceId: device.authorization.payload.deviceId, + authorizationId: await deriveDeviceAuthorizationIdV2(device.authorization.payload), + authorization: device.authorization, + aud: 'https://rp.example', + act: 'authenticate', + resource: 'session', + nonce, + iat: now, + exp: now + 60, + }); + return ownershipProofV2Schema.parse({ + payload, + deviceSignature: await signProtocolPayload(payload, device.privateKey), + }); +} + +function proofExpectation(nonce: VerificationExpectationV2['nonce']): VerificationExpectationV2 { + return { + audience: 'https://rp.example', + action: 'authenticate', + resource: 'session', + nonce, + now: Math.floor(Date.now() / 1_000), + maxClockSkewSeconds: 60, + contextHash: null, + }; +} + +function challengeStore(expected: VerificationExpectationV2): ChallengeStore { + let consumed = false; + return { + get(nonce) { + return Promise.resolve( + nonce === expected.nonce + ? { + nonce, + action: expected.action, + resource: expected.resource, + expiresAt: expected.now + 120, + consumed, + } + : null, + ); + }, + consumeAtomically(nonce) { + if (nonce !== expected.nonce || consumed) return Promise.resolve(false); + consumed = true; + return Promise.resolve(true); + }, + }; +} + +function authoritativeProviders( + stub: DurableObjectStub, + expectedSubject: NexusSubject, +): { lifecycle: LifecycleProvider; devices: DeviceLifecycleProvider } { + return { + lifecycle: { + async getAuthoritativeStatus(subject) { + if (subject !== expectedSubject) return { state: 'not-found' }; + const result = await stub.status(); + if (!result.ok) return { state: 'not-found' }; + return result.value.state === 'active' + ? { + state: 'active', + sequence: result.value.sequence, + registeredAt: result.value.registeredAt, + } + : { + state: 'revoked', + sequence: result.value.sequence, + registeredAt: result.value.registeredAt, + ...(result.value.revokedAt === null ? {} : { revokedAt: result.value.revokedAt }), + }; + }, + }, + devices: { + async getAuthoritativeDeviceStatus(subject, deviceId, authorizationId) { + if (subject !== expectedSubject) return { state: 'not-found' }; + const result = await stub.deviceStatus({ deviceId, authorizationId }); + if (!result.ok || result.value.deviceState === 'unknown') return { state: 'not-found' }; + return { + state: result.value.deviceState, + identityState: result.value.identityState, + identitySequence: result.value.identitySequence, + deviceId: result.value.deviceId, + authorizationId: result.value.authorizationId, + deviceLedgerSequence: result.value.deviceLedgerSequence, + ...(result.value.activatedAt === null ? {} : { activatedAt: result.value.activatedAt }), + ...(result.value.revokedAt === null ? {} : { revokedAt: result.value.revokedAt }), + ...(result.value.authorizationExpiresAt === null + ? {} + : { authorizationExpiresAt: result.value.authorizationExpiresAt }), + }; + }, + }, + }; +} + +function freshNonce(): VerificationExpectationV2['nonce'] { + return base64UrlAtLeast16Schema.parse( + encodeBase64Url(crypto.getRandomValues(new Uint8Array(16))), + ); +} + describe('IdentityState SQLite Durable Object', () => { beforeEach(async () => { await env.TEST_QUEUE_CONTROL.reset(false); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('keeps an unknown status read-only, then creates schema on registration', async () => { const fixture = await createIdentityFixture(); const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); @@ -246,7 +428,7 @@ describe('IdentityState SQLite Durable Object', () => { ); }); - it('creates the exact spec lifecycle/action/outbox columns under schema_meta v1', async () => { + it('preserves the exact v1 lifecycle/action/outbox columns under additive schema v2', async () => { const fixture = await createIdentityFixture(); const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); await stub.register(fixture.prepared); @@ -267,7 +449,7 @@ describe('IdentityState SQLite Durable Object', () => { }; }); - expect(schema.version).toBe(1); + expect(schema.version).toBe(2); expect(schema.identity).toEqual([ 'singleton', 'subject', @@ -299,4 +481,726 @@ describe('IdentityState SQLite Durable Object', () => { 'attempt_count', ]); }); + + it('activates an exact root-authorized device once without changing v1 lifecycle state', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + + const results = await Promise.all( + Array.from({ length: 50 }, async () => await stub.activateDevice(device.activation)), + ); + expect(results.every((result) => result.ok)).toBe(true); + const eventIds = results.flatMap((result) => (result.ok ? [result.value.eventId] : [])); + expect(new Set(eventIds).size).toBe(1); + + const authorizationId = await deriveDeviceAuthorizationIdV2(device.authorization.payload); + await expect( + stub.deviceStatus({ deviceId: device.authorization.payload.deviceId, authorizationId }), + ).resolves.toMatchObject({ + ok: true, + value: { + identityState: 'active', + identitySequence: 0, + deviceLedgerSequence: 1, + deviceState: 'active', + }, + }); + const stored = await inspect(stub, (state) => ({ + identity: state.storage.sql + .exec<{ state: string; sequence: number }>( + 'SELECT state, sequence FROM identity_state WHERE singleton = 1', + ) + .one(), + deviceCount: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_state') + .one().count, + operationCount: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_operations') + .one().count, + deviceOutboxCount: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_outbox') + .one().count, + })); + expect(stored).toEqual({ + identity: { state: 'active', sequence: 0 }, + deviceCount: 1, + operationCount: 1, + deviceOutboxCount: 1, + }); + }); + + it('serializes root and self revocation races into one permanent device tombstone', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await stub.activateDevice(device.activation); + const root = await createDeviceRootRevocation(fixture, device.authorization.payload.deviceId); + const self = await createDeviceSelfRevocation(fixture, device); + + const results = await Promise.all( + Array.from({ length: 50 }, async (_unused, index) => + index % 2 === 0 ? await stub.revokeDeviceRoot(root) : await stub.revokeDeviceSelf(self), + ), + ); + expect(results.every((result) => result.ok)).toBe(true); + const eventIds = results.flatMap((result) => (result.ok ? [result.value.eventId] : [])); + expect(new Set(eventIds).size).toBe(1); + + const state = await inspect(stub, (durableState) => ({ + identitySequence: durableState.storage.sql + .exec<{ sequence: number }>('SELECT sequence FROM identity_state WHERE singleton = 1') + .one().sequence, + deviceLedgerSequence: durableState.storage.sql + .exec<{ sequence: number }>('SELECT sequence FROM device_ledger WHERE singleton = 1') + .one().sequence, + revocationOperations: durableState.storage.sql + .exec<{ count: number }>( + "SELECT COUNT(*) AS count FROM device_operations WHERE operation_type <> 'activate'", + ) + .one().count, + })); + expect(state).toEqual({ + identitySequence: 0, + deviceLedgerSequence: 2, + revocationOperations: 1, + }); + }); + + it('lets the root tombstone an unactivated device and permanently rejects delayed activation', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const root = await createDeviceRootRevocation(fixture, device.authorization.payload.deviceId); + const revoked = await stub.revokeDeviceRoot(root); + expect(revoked).toMatchObject({ + ok: true, + value: { authorizationId: null, deviceState: 'revoked', activatedAt: null }, + }); + await expect(stub.activateDevice(device.activation)).resolves.toMatchObject({ + ok: false, + error: { code: 'DEVICE_REVOKED' }, + }); + + const authorizationId = await deriveDeviceAuthorizationIdV2(device.authorization.payload); + const status = await stub.deviceStatus({ + deviceId: device.authorization.payload.deviceId, + authorizationId, + }); + expect(status).toMatchObject({ + ok: true, + value: { deviceState: 'revoked', activatedAt: null }, + }); + expect(status.ok && typeof status.value.revokedAt === 'number').toBe(true); + }); + + it('makes terminal v1 identity revocation dominate an activation race', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + + const [activation, terminal] = await Promise.all([ + stub.activateDevice(device.activation), + stub.revokeBySecret(createSecretRevocation(fixture)), + ]); + expect(terminal.ok).toBe(true); + expect(activation.ok || activation.error.code === 'IDENTITY_REVOKED').toBe(true); + + const authorizationId = await deriveDeviceAuthorizationIdV2(device.authorization.payload); + await expect( + stub.deviceStatus({ deviceId: device.authorization.payload.deviceId, authorizationId }), + ).resolves.toMatchObject({ + ok: true, + value: { identityState: 'revoked', identitySequence: 1 }, + }); + const final = await stub.deviceStatus({ + deviceId: device.authorization.payload.deviceId, + authorizationId, + }); + if (final.ok) { + expect(final.value.deviceState).toBe('revoked'); + } + }); + + it('recovers an exact committed activation after its timing windows close', async () => { + vi.useFakeTimers(); + const startedAt = new Date('2026-08-12T00:00:00.000Z'); + vi.setSystemTime(startedAt); + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const first = await stub.activateDevice(device.activation); + expect(first.ok).toBe(true); + + vi.setSystemTime(new Date(startedAt.getTime() + 2 * 60 * 60 * 1_000)); + const replay = await stub.activateDevice(device.activation); + expect(replay.ok).toBe(true); + if (first.ok && replay.ok) { + expect(replay.value.eventId).toBe(first.value.eventId); + expect(replay.value.operationId).toBe(first.value.operationId); + } + }); + + it('rejects stale uncommitted self and root revocations without writing device state', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const staleIssuedAt = Math.floor(Date.now() / 1_000) - 301; + const self = await createDeviceSelfRevocation(fixture, device, staleIssuedAt); + const root = await createDeviceRootRevocation( + fixture, + device.authorization.payload.deviceId, + staleIssuedAt, + ); + + await expect(stub.revokeDeviceSelf(self)).resolves.toMatchObject({ + ok: false, + error: { code: 'BAD_REQUEST' }, + }); + await expect(stub.revokeDeviceRoot(root)).resolves.toMatchObject({ + ok: false, + error: { code: 'BAD_REQUEST' }, + }); + const counts = await inspect(stub, (state) => ({ + devices: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_state') + .one().count, + operations: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_operations') + .one().count, + })); + expect(counts).toEqual({ devices: 0, operations: 0 }); + }); + + it('applies terminal identity and device tombstone dominance before exact operation replay', async () => { + const fixture = await createIdentityFixture(); + const activeDevice = await createDeviceFixture(fixture); + const unactivatedDevice = await createDeviceFixture(fixture); + const missingDevice = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await stub.activateDevice(activeDevice.activation); + + const self = await createDeviceSelfRevocation(fixture, activeDevice); + const selfResult = await stub.revokeDeviceSelf(self); + expect(selfResult.ok).toBe(true); + await expect(stub.activateDevice(activeDevice.activation)).resolves.toMatchObject({ + ok: false, + error: { code: 'DEVICE_REVOKED' }, + }); + + const root = await createDeviceRootRevocation( + fixture, + unactivatedDevice.authorization.payload.deviceId, + ); + const rootResult = await stub.revokeDeviceRoot(root); + expect(rootResult.ok).toBe(true); + await stub.revokeBySecret(createSecretRevocation(fixture)); + + await expect(stub.revokeDeviceSelf(self)).resolves.toMatchObject({ + ok: false, + error: { code: 'IDENTITY_REVOKED' }, + }); + await expect(stub.revokeDeviceRoot(root)).resolves.toMatchObject({ + ok: false, + error: { code: 'IDENTITY_REVOKED' }, + }); + const unactivatedAuthorizationId = await deriveDeviceAuthorizationIdV2( + unactivatedDevice.authorization.payload, + ); + await expect( + stub.deviceStatus({ + deviceId: unactivatedDevice.authorization.payload.deviceId, + authorizationId: unactivatedAuthorizationId, + }), + ).resolves.toMatchObject({ + ok: true, + value: { identityState: 'revoked', deviceState: 'revoked' }, + }); + const missingAuthorizationId = await deriveDeviceAuthorizationIdV2( + missingDevice.authorization.payload, + ); + await expect( + stub.deviceStatus({ + deviceId: missingDevice.authorization.payload.deviceId, + authorizationId: missingAuthorizationId, + }), + ).resolves.toMatchObject({ + ok: true, + value: { identityState: 'revoked', deviceState: 'revoked' }, + }); + }); + + it('rejects invalid root authorization and activation proof-of-possession signatures', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const invalidSignature = base64Url64Schema.parse(encodeBase64Url(new Uint8Array(64))); + + const invalidRoot = deviceActivationRequestV2Schema.parse({ + ...device.activation, + authorization: { ...device.authorization, rootSignature: invalidSignature }, + }); + await expect(stub.activateDevice(invalidRoot)).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_SIGNATURE' }, + }); + + const invalidPossession = deviceActivationRequestV2Schema.parse({ + ...device.activation, + deviceSignature: invalidSignature, + }); + await expect(stub.activateDevice(invalidPossession)).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_SIGNATURE' }, + }); + const counts = await inspect(stub, (state) => ({ + devices: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_state') + .one().count, + operations: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_operations') + .one().count, + })); + expect(counts).toEqual({ devices: 0, operations: 0 }); + }); + + it('rejects the identity root signing key when presented as a device key', async () => { + const fixture = await createIdentityFixture(); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const now = Math.floor(Date.now() / 1_000); + const signingKey = fixture.genesis.signingKey; + const deviceId = await deriveDeviceIdV2({ + subject: fixture.prepared.subject, + signingKey, + }); + const authorizationPayload = deviceAuthorizationPayloadV2Schema.parse({ + protocol: 'nexus.device-authorization.v2', + subject: fixture.prepared.subject, + genesisHash: fixture.prepared.genesisHash, + deviceId, + signingKey, + authorizationNonce: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + validFrom: now - 1, + activationDeadline: now + 300, + expiresAt: now + 3600, + }); + const authorization = { + payload: authorizationPayload, + rootSignature: base64Url64Schema.parse( + await signProtocolPayload(authorizationPayload, fixture.privateKey), + ), + }; + const activationPayload = deviceActivationPayloadV2Schema.parse({ + protocol: 'nexus.device-activation.v2', + subject: fixture.prepared.subject, + deviceId, + authorizationId: await deriveDeviceAuthorizationIdV2(authorizationPayload), + requestId: encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))), + iat: now, + exp: now + 60, + }); + const request = deviceActivationRequestV2Schema.parse({ + authorization, + payload: activationPayload, + deviceSignature: await signProtocolPayload(activationPayload, fixture.privateKey), + }); + + await expect(stub.activateDevice(request)).resolves.toMatchObject({ + ok: false, + error: { code: 'BAD_REQUEST' }, + }); + const counts = await inspect(stub, (state) => ({ + devices: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_state') + .one().count, + operations: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_operations') + .one().count, + })); + expect(counts).toEqual({ devices: 0, operations: 0 }); + }); + + it('keeps device and root signing roles cryptographically separate', async () => { + const fixture = await createIdentityFixture(); + const target = await createDeviceFixture(fixture); + const sibling = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await stub.activateDevice(target.activation); + + const self = await createDeviceSelfRevocation(fixture, target); + const siblingRevoke = deviceSelfRevokeRequestV2Schema.parse({ + ...self, + deviceSignature: await signProtocolPayload(self.payload, sibling.privateKey), + }); + await expect(stub.revokeDeviceSelf(siblingRevoke)).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_SIGNATURE' }, + }); + + const root = await createDeviceRootRevocation(fixture, target.authorization.payload.deviceId); + const deviceAsRoot = deviceRootRevokeRequestV2Schema.parse({ + ...root, + rootSignature: await signProtocolPayload(root.payload, target.privateKey), + }); + await expect(stub.revokeDeviceRoot(deviceAsRoot)).resolves.toMatchObject({ + ok: false, + error: { code: 'INVALID_SIGNATURE' }, + }); + const authorizationId = await deriveDeviceAuthorizationIdV2(target.authorization.payload); + await expect( + stub.deviceStatus({ deviceId: target.authorization.payload.deviceId, authorizationId }), + ).resolves.toMatchObject({ ok: true, value: { deviceState: 'active' } }); + }); + + it('rejects a conflicting root authorization for an already-active device key', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await stub.activateDevice(device.activation); + const now = Math.floor(Date.now() / 1_000); + const conflicting = await createTimedActivation(fixture, device, { + validFrom: now - 1, + activationDeadline: now + 300, + expiresAt: now + 3600, + }); + + await expect(stub.activateDevice(conflicting)).resolves.toMatchObject({ + ok: false, + error: { code: 'DEVICE_AUTHORIZATION_CONFLICT' }, + }); + const state = await inspect(stub, (durableState) => ({ + ledger: durableState.storage.sql + .exec<{ sequence: number }>('SELECT sequence FROM device_ledger WHERE singleton = 1') + .one().sequence, + operations: durableState.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_operations') + .one().count, + })); + expect(state).toEqual({ ledger: 1, operations: 1 }); + }); + + it('enforces authorization and activation request time boundaries', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const now = Math.floor(Date.now() / 1_000); + const validTimes = { validFrom: now - 1, activationDeadline: now + 300, expiresAt: now + 3600 }; + const cases: DeviceActivationRequestV2[] = [ + await createTimedActivation(fixture, device, { + validFrom: now + 61, + activationDeadline: now + 300, + expiresAt: now + 3600, + }), + await createTimedActivation(fixture, device, { + validFrom: now - 3600, + activationDeadline: now - 2, + expiresAt: now - 1, + }), + await createTimedActivation(fixture, device, validTimes, { + iat: now + 61, + exp: now + 120, + }), + await createTimedActivation(fixture, device, validTimes, { + iat: now - 120, + exp: now - 61, + }), + await createTimedActivation(fixture, device, validTimes, { + iat: now, + exp: now + 301, + }), + ]; + const overlongAuthorization: DeviceActivationRequestV2 = { + ...device.activation, + authorization: { + ...device.activation.authorization, + payload: { + ...device.activation.authorization.payload, + validFrom: now, + activationDeadline: now + 300, + expiresAt: now + 366 * 24 * 60 * 60 + 1, + }, + }, + }; + cases.push(overlongAuthorization); + + for (const request of cases) { + await expect(stub.activateDevice(request)).resolves.toMatchObject({ + ok: false, + error: { code: 'BAD_REQUEST' }, + }); + } + const counts = await inspect(stub, (state) => ({ + devices: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_state') + .one().count, + operations: state.storage.sql + .exec<{ count: number }>('SELECT COUNT(*) AS count FROM device_operations') + .one().count, + })); + expect(counts).toEqual({ devices: 0, operations: 0 }); + }); + + it('retries the independent device outbox with stable at-least-once event identity', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await new Promise((resolve) => setTimeout(resolve, 20)); + await env.TEST_QUEUE_CONTROL.reset(true); + + const activation = await stub.activateDevice(device.activation); + expect(activation.ok).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 20)); + const failed = await inspect(stub, (state) => + state.storage.sql + .exec<{ event_id: string; published_at: number | null; attempt_count: number }>( + 'SELECT event_id, published_at, attempt_count FROM device_outbox', + ) + .one(), + ); + expect(failed.event_id).toBe(activation.ok ? activation.value.eventId : ''); + expect(failed.published_at).toBeNull(); + expect(failed.attempt_count).toBeGreaterThanOrEqual(1); + + await env.TEST_QUEUE_CONTROL.setFailure(false); + await expect(runDurableObjectAlarm(stub)).resolves.toBe(true); + const recovered = await inspect(stub, (state) => + state.storage.sql + .exec<{ published_at: number | null; attempt_count: number }>( + 'SELECT published_at, attempt_count FROM device_outbox', + ) + .one(), + ); + expect(recovered.published_at).toEqual(expect.any(Number)); + expect(recovered.attempt_count).toBeGreaterThanOrEqual(2); + const attempts = (await env.TEST_QUEUE_CONTROL.getMessages()).filter( + (event): event is DeviceRegistryEventV2 => + event.protocol === 'nexus.device-registry-event.v2', + ); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(new Set(attempts.map((event) => event.eventId))).toEqual( + new Set([activation.ok ? activation.value.eventId : '']), + ); + }); + + it('resolves activation racing root revocation to a permanent tombstone', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + const root = await createDeviceRootRevocation(fixture, device.authorization.payload.deviceId); + await Promise.all([stub.activateDevice(device.activation), stub.revokeDeviceRoot(root)]); + const authorizationId = await deriveDeviceAuthorizationIdV2(device.authorization.payload); + await expect( + stub.deviceStatus({ deviceId: device.authorization.payload.deviceId, authorizationId }), + ).resolves.toMatchObject({ ok: true, value: { deviceState: 'revoked' } }); + await expect(stub.activateDevice(device.activation)).resolves.toMatchObject({ + ok: false, + error: { code: 'DEVICE_REVOKED' }, + }); + }); + + it('returns stable exact replays for self and root device revocations', async () => { + const selfIdentity = await createIdentityFixture(); + const selfDevice = await createDeviceFixture(selfIdentity); + const selfStub = env.IDENTITY_STATE.getByName(selfIdentity.prepared.subject); + await selfStub.register(selfIdentity.prepared); + await selfStub.activateDevice(selfDevice.activation); + const self = await createDeviceSelfRevocation(selfIdentity, selfDevice); + const selfFirst = await selfStub.revokeDeviceSelf(self); + const selfReplay = await selfStub.revokeDeviceSelf(self); + expect(selfFirst.ok && selfReplay.ok).toBe(true); + if (selfFirst.ok && selfReplay.ok) { + expect(selfReplay.value.eventId).toBe(selfFirst.value.eventId); + expect(selfReplay.value.operationId).toBe(selfFirst.value.operationId); + } + + const rootIdentity = await createIdentityFixture(); + const rootDevice = await createDeviceFixture(rootIdentity); + const rootStub = env.IDENTITY_STATE.getByName(rootIdentity.prepared.subject); + await rootStub.register(rootIdentity.prepared); + const root = await createDeviceRootRevocation( + rootIdentity, + rootDevice.authorization.payload.deviceId, + ); + const rootFirst = await rootStub.revokeDeviceRoot(root); + const rootReplay = await rootStub.revokeDeviceRoot(root); + expect(rootFirst.ok && rootReplay.ok).toBe(true); + if (rootFirst.ok && rootReplay.ok) { + expect(rootReplay.value.eventId).toBe(rootFirst.value.eventId); + expect(rootReplay.value.operationId).toBe(rootFirst.value.operationId); + } + }); + + it('returns unknown for a wrong authorization query while the device remains active', async () => { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const other = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await stub.activateDevice(device.activation); + const wrongAuthorizationId = await deriveDeviceAuthorizationIdV2(other.authorization.payload); + await expect( + stub.deviceStatus({ + deviceId: device.authorization.payload.deviceId, + authorizationId: wrongAuthorizationId, + }), + ).resolves.toMatchObject({ + ok: true, + value: { identityState: 'active', deviceState: 'unknown' }, + }); + }); + + it('makes identity revocation dominate races with both device revocation modes', async () => { + for (const mode of ['self', 'root'] as const) { + const fixture = await createIdentityFixture(); + const device = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + await stub.activateDevice(device.activation); + if (mode === 'self') { + const deviceRevocation = await createDeviceSelfRevocation(fixture, device); + await Promise.all([ + stub.revokeBySecret(createSecretRevocation(fixture)), + stub.revokeDeviceSelf(deviceRevocation), + ]); + } else { + const deviceRevocation = await createDeviceRootRevocation( + fixture, + device.authorization.payload.deviceId, + ); + await Promise.all([ + stub.revokeBySecret(createSecretRevocation(fixture)), + stub.revokeDeviceRoot(deviceRevocation), + ]); + } + const authorizationId = await deriveDeviceAuthorizationIdV2(device.authorization.payload); + await expect( + stub.deviceStatus({ deviceId: device.authorization.payload.deviceId, authorizationId }), + ).resolves.toMatchObject({ + ok: true, + value: { identityState: 'revoked', deviceState: 'revoked' }, + }); + } + }); + + it('enforces sibling isolation and clone-wide revocation through the real authoritative registry', async () => { + const fixture = await createIdentityFixture(); + const deviceA = await createDeviceFixture(fixture); + const deviceB = await createDeviceFixture(fixture); + const stub = env.IDENTITY_STATE.getByName(fixture.prepared.subject); + await stub.register(fixture.prepared); + + const installA1 = await stub.activateDevice(deviceA.activation); + const installA2 = await stub.activateDevice(deviceA.activation); + const activationB = await stub.activateDevice(deviceB.activation); + expect(installA1.ok && installA2.ok && activationB.ok).toBe(true); + if (installA1.ok && installA2.ok) { + expect(installA2.value.eventId).toBe(installA1.value.eventId); + expect(installA2.value.operationId).toBe(installA1.value.operationId); + } + + const authorizationA = await deriveDeviceAuthorizationIdV2(deviceA.authorization.payload); + const authorizationB = await deriveDeviceAuthorizationIdV2(deviceB.authorization.payload); + await expect( + stub.deviceStatus({ + deviceId: deviceA.authorization.payload.deviceId, + authorizationId: authorizationA, + }), + ).resolves.toMatchObject({ ok: true, value: { deviceState: 'active' } }); + await expect( + stub.deviceStatus({ + deviceId: deviceB.authorization.payload.deviceId, + authorizationId: authorizationB, + }), + ).resolves.toMatchObject({ ok: true, value: { deviceState: 'active' } }); + + const providers = authoritativeProviders(stub, fixture.prepared.subject); + for (const logicalInstall of ['A1', 'A2'] as const) { + const nonce = freshNonce(); + const expected = proofExpectation(nonce); + const proof = await createOwnershipProof(fixture, deviceA, nonce); + await expect( + verifyRpOperationV2( + proof, + expected, + challengeStore(expected), + providers.lifecycle, + providers.devices, + ), + logicalInstall, + ).resolves.toMatchObject({ + subject: fixture.prepared.subject, + deviceId: deviceA.authorization.payload.deviceId, + }); + } + + const revokeA = await createDeviceRootRevocation( + fixture, + deviceA.authorization.payload.deviceId, + ); + await expect(stub.revokeDeviceRoot(revokeA)).resolves.toMatchObject({ + ok: true, + value: { deviceState: 'revoked' }, + }); + await expect( + stub.deviceStatus({ + deviceId: deviceA.authorization.payload.deviceId, + authorizationId: authorizationA, + }), + ).resolves.toMatchObject({ ok: true, value: { deviceState: 'revoked' } }); + await expect( + stub.deviceStatus({ + deviceId: deviceB.authorization.payload.deviceId, + authorizationId: authorizationB, + }), + ).resolves.toMatchObject({ ok: true, value: { deviceState: 'active' } }); + + for (const logicalInstall of ['A1', 'A2'] as const) { + const nonce = freshNonce(); + const expected = proofExpectation(nonce); + const proof = await createOwnershipProof(fixture, deviceA, nonce); + await expect( + verifyRpOperationV2( + proof, + expected, + challengeStore(expected), + providers.lifecycle, + providers.devices, + ), + logicalInstall, + ).rejects.toMatchObject({ + code: 'IDENTITY_REVOKED', + codeV2: 'DEVICE_REVOKED', + }); + } + + const nonceB = freshNonce(); + const expectedB = proofExpectation(nonceB); + const proofB = await createOwnershipProof(fixture, deviceB, nonceB); + await expect( + verifyRpOperationV2( + proofB, + expectedB, + challengeStore(expectedB), + providers.lifecycle, + providers.devices, + ), + ).resolves.toMatchObject({ + subject: fixture.prepared.subject, + deviceId: deviceB.authorization.payload.deviceId, + }); + }); }); diff --git a/workers/registry/vitest.worker.config.ts b/workers/registry/vitest.worker.config.ts index 6d032a5..9c2e69f 100644 --- a/workers/registry/vitest.worker.config.ts +++ b/workers/registry/vitest.worker.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ miniflare: { serviceBindings: { REGISTRY_EVENTS: 'registry-test-queue', + REGISTRY_DEVICE_EVENTS: 'registry-test-queue', TEST_QUEUE_CONTROL: 'registry-test-queue', }, workers: [ diff --git a/workers/registry/wrangler.jsonc b/workers/registry/wrangler.jsonc index 5717952..9f01542 100644 --- a/workers/registry/wrangler.jsonc +++ b/workers/registry/wrangler.jsonc @@ -13,7 +13,10 @@ }, }, "queues": { - "producers": [{ "binding": "REGISTRY_EVENTS", "queue": "nexus-registry-events" }], + "producers": [ + { "binding": "REGISTRY_EVENTS", "queue": "nexus-registry-events" }, + { "binding": "REGISTRY_DEVICE_EVENTS", "queue": "nexus-registry-device-events" }, + ], }, "analytics_engine_datasets": [{ "binding": "METRICS", "dataset": "nexus_metrics" }], "observability": { diff --git a/workers/transparency/src/index.ts b/workers/transparency/src/index.ts index dd9ab20..46cc947 100644 --- a/workers/transparency/src/index.ts +++ b/workers/transparency/src/index.ts @@ -2,6 +2,7 @@ import { WorkerEntrypoint } from 'cloudflare:workers'; import { decodeBase64Url, decodeBase64UrlExact, + deviceRegistryEventV2Schema, encodeBase64Url, globalTransparencyCheckpointPayloadV1Schema, registryEventV1Schema, @@ -13,12 +14,18 @@ import { transparencyCheckpointPayloadV1Schema, transparencyInclusionProofV1Schema, type RegistryEventV1, + type DeviceRegistryEventV2, type ServiceKeySet, type SignedGlobalTransparencyCheckpointV1, type SignedTransparencyCheckpointV1, type TransparencyInclusionProofV1, } from '@nexus/protocol'; -import { deriveRegistryEventId, signProtocolPayload, WebCryptoProvider } from '@nexus/crypto'; +import { + deriveDeviceRegistryEventIdV2, + deriveRegistryEventId, + signProtocolPayload, + WebCryptoProvider, +} from '@nexus/crypto'; import { badRequest, TransparencyError } from './errors.js'; import { @@ -89,6 +96,28 @@ export default class TransparencyService extends WorkerEntrypoint { }); } + /** Additive v2 append; the public log still retains hashes only. */ + async appendDevice(event: DeviceRegistryEventV2): Promise { + const parsed = deviceRegistryEventV2Schema.parse(event); + const { eventId, ...eventWithoutEventId } = parsed; + const derived = await deriveDeviceRegistryEventIdV2(eventWithoutEventId, this.provider); + if (derived.eventId !== eventId) { + throw new TransparencyError( + 'INVALID_EVENT_ID', + 'The device registry event ID does not match its canonical payload.', + 400, + ); + } + + const eventHash = encodeBase64Url(derived.eventHash); + const shardId = resolveShardId(derived.eventHash); + return this.callShard(shardId, '/internal/v1/append', { + eventId, + eventHash, + shardId, + }); + } + /** Returns a signed, hash-only checkpoint for one shard. */ async checkpoint(shardId: string): Promise { assertShardId(shardId); diff --git a/workers/transparency/src/scheduled-publication.test.ts b/workers/transparency/src/scheduled-publication.test.ts index f58c5a7..4f89b99 100644 --- a/workers/transparency/src/scheduled-publication.test.ts +++ b/workers/transparency/src/scheduled-publication.test.ts @@ -1,7 +1,10 @@ import { + deviceRegistryEventV2Schema, + deviceRegistryEventWithoutEventIdV2Schema, signedGlobalTransparencyCheckpointV1Schema, signedTransparencyCheckpointV1Schema, } from '@nexus/protocol'; +import { deriveDeviceRegistryEventIdV2 } from '@nexus/crypto'; import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; import { describe, expect, it } from 'vitest'; @@ -66,6 +69,43 @@ async function requireObject(key: string): Promise { } describe('scheduled transparency publication', () => { + it('appends canonical v2 device events as idempotent hash-only leaves', async () => { + const ctx = createExecutionContext(); + const worker = new TransparencyService(ctx, testEnv); + const value = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + const eventWithoutId = deviceRegistryEventWithoutEventIdV2Schema.parse({ + protocol: 'nexus.device-registry-event.v2', + operationId: `nxo2_${value}`, + eventType: 'activated', + subject: `nx1_${value}`, + genesisHash: value, + identitySequence: 0, + identityState: 'active', + deviceLedgerSequence: 1, + deviceId: `nxd2_${value}`, + authorizationId: `nxa2_${value}`, + deviceState: 'active', + authorizationExpiresAt: 1_820_000_000, + acceptedAt: 1_788_800_000, + actionHash: value, + }); + const { eventId } = await deriveDeviceRegistryEventIdV2(eventWithoutId); + const event = deviceRegistryEventV2Schema.parse({ ...eventWithoutId, eventId }); + + const first = await worker.appendDevice(event); + const duplicate = await worker.appendDevice(event); + + expect(first).toMatchObject({ eventId, duplicate: false }); + expect(duplicate).toMatchObject({ eventId, duplicate: true }); + expect(first).not.toHaveProperty('subject'); + expect(first).not.toHaveProperty('deviceId'); + expect(first).not.toHaveProperty('authorizationId'); + + await expect( + worker.appendDevice({ ...event, acceptedAt: event.acceptedAt + 1 }), + ).rejects.toMatchObject({ code: 'INVALID_EVENT_ID' }); + }); + it('serves the transparency-specific verification keyset route', async () => { const ctx = createExecutionContext(); const worker = new TransparencyService(ctx, testEnv); diff --git a/workers/transparency/vitest.config.ts b/workers/transparency/vitest.config.ts index 46f1ce3..7361cc8 100644 --- a/workers/transparency/vitest.config.ts +++ b/workers/transparency/vitest.config.ts @@ -2,7 +2,7 @@ import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; import { defineConfig } from 'vitest/config'; const TEST_SIGNING_KID = 'transparency-test-2026-08'; -const TEST_PRIVATE_KEY_PKCS8 = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; +const TEST_PRIVATE_KEY_PKCS8 = 'MC4CAQAwBQYDK2VwBCIEIMWqjfQ_n4N77bdELzHct7Fm04U1B28JS4XOOi4LRFj3'; // gitleaks:allow -- fixed test-only Ed25519 fixture const TEST_PUBLIC_KEY = '_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU'; export default defineConfig({