From ba668707bd336e0d9b8f3799dee4b1ecebc8ca05 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:01:15 +0100 Subject: [PATCH 1/3] test: prove label crypto interoperability --- .../gate-0/crypto-interop.md | 86 ++++ .../gate-0/fixtures/crypto/p256-label-v1.json | 43 ++ .../plugin-registry-labelling-service/spec.md | 8 +- .../test/label-crypto-interop.test.ts | 482 ++++++++++++++++++ .../tests/label-crypto-vector.test.ts | 49 ++ 5 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md create mode 100644 .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json create mode 100644 apps/aggregator/test/label-crypto-interop.test.ts create mode 100644 packages/atproto-test-utils/tests/label-crypto-vector.test.ts diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md new file mode 100644 index 0000000000..dccb538d47 --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md @@ -0,0 +1,86 @@ +# Gate 0: Label crypto interoperability + +## Decision + +Use P-256 (`secp256r1`) with `@atcute/cbor` 2.3.3 and `@atcute/crypto` 2.4.1. The production secret format is one unpadded base64url-encoded 32-byte P-256 private scalar. Decode it and pass the raw bytes to `P256PrivateKey.importRaw`. Publish the compressed public key as an ATProto P-256 multikey in the issuer DID document's exact `#atproto_label` verification method. + +P-256 is selected over k256 because the atcute implementation uses the WebCrypto `P-256`/`SHA-256` implementation available in workerd. Signatures are low-S, 64-byte IEEE-P1363 values. Do not accept DER signatures. A startup check must derive the public multikey from the secret and require it to equal the DID/configured key before issuance starts. + +The high-level atcute `P256PrivateKey.sign(data)` and `P256PublicKey.verify(sig, data)` APIs perform SHA-256 as part of WebCrypto ECDSA. They therefore receive canonical CBOR bytes, not an already computed digest. The fixture records the intermediate SHA-256 digest required by the protocol; passing that digest to these APIs would hash twice. + +## Signed shape + +The retained workerd harness reconstructs a label from this unsigned v1 allowlist only: + +`ver`, `src`, `uri`, `cid`, `val`, `neg`, `cts`, `exp` + +It requires `ver: 1`; validates `src` as a DID, `uri` as an ATProto generic URI, `cid` with the ATProto CID validator, and `cts`/`exp` as RFC3339 datetimes with valid calendar dates; requires `val` to contain 1 to 128 UTF-8 bytes; and rejects `sig`, `$type`, and every unknown field before DRISL encoding. An input `neg: false` is omitted from the reconstructed object. `sig` is attached only after signing. The signer must never accept an arbitrary object. + +## Key decoding and DID resolution + +Private scalar decoding is a signer boundary, not a permissive utility. It accepts only the unpadded base64url alphabet, requires canonical re-encoding to exactly the supplied string, requires exactly 32 decoded bytes, interprets them as an unsigned big-endian integer, and requires `1 <= d < n` for the P-256 order `n`. Padded, malformed, non-canonical, wrong-length, zero, and order-or-larger values fail before `P256PrivateKey.importRaw`. + +Resolve the issuer DID document and normalize relative `#atproto_label` and fully qualified `${issuerDid}#atproto_label` IDs to the same logical method. Either form is valid alone; any duplicate across relative or fully qualified forms fails independent of order. The DID document ID and method controller must equal the issuer DID. The method must use `type: "Multikey"`, include `publicKeyMultibase`, identify the P-256 codec, and contain an importable 33-byte compressed P-256 point. At startup, derive the signer public multikey from the configured private scalar and require exact equality with the resolved method's `publicKeyMultibase`; a valid but different P-256 key fails. A duplicate method, legacy key type, wrong controller, k256 key, malformed point, missing label key, or document containing only `#atproto` fails. There is no fallback to another verification method. + +## Independent source + +The independent implementation is Bluesky's `@atproto/crypto` 0.4.5 from , dual licensed MIT or Apache-2.0. It is independent of the Mary-ext atcute implementation and uses Noble Curves rather than WebCrypto for P-256. + +The committed fixture is reproducible from test-only private scalar `0x01`: + +- `@atcute/cbor` produced the canonical payload bytes. +- `@atproto/crypto` produced the deterministic `atprotoReferenceHex` signature; workerd/atcute verifies it. +- Actual workerd `@atcute/crypto` produced `atcuteWebcryptoHex`; the retained Node test calls `@atproto/crypto.verifySignature` over it. +- Both implementations derived the same compressed public key and P-256 `did:key` multikey. + +The vector is test material, not a usable deployment key. Its canonical CBOR bytes, SHA-256 hash, two signatures, raw compressed public key, public multikey, and reproducible test private scalar are in `fixtures/crypto/p256-label-v1.json` for W1.4. + +## Commands and results + +Run from the repository root: + +```sh +pnpm --filter @emdash-cms/atproto-test-utils exec vitest run tests/label-crypto-vector.test.ts +pnpm --filter @emdash-cms/aggregator exec vitest run test/label-crypto-interop.test.ts +``` + +The first command is the retained Node generator/assertion. It reproduces the canonical CBOR bytes, SHA-256 hash, public key forms, and deterministic `@atproto/crypto` reference signature from the test scalar, then independently verifies the retained atcute workerd signature. No fixture boolean stands in for execution. + +The second command runs the selected atcute API in workerd. It proves local signing and verification, verifies the independently generated signature, and exercises all signer/key rejection boundaries. Both commands must pass whenever the vector changes. + +## Rejections proved + +- Unknown fields, including `sig`, `$type`, and a representative arbitrary field, are rejected before encoding. +- Unsupported versions and malformed required/optional field types are rejected before encoding. +- Invalid DID, URI, CID, RFC3339/calendar datetime, empty/oversized UTF-8 value, and explicit false negation handling are covered. +- Private scalar decoding rejects malformed, padded, non-canonical, wrong-length, zero, and out-of-range inputs. +- DID method resolution accepts either relative or fully qualified label-key IDs alone, rejects mixed-form and same-form duplicates, and rejects missing IDs, wrong type/controller/codec, absent multikey data, signer-key mismatch, and fallback to `#atproto`. +- Passing the recorded protocol digest to `sign` produces a double-hashed signature that does not verify against canonical CBOR. +- The correct signature fails under a different P-256 key. +- The correct signature fails for a changed label payload. +- The correct signature fails against malformed non-DRISL payload bytes. +- A bit-flipped signature fails. +- Truncated, oversized, high-S equivalent, and ASN.1 DER signatures fail; only low-S compact 64-byte signatures are accepted. + +## Key lifecycle contract + +### Routine rotation + +1. Pause the issuance boundary before creating any label sequence that would need a signature. Assessment decisions may queue, but no sequence is allocated, stored, or broadcast while paused. +2. Generate a fresh P-256 key through the approved ceremony, store its base64url private scalar as a new Secrets Store version, derive its public multikey, and record a non-secret key-version identifier. Keep the old private key available for rollback until rotation is confirmed. +3. Update the issuer DID document's `#atproto_label` method to the new public multikey and verify the resolved document from outside the deployment. +4. Activate the matching secret version only after the DID update is observable. The startup/runtime guard must refuse issuance if the derived key does not match the resolved/configured public key. +5. Resume issuance. Persist the key-version identifier with every signature. Monitor signing failures, DID mismatch, and downstream verification failures. +6. On query, lazily re-sign labels carrying an old key version with the current key without changing `cts`, then persist the replacement signature/key version. Event-stream backfill may retain old signatures as allowed by the ATProto label specification. +7. Subscribers retry one failed verification after re-resolving the DID. They recover missed labels from their last durably accepted cursor; rotation alone does not reset sequence history. + +### Suspected or confirmed compromise + +1. Pause issuance immediately at the same pre-sequence boundary. Do not use the compromised key for rollback or historical re-signing. +2. Replace or remove the compromised `#atproto_label` key in the DID document, publish an incident notice through the policy endpoint, establish the last trusted sequence/time where possible, and deploy a fresh key only after its DID publication is observable. +3. Preserve compromised signatures and their key-version mapping for forensics, but never treat them as currently valid merely because they once verified. +4. Reissue the current effective label set with new signed events. Do not rewrite compromised history into an apparently continuous trusted history. +5. Declare a safe replay cursor when one can be established. Otherwise require subscribers to clear derived state for this labeller and replay the retained stream from cursor `0` after trustworthy history/current-state recovery is available. +6. Subscribers re-resolve the DID on signature failure, reject labels that still fail, and alert rather than falling back to `#atproto` or any other DID key. + +W3.7 owns implementing this state machine and key-version persistence. W11.4 owns the operator runbook, ceremony/custody details, external DID verification, monitoring, and subscriber communications. diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json b/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json new file mode 100644 index 0000000000..3ae70f864d --- /dev/null +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json @@ -0,0 +1,43 @@ +{ + "description": "ATProto label v1 P-256 cross-implementation interoperability vector", + "label": { + "ver": 1, + "src": "did:web:labeller.test", + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/com.emdashcms.experimental.package.release/crypto-interop", + "cid": "bafyreigh2akiscaildc4mscz4uzpcbap5jxg26eecmrf6cmnvkzkjmoixe", + "val": "assessment-passed", + "cts": "2026-07-10T12:34:56.000Z", + "exp": "2026-08-09T12:34:56.000Z" + }, + "canonicalCborHex": "a763636964783b62616679726569676832616b69736361696c6463346d73637a34757a7063626170356a786732366565636d726636636d6e766b7a6b6a6d6f697865636374737818323032362d30372d31305431323a33343a35362e3030305a636578707818323032362d30382d30395431323a33343a35362e3030305a63737263756469643a7765623a6c6162656c6c65722e7465737463757269785f61743a2f2f6469643a706c633a7a373269376864796e6d6b367232327a32376836747675722f636f6d2e656d64617368636d732e6578706572696d656e74616c2e7061636b6167652e72656c656173652f63727970746f2d696e7465726f706376616c716173736573736d656e742d7061737365646376657201", + "sha256Hex": "4ac798323da266b2a68caf3caf769a930bafc4403dd4f5921aeb33d2a1a26631", + "key": { + "curve": "P-256", + "publicKeyDid": "did:key:zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + "publicKeyMultikey": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + "publicKeyRawHex": "036b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296", + "testPrivateKeyRawBase64Url": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE" + }, + "didDocument": { + "id": "did:web:labeller.test", + "verificationMethod": [ + { + "id": "did:web:labeller.test#atproto_label", + "type": "Multikey", + "controller": "did:web:labeller.test", + "publicKeyMultibase": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ" + } + ] + }, + "signatures": { + "atcuteWebcryptoHex": "884bc6f1dabf7da026d8c7fcf20dfb0395eb5e9dce09958f54e00b64644af7950244e3d1ada5ef60d0eec8e6b3e0721fad27b555b48291893acc9ba6db4238de", + "atprotoReferenceHex": "183823d83ada5cb73cf29ada84cadeb4ffe76b6f5c65245f820fbe163ab4482b5d7e9f9bd7bf206dcebefe3877c7f547d34279235fe4efde497bbb5655685200" + }, + "provenance": { + "payloadCodec": "@atcute/cbor 2.3.3", + "workerdSigner": "@atcute/crypto 2.4.1 P256PrivateKey.sign", + "independentSignerAndVerifier": "@atproto/crypto 0.4.5 P256Keypair.sign/verifySignature", + "independentSource": "https://github.com/bluesky-social/atproto/tree/main/packages/crypto", + "independentLicense": "MIT OR Apache-2.0" + } +} diff --git a/.opencode/plans/plugin-registry-labelling-service/spec.md b/.opencode/plans/plugin-registry-labelling-service/spec.md index 23b17893ec..38278e70e5 100644 --- a/.opencode/plans/plugin-registry-labelling-service/spec.md +++ b/.opencode/plans/plugin-registry-labelling-service/spec.md @@ -826,12 +826,16 @@ Signing follows the ATProto label specification exactly: 1. Construct only the allowed v1 label fields, excluding `sig` and `$type`. 2. Encode with deterministic DRISL CBOR. -3. SHA-256 the canonical bytes. -4. Sign the raw hash using the private key corresponding to `#atproto_label`. +3. At the protocol level, SHA-256 the canonical bytes and sign that digest. +4. With the selected high-level `@atcute/crypto` API, pass the canonical CBOR bytes directly to `P256PrivateKey.sign`, and pass those same bytes to `P256PublicKey.verify`. WebCrypto ECDSA performs the protocol SHA-256 operation internally. Never pass the precomputed digest to either high-level API, because that hashes the digest again and produces a non-interoperable signature. 5. Store signature bytes and signing key ID. No generic object-signing helper accepts arbitrary fields. The issuer takes a typed internal proposal and reconstructs the complete label object itself. +The issuer normalizes verification method IDs before selection: relative `#atproto_label` and fully qualified `${issuerDid}#atproto_label` identify the same logical method, and either form is valid alone. Exactly one logical label key must exist; duplicates across either or both forms are invalid regardless of order. The method must have `type: "Multikey"`, `controller` equal to the issuer DID, and a `publicKeyMultibase` using the P-256 multikey codec. At startup, the public multikey derived from the configured private scalar must exactly equal the resolved method's `publicKeyMultibase`; a structurally valid different P-256 key is a fatal mismatch. Missing, duplicate, differently controlled, legacy-typed, or non-P-256 methods are invalid. The issuer and subscribers never fall back to `#atproto` or another DID key. + +The private secret is an unpadded, canonically encoded base64url 32-byte P-256 scalar. Decoding rejects non-base64url characters, padding, non-canonical encodings, lengths other than 32 bytes, zero, and values greater than or equal to the P-256 group order. + The private key is stored in Cloudflare Secrets Store. A plain Worker secret is acceptable only for initial local/staging deployment, not production launch. ### 15.1 Subscriber Durable Object diff --git a/apps/aggregator/test/label-crypto-interop.test.ts b/apps/aggregator/test/label-crypto-interop.test.ts new file mode 100644 index 0000000000..81995889f9 --- /dev/null +++ b/apps/aggregator/test/label-crypto-interop.test.ts @@ -0,0 +1,482 @@ +import { encode } from "@atcute/cbor"; +import { P256PrivateKey, P256PublicKey, parsePublicMultikey } from "@atcute/crypto"; +import { isCid, isDatetime, isDid, isGenericUri } from "@atcute/lexicons/syntax"; +import { fromBase64Url, toBase64Url } from "@atcute/multibase"; +import { describe, expect, it } from "vitest"; + +import vector from "../../../.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json" with { type: "json" }; + +const ALLOWED_UNSIGNED_V1_FIELDS = new Set([ + "ver", + "src", + "uri", + "cid", + "val", + "neg", + "cts", + "exp", +]); + +const P256_ORDER = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n; + +interface UnsignedLabelV1 { + ver: 1; + src: string; + uri: string; + cid?: string; + val: string; + neg?: boolean; + cts: string; + exp?: string; +} + +function constructUnsignedLabelV1(input: Record): UnsignedLabelV1 { + for (const field of Object.keys(input)) { + if (!ALLOWED_UNSIGNED_V1_FIELDS.has(field)) { + throw new TypeError(`unexpected label field: ${field}`); + } + } + + if (input.ver !== 1) throw new TypeError("label ver must be 1"); + const src = requireDid(input.src); + const uri = requireUri(input.uri); + const cid = optionalCid(input.cid); + const val = requireLabelValue(input.val); + const neg = optionalBoolean(input.neg, "neg"); + const cts = requireDatetime(input.cts, "cts"); + const exp = optionalDatetime(input.exp, "exp"); + + return { + ver: 1, + src, + uri, + ...(cid === undefined ? {} : { cid }), + val, + ...(neg === true ? { neg: true } : {}), + cts, + ...(exp === undefined ? {} : { exp }), + }; +} + +function requireDid(value: unknown): string { + if (!isDid(value)) throw new TypeError("label src must be a DID"); + return value; +} + +function requireUri(value: unknown): string { + if (!isGenericUri(value)) throw new TypeError("label uri must be a URI"); + return value; +} + +function optionalCid(value: unknown): string | undefined { + if (value !== undefined && !isCid(value)) throw new TypeError("label cid must be a CID"); + return value; +} + +function requireLabelValue(value: unknown): string { + if (typeof value !== "string") throw new TypeError("label val must be a string"); + const length = new TextEncoder().encode(value).length; + if (length < 1 || length > 128) + throw new TypeError("label val must contain 1 to 128 UTF-8 bytes"); + return value; +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value !== undefined && typeof value !== "boolean") { + throw new TypeError(`label ${field} must be a boolean`); + } + return value; +} + +function requireDatetime(value: unknown, field: string): string { + if (!isDatetime(value) || !hasValidCalendarDate(value)) { + throw new TypeError(`label ${field} must be an RFC3339 datetime`); + } + return value; +} + +function optionalDatetime(value: unknown, field: string): string | undefined { + if (value !== undefined && (!isDatetime(value) || !hasValidCalendarDate(value))) { + throw new TypeError(`label ${field} must be an RFC3339 datetime`); + } + return value; +} + +function hasValidCalendarDate(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})T/.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + return day <= new Date(Date.UTC(year, month, 0)).getUTCDate() && !Number.isNaN(Date.parse(value)); +} + +function decodePrivateScalar(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(value)) { + throw new TypeError("private scalar must be unpadded base64url"); + } + + const bytes = fromBase64Url(value); + if (toBase64Url(bytes) !== value) + throw new TypeError("private scalar must use canonical base64url"); + if (bytes.length !== 32) throw new TypeError("private scalar must be exactly 32 bytes"); + + const scalar = BigInt(`0x${toHex(bytes)}`); + if (scalar < 1n || scalar >= P256_ORDER) + throw new TypeError("private scalar is outside P-256 range"); + return bytes; +} + +interface DidVerificationMethod { + id: string; + type: string; + controller: string; + publicKeyMultibase: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function getLabelVerificationMethod( + document: unknown, + issuerDid: string, + signerPublicMultikey: string, +): Promise { + if (!isDid(issuerDid)) throw new TypeError("issuer must be a DID"); + if (!isRecord(document)) throw new TypeError("invalid DID document"); + if (document.id !== issuerDid) throw new TypeError("DID document does not belong to issuer"); + if (!Array.isArray(document.verificationMethod)) { + throw new TypeError("DID document verificationMethod must be an array"); + } + const id = `${issuerDid}#atproto_label`; + const matches = document.verificationMethod.filter( + (method): method is Record => + isRecord(method) && (method.id === "#atproto_label" || method.id === id), + ); + if (matches.length !== 1) + throw new TypeError("DID document must contain exactly one #atproto_label key"); + + const method = matches[0]; + if (!method) throw new TypeError("missing #atproto_label key"); + if (method.type !== "Multikey") throw new TypeError("#atproto_label must use Multikey"); + if (method.controller !== issuerDid) + throw new TypeError("#atproto_label controller must equal issuer DID"); + if (typeof method.publicKeyMultibase !== "string") { + throw new TypeError("#atproto_label must contain publicKeyMultibase"); + } + const parsed = parsePublicMultikey(method.publicKeyMultibase); + if (parsed.type !== "p256") + throw new TypeError("#atproto_label must use the P-256 multikey codec"); + if ( + parsed.publicKeyBytes.length !== 33 || + ![0x02, 0x03].includes(parsed.publicKeyBytes[0] ?? 0) + ) { + throw new TypeError("#atproto_label must contain a compressed P-256 public key"); + } + await P256PublicKey.importRaw(parsed.publicKeyBytes); + if (method.publicKeyMultibase !== signerPublicMultikey) { + throw new TypeError("#atproto_label public key must match the signer-derived multikey"); + } + return { + id, + type: method.type, + controller: method.controller, + publicKeyMultibase: method.publicKeyMultibase, + }; +} + +function fromHex(value: string): Uint8Array { + if (!/^(?:[0-9a-f]{2})+$/i.test(value)) throw new TypeError("invalid hex bytes"); + return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function highSEquivalent(signature: Uint8Array): Uint8Array { + const highS = P256_ORDER - BigInt(`0x${toHex(signature.slice(32))}`); + const result = signature.slice(); + result.set(fromHex(highS.toString(16).padStart(64, "0")), 32); + return result; +} + +function compactToDer(signature: Uint8Array): Uint8Array { + const integer = (value: Uint8Array): Uint8Array => { + let offset = 0; + while (offset < value.length - 1 && value[offset] === 0) offset++; + const bytes = value.slice(offset); + const needsPositivePrefix = ((bytes[0] ?? 0) & 0x80) !== 0; + return Uint8Array.of( + 0x02, + bytes.length + (needsPositivePrefix ? 1 : 0), + ...(needsPositivePrefix ? [0] : []), + ...bytes, + ); + }; + const r = integer(signature.slice(0, 32)); + const s = integer(signature.slice(32)); + return Uint8Array.of(0x30, r.length + s.length, ...r, ...s); +} + +describe("ATProto label v1 crypto interoperability", () => { + it("constructs deterministic DRISL bytes and SHA-256 hash", async () => { + const label = constructUnsignedLabelV1(vector.label); + const canonicalBytes = encode(label); + + expect(toHex(canonicalBytes)).toBe(vector.canonicalCborHex); + expect(toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalBytes)))).toBe( + vector.sha256Hex, + ); + }); + + it("signs and verifies locally in workerd with P-256", async () => { + const label = constructUnsignedLabelV1(vector.label); + const canonicalBytes = encode(label); + const privateKey = await P256PrivateKey.importRaw( + decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url), + ); + const signature = await privateKey.sign(canonicalBytes); + + expect(signature).toHaveLength(64); + expect(await privateKey.exportPublicKey("did")).toBe(vector.key.publicKeyDid); + expect(await privateKey.exportPublicKey("multikey")).toBe(vector.key.publicKeyMultikey); + expect(await privateKey.verify(signature, canonicalBytes)).toBe(true); + }); + + it("does not double-hash by passing the protocol digest to the high-level API", async () => { + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", canonicalBytes)); + const privateKey = await P256PrivateKey.importRaw( + decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url), + ); + const doubleHashedSignature = await privateKey.sign(digest); + + expect(await privateKey.verify(doubleHashedSignature, digest)).toBe(true); + expect(await privateKey.verify(doubleHashedSignature, canonicalBytes)).toBe(false); + }); + + it("verifies the independently signed @atproto/crypto vector", async () => { + const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + + expect( + await publicKey.verify(fromHex(vector.signatures.atprotoReferenceHex), canonicalBytes), + ).toBe(true); + }); + + it("retains the workerd signature verified by the independent implementation", async () => { + const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + + expect( + await publicKey.verify(fromHex(vector.signatures.atcuteWebcryptoHex), canonicalBytes), + ).toBe(true); + }); + + it("requires the exact P-256 #atproto_label DID verification method", async () => { + const method = await getLabelVerificationMethod( + vector.didDocument, + vector.label.src, + vector.key.publicKeyMultikey, + ); + expect(method.id).toBe(`${vector.label.src}#atproto_label`); + expect(method.publicKeyMultibase).toBe(vector.key.publicKeyMultikey); + + const relativeMethod = await getLabelVerificationMethod( + { ...vector.didDocument, verificationMethod: [{ ...method, id: "#atproto_label" }] }, + vector.label.src, + vector.key.publicKeyMultikey, + ); + expect(relativeMethod.id).toBe(`${vector.label.src}#atproto_label`); + expect(relativeMethod.publicKeyMultibase).toBe(vector.key.publicKeyMultikey); + const relative = { ...method, id: "#atproto_label" }; + for (const verificationMethod of [ + [method, relative], + [relative, method], + [method, method], + ]) { + await expect( + getLabelVerificationMethod( + { ...vector.didDocument, verificationMethod }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("exactly one #atproto_label key"); + } + await expect( + getLabelVerificationMethod( + { ...vector.didDocument, verificationMethod: [{ ...method, type: "JsonWebKey2020" }] }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must use Multikey"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, controller: "did:web:other.test" }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("controller must equal issuer DID"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, publicKeyMultibase: undefined as unknown as string }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must contain publicKeyMultibase"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [ + { ...method, publicKeyMultibase: "zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc" }, + ], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must use the P-256 multikey codec"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, id: `${vector.label.src}#atproto` }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("exactly one #atproto_label key"); + + const otherKey = await P256PrivateKey.importRaw( + fromHex("0000000000000000000000000000000000000000000000000000000000000002"), + ); + const otherMultikey = await otherKey.exportPublicKey("multikey"); + await expect( + getLabelVerificationMethod( + { + ...vector.didDocument, + verificationMethod: [{ ...method, publicKeyMultibase: otherMultikey }], + }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("must match the signer-derived multikey"); + await expect( + getLabelVerificationMethod( + vector.didDocument, + "did:web:other.test", + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("DID document does not belong to issuer"); + await expect( + getLabelVerificationMethod( + { id: vector.label.src }, + vector.label.src, + vector.key.publicKeyMultikey, + ), + ).rejects.toThrow("verificationMethod must be an array"); + await expect( + getLabelVerificationMethod(null, vector.label.src, vector.key.publicKeyMultikey), + ).rejects.toThrow("invalid DID document"); + }); + + it.each(["sig", "$type", "purpose"])("rejects the extra %s field before encoding", (field) => { + expect(() => constructUnsignedLabelV1({ ...vector.label, [field]: "not-allowed" })).toThrow( + `unexpected label field: ${field}`, + ); + }); + + it("rejects malformed allowed fields before encoding", () => { + expect(() => constructUnsignedLabelV1({ ...vector.label, ver: 2 })).toThrow( + "label ver must be 1", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, src: "not-a-did" })).toThrow( + "label src must be a DID", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, uri: "not a uri" })).toThrow( + "label uri must be a URI", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, cid: "not-a-cid" })).toThrow( + "label cid must be a CID", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, val: "" })).toThrow( + "1 to 128 UTF-8 bytes", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, val: "é".repeat(65) })).toThrow( + "1 to 128 UTF-8 bytes", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, neg: "true" })).toThrow( + "label neg must be a boolean", + ); + expect(() => constructUnsignedLabelV1({ ...vector.label, cts: "2026-07-10" })).toThrow( + "label cts must be an RFC3339 datetime", + ); + expect(() => + constructUnsignedLabelV1({ ...vector.label, cts: "2026-02-31T12:34:56Z" }), + ).toThrow("label cts must be an RFC3339 datetime"); + expect(() => constructUnsignedLabelV1({ ...vector.label, exp: "tomorrow" })).toThrow( + "label exp must be an RFC3339 datetime", + ); + + const withFalse = constructUnsignedLabelV1({ ...vector.label, neg: false }); + expect(withFalse).not.toHaveProperty("neg"); + expect(encode(withFalse)).toEqual(encode(constructUnsignedLabelV1(vector.label))); + }); + + it("strictly decodes an in-range canonical 32-byte private scalar", () => { + expect(toBase64Url(decodePrivateScalar(vector.key.testPrivateKeyRawBase64Url))).toBe( + vector.key.testPrivateKeyRawBase64Url, + ); + expect(() => decodePrivateScalar(`${vector.key.testPrivateKeyRawBase64Url}=`)).toThrow( + "unpadded base64url", + ); + expect(() => decodePrivateScalar("not+base64url")).toThrow("unpadded base64url"); + expect(() => + decodePrivateScalar(`${vector.key.testPrivateKeyRawBase64Url.slice(0, -1)}F`), + ).toThrow("canonical base64url"); + expect(() => decodePrivateScalar(toBase64Url(new Uint8Array(31).fill(1)))).toThrow( + "exactly 32 bytes", + ); + expect(() => decodePrivateScalar(toBase64Url(new Uint8Array(32)))).toThrow( + "outside P-256 range", + ); + expect(() => decodePrivateScalar(toBase64Url(fromHex(P256_ORDER.toString(16))))).toThrow( + "outside P-256 range", + ); + }); + + it("rejects a wrong key, changed payload, and malformed signatures", async () => { + const canonicalBytes = encode(constructUnsignedLabelV1(vector.label)); + const signature = fromHex(vector.signatures.atprotoReferenceHex); + const wrongKey = await P256PrivateKey.importRaw( + fromHex("0000000000000000000000000000000000000000000000000000000000000002"), + ); + const publicKey = await P256PublicKey.importRaw(fromHex(vector.key.publicKeyRawHex)); + const changedBytes = encode( + constructUnsignedLabelV1({ ...vector.label, val: "assessment-warning" }), + ); + const changedSignature = signature.slice(); + changedSignature[0] = (changedSignature[0] ?? 0) ^ 0x01; + const highS = highSEquivalent(signature); + const der = compactToDer(signature); + + expect(await wrongKey.verify(signature, canonicalBytes)).toBe(false); + expect(await publicKey.verify(signature, changedBytes)).toBe(false); + expect(await publicKey.verify(signature, Uint8Array.of(0xff))).toBe(false); + expect(await publicKey.verify(changedSignature, canonicalBytes)).toBe(false); + expect(highS).toHaveLength(64); + expect(await publicKey.verify(highS, canonicalBytes)).toBe(false); + expect(der.length).toBeGreaterThan(64); + expect(await publicKey.verify(der, canonicalBytes)).toBe(false); + expect(await publicKey.verify(signature.slice(0, 63), canonicalBytes)).toBe(false); + expect(await publicKey.verify(new Uint8Array(65), canonicalBytes)).toBe(false); + }); +}); diff --git a/packages/atproto-test-utils/tests/label-crypto-vector.test.ts b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts new file mode 100644 index 0000000000..44ffc4f7c5 --- /dev/null +++ b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; + +import { encode } from "@atcute/cbor"; +import { fromBase64Url } from "@atcute/multibase"; +import { P256Keypair, verifySignature } from "@atproto/crypto"; +import { describe, expect, it } from "vitest"; + +import vector from "../../../.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json" with { type: "json" }; + +function fromHex(value: string): Uint8Array { + return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function toHex(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +describe("ATProto label crypto vector generation", () => { + it("reproduces the reference vector and independently verifies the workerd signature", async () => { + const canonicalBytes = encode(vector.label); + const keypair = await P256Keypair.import(fromBase64Url(vector.key.testPrivateKeyRawBase64Url), { + exportable: true, + }); + const referenceSignature = await keypair.sign(canonicalBytes); + + expect(toHex(canonicalBytes)).toBe(vector.canonicalCborHex); + expect(createHash("sha256").update(canonicalBytes).digest("hex")).toBe(vector.sha256Hex); + expect(keypair.did()).toBe(vector.key.publicKeyDid); + expect(keypair.did().slice("did:key:".length)).toBe(vector.key.publicKeyMultikey); + expect(vector.didDocument.verificationMethod[0]?.publicKeyMultibase).toBe( + keypair.did().slice("did:key:".length), + ); + expect(toHex(keypair.publicKeyBytes())).toBe(vector.key.publicKeyRawHex); + expect(toHex(referenceSignature)).toBe(vector.signatures.atprotoReferenceHex); + expect(await verifySignature(vector.key.publicKeyDid, canonicalBytes, referenceSignature)).toBe( + true, + ); + expect( + await verifySignature( + vector.key.publicKeyDid, + canonicalBytes, + fromHex(vector.signatures.atcuteWebcryptoHex), + ), + ).toBe(true); + + const digest = createHash("sha256").update(canonicalBytes).digest(); + expect(await verifySignature(vector.key.publicKeyDid, digest, referenceSignature)).toBe(false); + }); +}); From 8e2515259c89666077fcb1ddaf09251ef588b754 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:14:59 +0100 Subject: [PATCH 2/3] test: keep crypto vector with retained tests --- .../plugin-registry-labelling-service/gate-0/crypto-interop.md | 2 +- apps/aggregator/test/label-crypto-interop.test.ts | 2 +- .../atproto-test-utils/tests/fixtures}/p256-label-v1.json | 0 packages/atproto-test-utils/tests/label-crypto-vector.test.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename {.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto => packages/atproto-test-utils/tests/fixtures}/p256-label-v1.json (100%) diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md index dccb538d47..869bd92103 100644 --- a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md @@ -33,7 +33,7 @@ The committed fixture is reproducible from test-only private scalar `0x01`: - Actual workerd `@atcute/crypto` produced `atcuteWebcryptoHex`; the retained Node test calls `@atproto/crypto.verifySignature` over it. - Both implementations derived the same compressed public key and P-256 `did:key` multikey. -The vector is test material, not a usable deployment key. Its canonical CBOR bytes, SHA-256 hash, two signatures, raw compressed public key, public multikey, and reproducible test private scalar are in `fixtures/crypto/p256-label-v1.json` for W1.4. +The vector is test material, not a usable deployment key. Its canonical CBOR bytes, SHA-256 hash, two signatures, raw compressed public key, public multikey, and reproducible test private scalar are retained at `packages/atproto-test-utils/tests/fixtures/p256-label-v1.json` for W1.4. This Gate 0 plan document is explanatory only; retained tests and production code must not import or otherwise depend on files under `.opencode/plans`. ## Commands and results diff --git a/apps/aggregator/test/label-crypto-interop.test.ts b/apps/aggregator/test/label-crypto-interop.test.ts index 81995889f9..f44d747a01 100644 --- a/apps/aggregator/test/label-crypto-interop.test.ts +++ b/apps/aggregator/test/label-crypto-interop.test.ts @@ -4,7 +4,7 @@ import { isCid, isDatetime, isDid, isGenericUri } from "@atcute/lexicons/syntax" import { fromBase64Url, toBase64Url } from "@atcute/multibase"; import { describe, expect, it } from "vitest"; -import vector from "../../../.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json" with { type: "json" }; +import vector from "../../../packages/atproto-test-utils/tests/fixtures/p256-label-v1.json" with { type: "json" }; const ALLOWED_UNSIGNED_V1_FIELDS = new Set([ "ver", diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json b/packages/atproto-test-utils/tests/fixtures/p256-label-v1.json similarity index 100% rename from .opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json rename to packages/atproto-test-utils/tests/fixtures/p256-label-v1.json diff --git a/packages/atproto-test-utils/tests/label-crypto-vector.test.ts b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts index 44ffc4f7c5..c8f4a26e80 100644 --- a/packages/atproto-test-utils/tests/label-crypto-vector.test.ts +++ b/packages/atproto-test-utils/tests/label-crypto-vector.test.ts @@ -5,7 +5,7 @@ import { fromBase64Url } from "@atcute/multibase"; import { P256Keypair, verifySignature } from "@atproto/crypto"; import { describe, expect, it } from "vitest"; -import vector from "../../../.opencode/plans/plugin-registry-labelling-service/gate-0/fixtures/crypto/p256-label-v1.json" with { type: "json" }; +import vector from "./fixtures/p256-label-v1.json" with { type: "json" }; function fromHex(value: string): Uint8Array { return Uint8Array.from(value.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); From aa648b97b4fc3ed10280eebbe40a364b6c265147 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 13:20:33 +0100 Subject: [PATCH 3/3] docs: record crypto ratification --- .../plugin-registry-labelling-service/gate-0/crypto-interop.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md index 869bd92103..9bf67d75e1 100644 --- a/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md +++ b/.opencode/plans/plugin-registry-labelling-service/gate-0/crypto-interop.md @@ -1,5 +1,7 @@ # Gate 0: Label crypto interoperability +Status: P-256 interoperability and key-lifecycle contract ratified on 2026-07-10. + ## Decision Use P-256 (`secp256r1`) with `@atcute/cbor` 2.3.3 and `@atcute/crypto` 2.4.1. The production secret format is one unpadded base64url-encoded 32-byte P-256 private scalar. Decode it and pass the raw bytes to `P256PrivateKey.importRaw`. Publish the compressed public key as an ATProto P-256 multikey in the issuer DID document's exact `#atproto_label` verification method.