Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 117 additions & 7 deletions NEXUS_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -1198,7 +1224,7 @@ Rules:

### `GET /.well-known/nexus.json`

Returns:
This is the immutable v1 discovery contract. It returns exactly:

```json
{
Expand All @@ -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.
Expand Down Expand Up @@ -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.

---
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/reference-rp/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 19 additions & 6 deletions apps/reference-rp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion apps/reference-rp/src/backend/challenges.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,6 +12,7 @@ interface StoredChallenge {
contextHash: string;
expiresAt: number;
consumedAt: number | null;
acceptedProofProtocols: AcceptedProofProtocols;
}

export class HashOnlyChallengeStore implements ChallengeStore {
Expand All @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions apps/reference-rp/src/backend/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading