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
1 change: 1 addition & 0 deletions apps/verifier/src/ui/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const STATUS_LABELS: Record<string, string> = {
invalid: 'INVALID',
error: 'ERROR',
'no-key': 'NO KEY',
'unsupported-runtime': 'UNSUPPORTED RUNTIME',
};

export function renderResults(result: VerifyResult): void {
Expand Down
67 changes: 66 additions & 1 deletion apps/verifier/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { base64urlDecode } from '@peac/crypto';
import { findKeyForKid } from './lib/trust-store.js';
import { decodeReceipt } from './lib/decode-receipt.js';

export type VerifyStatus = 'valid' | 'invalid' | 'error' | 'no-key';
export type VerifyStatus = 'valid' | 'invalid' | 'error' | 'no-key' | 'unsupported-runtime';

export interface VerifyResult {
status: VerifyStatus;
Expand All @@ -19,6 +19,57 @@ export interface VerifyResult {
kid?: string;
}

const UNSUPPORTED_RUNTIME_MESSAGE =
'This browser does not support Ed25519 WebCrypto verification. ' +
'Use a current browser, or verify with the PEAC CLI.';

// RFC 8032 Section 7.1 Test 1: a known-good Ed25519 triple (empty message). The
// probe verifies this exact vector so it proves the runtime can perform Ed25519
// verification, not merely import a raw key.
const RFC8032_VECTOR1_PUBLIC_KEY =
'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a';
const RFC8032_VECTOR1_SIGNATURE =
'e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b';

function hexToBytes(hexStr: string): Uint8Array {
const out = new Uint8Array(hexStr.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hexStr.slice(i * 2, i * 2 + 2), 16);
return out;
}

/**
* Probe whether this runtime can verify Ed25519 via WebCrypto by importing a
* known-good RFC 8032 public key and verifying its known-good signature. Returns
* true only when verification of the known-good vector returns true. PEAC
* verification fails closed on an unsupported runtime; detecting it up front lets
* the UI tell the user their runtime is the problem rather than reporting the
* receipt as invalid.
*/
export async function ed25519WebCryptoSupported(): Promise<boolean> {
const subtle = globalThis.crypto?.subtle;
if (!subtle) return false;
try {
const key = await subtle.importKey(
'raw',
hexToBytes(RFC8032_VECTOR1_PUBLIC_KEY) as BufferSource,
{ name: 'Ed25519' },
false,
['verify']
);
// Empty message, known-good signature -> must verify true on a supporting runtime.
return await subtle.verify(
{ name: 'Ed25519' },
key,
hexToBytes(RFC8032_VECTOR1_SIGNATURE) as BufferSource,
new Uint8Array(0) as BufferSource
);
} catch {
// NotSupportedError (algorithm unavailable) or any failure verifying the
// known-good vector means this runtime cannot verify Ed25519.
return false;
}
}

export async function verifyReceipt(jws: string): Promise<VerifyResult> {
const decoded = decodeReceipt(jws);
if (!decoded) {
Expand All @@ -40,6 +91,17 @@ export async function verifyReceipt(jws: string): Promise<VerifyResult> {
};
}

// Fail closed on an unsupported runtime, and say so clearly: a runtime that
// cannot do Ed25519 WebCrypto is not the same as an invalid receipt.
if (!(await ed25519WebCryptoSupported())) {
return {
status: 'unsupported-runtime',
message: UNSUPPORTED_RUNTIME_MESSAGE,
claims: decoded.payload,
kid,
};
}

try {
const publicKeyBytes = base64urlDecode(trustedKey.x);
const result = await verifyLocal(jws, publicKeyBytes);
Expand All @@ -60,6 +122,9 @@ export async function verifyReceipt(jws: string): Promise<VerifyResult> {
kid,
};
} catch (err) {
// Unsupported-runtime is handled by the ed25519WebCryptoSupported() gate
// above; verifyLocal() maps any internal verifier error to a result code, so
// this catch only sees unexpected pre-verification errors.
return {
status: 'error',
message: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
Expand Down
154 changes: 154 additions & 0 deletions apps/verifier/tests/runtime-support.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Runtime-support tests for the browser verifier.
*
* PEAC Ed25519 verification requires a runtime with WebCrypto Ed25519. The
* verifier must (a) detect support up front, and (b) report an unsupported
* runtime as exactly that -- never as an invalid receipt.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';

import { generateKeypair, sign, base64urlEncode } from '@peac/crypto';

import { verifyReceipt, ed25519WebCryptoSupported } from '../src/verify.js';
import { addIssuer, clearStore } from '../src/lib/trust-store.js';

// Mock localStorage so the trust store works in the test runtime.
const storage = new Map<string, string>();
const mockLocalStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
get length() {
return storage.size;
},
key: (_index: number) => null,
};
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
configurable: true,
});

const KID = 'runtime-test-kid';

async function buildReceiptAndTrustKey(): Promise<string> {
const { privateKey, publicKey } = await generateKeypair();
const claims = {
iss: 'https://issuer.example.com',
aud: 'https://aud.example.com',
iat: 1000,
exp: 2000,
rid: 'runtime-test',
};
const jws = await sign(claims, privateKey, KID);
addIssuer({
issuer: 'https://issuer.example.com',
keys: [{ kid: KID, kty: 'OKP', crv: 'Ed25519', x: base64urlEncode(publicKey) }],
});
return jws;
}

describe('verifier runtime support', () => {
const realCrypto = globalThis.crypto;

beforeEach(() => {
storage.clear();
clearStore();
});

afterEach(() => {
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
});

it('ed25519WebCryptoSupported() is true on a supporting runtime', async () => {
expect(await ed25519WebCryptoSupported()).toBe(true);
});

it('ed25519WebCryptoSupported() is false when importKey raises NotSupportedError', async () => {
Object.defineProperty(globalThis, 'crypto', {
value: {
...realCrypto,
subtle: {
importKey: async () => {
const err = new Error('Ed25519 unsupported');
err.name = 'NotSupportedError';
throw err;
},
verify: async () => {
throw new Error('verify must not run after a failed import');
},
},
},
configurable: true,
});
expect(await ed25519WebCryptoSupported()).toBe(false);
});

it('ed25519WebCryptoSupported() is false when verify raises NotSupportedError', async () => {
Object.defineProperty(globalThis, 'crypto', {
value: {
...realCrypto,
subtle: {
importKey: async () => ({}) as never,
verify: async () => {
const err = new Error('Ed25519 verify unsupported');
err.name = 'NotSupportedError';
throw err;
},
},
},
configurable: true,
});
expect(await ed25519WebCryptoSupported()).toBe(false);
});

it('ed25519WebCryptoSupported() is false when the known-good vector does not verify', async () => {
// A runtime that imports and runs verify but returns false for the RFC 8032
// known-good vector is not a runtime we can trust to verify Ed25519.
Object.defineProperty(globalThis, 'crypto', {
value: {
...realCrypto,
subtle: {
importKey: async () => ({}) as never,
verify: async () => false,
},
},
configurable: true,
});
expect(await ed25519WebCryptoSupported()).toBe(false);
});

it('reports an unsupported runtime as unsupported-runtime, not invalid', async () => {
const jws = await buildReceiptAndTrustKey();

Object.defineProperty(globalThis, 'crypto', {
value: {
...realCrypto,
subtle: {
importKey: async () => {
const err = new Error('Ed25519 unsupported');
err.name = 'NotSupportedError';
throw err;
},
},
},
configurable: true,
});

const result = await verifyReceipt(jws);
expect(result.status).toBe('unsupported-runtime');
expect(result.status).not.toBe('invalid');
expect(result.message).toMatch(/does not support Ed25519 WebCrypto/i);
});

it('reaches verification (not unsupported-runtime) on a supporting runtime', async () => {
// On a runtime that supports Ed25519, the probe must NOT short-circuit; the
// result is a real verification outcome. (Full valid/invalid verification is
// covered by the crypto round-trip and conformance suites; here we only
// assert the runtime gate let the request through.)
const jws = await buildReceiptAndTrustKey();
const result = await verifyReceipt(jws);
expect(result.status).not.toBe('unsupported-runtime');
});
});
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Layer 6: pillars (depends on protocol, control)

### 4. Runtime Support Policy

PEAC packages declare `engines.node: ">=22.0.0"` and follow this support policy:
PEAC packages declare `engines.node: ">=22.13.0"` and follow this support policy:

- **Canonical production target:** Node 24 (Active LTS). `.node-version` pins to the latest 24.x LTS patch.
- **Compatibility floor:** Node 22 (Maintenance LTS). Supported because it is declared in `engines.node`. CI exercises Node 22 in a compatibility lane.
Expand Down
2 changes: 1 addition & 1 deletion docs/CI_BEHAVIOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This document describes the Continuous Integration pipeline behavior for the PEA

CI runs on GitHub Actions for all pushes to `main` and for all pull requests. Release validation (tag builds, publication) is handled by separate workflows (`publish.yml`, `nightly.yml`), not the primary CI workflow. The primary workflow (`.github/workflows/ci.yml`) uses concurrency groups (`ci-${{ github.ref }}`) with cancel-in-progress to avoid redundant runs.

**Node.js:** Version determined by `.node-version` file (currently 24.x Active LTS). Engine requirement: `>=22.0.0`. CI also tests Node 22 (Maintenance LTS) and Node 25 (forward-compat, non-blocking).
**Node.js:** Version determined by `.node-version` file (currently 24.x Active LTS). Engine requirement: `>=22.13.0`. CI also tests Node 22 (Maintenance LTS) and Node 25 (forward-compat, non-blocking).

**Package manager:** pnpm (version from `packageManager` field in root `package.json`). All CI jobs use `--frozen-lockfile`.

Expand Down
2 changes: 1 addition & 1 deletion docs/COMPATIBILITY_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Current as of the repository state after the v0.14.3 profile additions.
| Environment | Status | Notes |
| ---------------------------- | ----------------- | ---------------------------------------------------------------------- |
| Node.js 24 (Active LTS) | **Required** | Canonical development and CI lane |
| Node.js 22 (Maintenance LTS) | **Compatibility** | `engines.node >= 22.0.0` floor |
| Node.js 22 (Maintenance LTS) | **Compatibility** | `engines.node >= 22.13.0` floor |
| Node.js 25+ | **Advisory** | Forward-compat CI lane |
| Go 1.26+ | **Default** | Interaction Record format (core issue/verify); middleware experimental |
| Browser / Edge runtime | **Partial** | `@peac/schema` (no-network), verifier UI, worker surfaces |
Expand Down
2 changes: 1 addition & 1 deletion docs/SECURITY-OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The current deprecation schedule is in [Deprecation Policy](DEPRECATION_POLICY.m
| Runtime | Status | Policy |
| ---------------------------- | ------------- | --------------------------------------------------------------- |
| Node.js 24 (Active LTS) | Required | Primary CI and development target |
| Node.js 22 (Maintenance LTS) | Compatibility | `engines.node >= 22.0.0` floor; security fixes backported |
| Node.js 22 (Maintenance LTS) | Compatibility | `engines.node >= 22.13.0` floor; security fixes backported |
| Node.js 25+ | Advisory | Forward-compat CI lane; no support guarantee |
| Go | Supported | Wire 0.2 core (issue and local verify); middleware experimental |
| Python | Not started | API-first via the reference verifier HTTP API |
Expand Down
2 changes: 1 addition & 1 deletion docs/SUPPORTED_ENVIRONMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
| Version | Status | Notes |
| ---------------------------- | ----------------- | --------------------------------------------------------------- |
| Node.js 24 (Active LTS) | **Required** | Canonical development and CI lane. `.node-version` pinned here. |
| Node.js 22 (Maintenance LTS) | **Compatibility** | `engines.node >= 22.0.0` floor. CI compat lane. |
| Node.js 22 (Maintenance LTS) | **Compatibility** | `engines.node >= 22.13.0` floor. CI compat lane. |
| Node.js 25+ | **Advisory** | Forward-compat CI lane. Not guaranteed. |

## Go
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/quickstart-agent-operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Verify a signed receipt in under 5 minutes. No server needed; verification is of

## Prerequisites

- Node.js >= 22.0.0
- Node.js >= 22.13.0

## 1. Install

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/quickstart-api-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Add signed receipts to your Express.js API in under 5 minutes. Every response wi

## Prerequisites

- Node.js >= 22.0.0
- Node.js >= 22.13.0
- An existing Express.js application (or create one below)

## 1. Install
Expand Down
2 changes: 1 addition & 1 deletion docs/releases/facts.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
"runtime": {
"node_canonical": "24.14.1",
"node_minimum": ">=22.0.0",
"node_minimum": ">=22.13.0",
"node_compat": "22",
"node_forward_compat": "25+"
},
Expand Down
34 changes: 32 additions & 2 deletions docs/specs/SECURITY-CONSIDERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,38 @@ MUST reject any other value.
downgrade attacks, and key-type mismatches. Ed25519 provides 128-bit security
with deterministic signatures (no per-signature randomness to leak).

**Implementation:** `@peac/crypto` exports `sign()` and `verify()` using
Ed25519 via the Node.js `node:crypto` module. No fallback algorithms exist.
**Implementation:** `@peac/crypto` exports `sign()` and `verify()`. Signing uses
`@noble/ed25519` (Web Crypto backed); verification uses the Web Crypto Subtle
API. No fallback algorithms exist.

**Verification profile.** "RFC 8032" is not a single accept/reject predicate:
Ed25519 implementations differ on small-order public keys and on cofactored
versus cofactorless verification. PEAC pins one predicate so independent
verifiers reach the same decision on every input:

- public key MUST be 32 bytes and signature MUST be 64 bytes;
- small-order public keys (the 8-torsion subgroup) MUST be rejected;
- a non-reduced signature scalar (`S >= L`, the Ed25519 group order) MUST be
rejected;
- signature verification MUST be cofactorless.

The TypeScript reference verifier implements this on the Web Crypto Subtle API
(cofactorless) and fails closed if the runtime cannot provide the primitive: it
throws rather than silently substituting a different predicate. The Go reference
verifier (`crypto/ed25519`, cofactorless) applies the identical admissibility
checks. The two implementations are cross-checked against a shared edge-vector
corpus at `specs/conformance/parity-corpus/ed25519-peac-profile/` (the
ed25519-speccheck cases plus canonical positives); both reach identical
accept/reject decisions on every vector. Canonical signatures produced by
`sign()` are unaffected; only malformed or non-canonical encodings are rejected.

**Runtime requirement.** PEAC Ed25519 verification requires a runtime with
stable WebCrypto Ed25519 support. Node.js users MUST use Node v22.13.0 or newer
(WebCrypto Ed25519 is also stable on v20.19.3+ and v23.5.0+); the `@peac/crypto`
package declares `engines.node >= 22.13.0` accordingly. Browsers MUST have
WebCrypto Ed25519 support. Unsupported runtimes fail closed (the verifier throws
`Ed25519RuntimeError`) and MUST NOT fall back to a different Ed25519
verification predicate.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/WIRE-0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1230,7 +1230,7 @@ Implementations MUST perform steps in the order specified. A step that produces
### 19.2 Validation Steps

**Step 1: Verify JWS signature.**
Decode the compact JWS and verify the Ed25519 signature against the provided `publicKey`. The `alg` header parameter MUST be `EdDSA`. JOSE hardening checks are applied at this step: reject embedded keys (`jwk`, `x5c`, `x5u`, `jku`), reject `crit`, reject `b64: false`, reject `zip`, require `kid` (1 to 256 characters). Failure produces the corresponding `E_JWS_*` or `E_INVALID_SIGNATURE` error code.
Decode the compact JWS and verify the Ed25519 signature against the provided `publicKey`. The `alg` header parameter MUST be `EdDSA`. Signature verification follows the PEAC Ed25519 verification profile (cofactorless; reject small-order public keys; reject a non-reduced scalar `S >= L`; 32-byte key, 64-byte signature), defined in `SECURITY-CONSIDERATIONS.md` Section 1 and cross-checked across the TypeScript and Go reference verifiers by the corpus at `specs/conformance/parity-corpus/ed25519-peac-profile/`. JOSE hardening checks are applied at this step: reject embedded keys (`jwk`, `x5c`, `x5u`, `jku`), reject `crit`, reject `b64: false`, reject `zip`, require `kid` (1 to 256 characters). Failure produces the corresponding `E_JWS_*` or `E_INVALID_SIGNATURE` error code.

**Step 2: Apply strictness routing.**
Examine the decoded `typ` header parameter:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
"yaml": "^2.3.4"
},
"engines": {
"node": ">=22.0.0",
"node": ">=22.13.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.15.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/audit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"author": "PEAC Protocol Contributors",
"license": "Apache-2.0",
"engines": {
"node": ">=22.0.0"
"node": ">=22.13.0"
},
"scripts": {
"prebuild": "rm -rf dist",
Expand Down
Loading
Loading