From 625b173d1e3a82275576682afd1d1bd83bc1fe08 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 10 Jul 2026 16:30:34 +0100 Subject: [PATCH] feat(registry): add label signing --- .changeset/shiny-ants-deny.md | 2 +- packages/registry-moderation/package.json | 9 +- packages/registry-moderation/src/index.ts | 308 +++++++++++++++++- .../tests/fixtures/label-crypto.json | 15 + .../tests/label-crypto.test.ts | 200 ++++++++++++ .../tests/moderation.test.ts | 104 ++++-- pnpm-lock.yaml | 31 +- 7 files changed, 634 insertions(+), 35 deletions(-) create mode 100644 packages/registry-moderation/tests/fixtures/label-crypto.json create mode 100644 packages/registry-moderation/tests/label-crypto.test.ts diff --git a/.changeset/shiny-ants-deny.md b/.changeset/shiny-ants-deny.md index b60827f61..1a12206d7 100644 --- a/.changeset/shiny-ants-deny.md +++ b/.changeset/shiny-ants-deny.md @@ -2,4 +2,4 @@ "@emdash-cms/registry-moderation": patch --- -Adds shared release moderation evaluation for accepted ATProto label streams. +Adds shared ATProto label verification and release moderation evaluation. diff --git a/packages/registry-moderation/package.json b/packages/registry-moderation/package.json index e02938a20..9954155ba 100644 --- a/packages/registry-moderation/package.json +++ b/packages/registry-moderation/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "catalog:", + "@atproto/crypto": "catalog:", "@types/node": "catalog:", "publint": "catalog:", "tsdown": "catalog:", @@ -43,5 +44,11 @@ "url": "git+https://github.com/emdash-cms/emdash.git", "directory": "packages/registry-moderation" }, - "homepage": "https://github.com/emdash-cms/emdash" + "homepage": "https://github.com/emdash-cms/emdash", + "dependencies": { + "@atcute/cbor": "catalog:", + "@atcute/cid": "catalog:", + "@atcute/crypto": "catalog:", + "@atcute/multibase": "catalog:" + } } diff --git a/packages/registry-moderation/src/index.ts b/packages/registry-moderation/src/index.ts index 5053a862c..58831ae90 100644 --- a/packages/registry-moderation/src/index.ts +++ b/packages/registry-moderation/src/index.ts @@ -1,3 +1,8 @@ +import { encode } from "@atcute/cbor"; +import { fromString as cidFromString, toString as cidToString } from "@atcute/cid"; +import { P256PrivateKey, P256PublicKey, parsePublicMultikey } from "@atcute/crypto"; +import { fromBase64Url, toBase64Url } from "@atcute/multibase"; + export type ModerationLabelValue = | "assessment-error" | "assessment-overridden" @@ -35,6 +40,62 @@ export interface ModerationLabel { exp?: string; } +/** An ATProto label before its detached P-256 signature is added. */ +export interface UnsignedLabel { + ver: 1; + src: string; + uri: string; + val: string; + cts: string; + cid?: string; + neg?: boolean; + exp?: string; +} + +/** An ATProto label v1 with its compact P-256 signature. */ +export interface SignedLabel extends UnsignedLabel { + sig: Uint8Array; +} + +const verifiedModerationLabel = Symbol("verifiedModerationLabel"); + +/** A label whose signature and source DID key have been verified by this package. */ +export type VerifiedModerationLabel = ModerationLabel & { + readonly [verifiedModerationLabel]: true; +}; + +export interface CreateLabelSignerInput { + issuerDid: string; + /** A canonical, unpadded base64url encoding of the 32-byte P-256 scalar. */ + privateKey: string; + resolveDid: LabelDidResolver; +} + +export interface LabelSigner { + readonly issuerDid: string; + sign(label: Omit): Promise; +} + +export interface DidVerificationMethod { + id: string; + type: string; + controller: string; + publicKeyMultibase: string; +} + +export interface LabelDidDocument { + id: string; + verificationMethod?: readonly DidVerificationMethod[]; +} + +/** Resolves the DID document used exclusively for a label signature check. */ +export type LabelDidResolver = (did: string) => Promise; + +export interface LabelVerificationInput { + label: SignedLabel; + resolveDid: LabelDidResolver; +} + export interface AcceptedLabelerPolicy { did: string; redact: boolean; @@ -56,7 +117,15 @@ export interface EvaluateReleaseModerationInput { acceptedLabelers: AcceptedLabelerPolicy[]; context: ReleaseSubjectContext; evaluatedAt: Date | string; - labels: ModerationLabel[]; + labels: VerifiedModerationLabel[]; +} + +export interface VerifyAndEvaluateReleaseModerationInput extends Omit< + EvaluateReleaseModerationInput, + "labels" +> { + labels: SignedLabel[]; + resolveDid: LabelDidResolver; } export type ReleaseEligibility = "eligible" | "pending" | "error" | "blocked"; @@ -116,6 +185,14 @@ interface LabelReduction { } const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/; +const DID = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*$/; +const P256_ORDER = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); +const LABEL_FIELDS = new Set(["ver", "src", "uri", "cid", "val", "neg", "cts", "exp"]); +const SIGNED_LABEL_FIELDS = new Set([...LABEL_FIELDS, "sig"]); +const PRINTABLE_LABEL_VALUE = /^[^\p{Cc}]{1,128}$/u; +const BASE64URL = /^[A-Za-z0-9_-]+$/; +const ATPROTO_URI = + /^at:\/\/(?:did:[a-z0-9]+:[A-Za-z0-9._:%-]+(?:[:][A-Za-z0-9._:%-]+)*|[A-Za-z0-9.-]+)\/[A-Za-z0-9.-]+(?:\/[A-Za-z0-9._~:%-]+)?$/; function daysFromCivil(year: bigint, month: bigint, day: bigint): bigint { const adjustedYear = year - (month <= 2n ? 1n : 0n); @@ -197,6 +274,216 @@ function compareInstants(left: ParsedInstant, right: ParsedInstant): number { return 0; } +function scalarToBigInt(bytes: Uint8Array): bigint { + let value = 0n; + for (const byte of bytes) value = (value << 8n) | BigInt(byte); + return value; +} + +function utf8Length(value: string): number { + let length = 0; + for (let index = 0; index < value.length; index++) { + const codeUnit = value.charCodeAt(index); + if (codeUnit <= 0x7f) length++; + else if (codeUnit <= 0x7ff) length += 2; + else if ( + codeUnit >= 0xd800 && + codeUnit <= 0xdbff && + value.charCodeAt(index + 1) >= 0xdc00 && + value.charCodeAt(index + 1) <= 0xdfff + ) { + length += 4; + index++; + } else length += 3; + } + return length; +} + +function validateDid(value: unknown, field: string): asserts value is string { + if (typeof value !== "string" || !DID.test(value)) + throw new TypeError(`${field} must be a valid DID`); +} + +function validateCid(value: unknown): asserts value is string { + if (typeof value !== "string") throw new TypeError("label.cid must be a valid CID"); + try { + const cid = cidFromString(value); + if (cidToString(cid) !== value) throw new TypeError("label.cid must be a valid CID"); + } catch { + throw new TypeError("label.cid must be a valid CID"); + } +} + +function validateLabelValue(value: unknown): asserts value is string { + if ( + typeof value !== "string" || + value.length === 0 || + !PRINTABLE_LABEL_VALUE.test(value) || + utf8Length(value) > 128 + ) { + throw new TypeError( + "label.val must be a non-empty printable string of at most 128 UTF-8 bytes", + ); + } +} + +function validateLabelUri(value: unknown): asserts value is string { + if (typeof value !== "string" || value.length === 0) + throw new TypeError("label.uri must be a URI"); + if (DID.test(value)) return; + if (!ATPROTO_URI.test(value)) throw new TypeError("label.uri must be an at:// URI or DID"); +} + +function getField(value: object, field: string): unknown { + return Object.getOwnPropertyDescriptor(value, field)?.value; +} + +function validateLabelObject(value: unknown, signed: true): SignedLabel; +function validateLabelObject(value: unknown, signed: false): UnsignedLabel; +function validateLabelObject(value: unknown, signed: boolean): SignedLabel | UnsignedLabel { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new TypeError("label must be an object"); + const fields = signed ? SIGNED_LABEL_FIELDS : LABEL_FIELDS; + for (const field of Object.keys(value)) { + if (!fields.has(field)) throw new TypeError(`label contains unsupported field: ${field}`); + } + if (getField(value, "ver") !== 1) throw new TypeError("label.ver must be 1"); + const src = getField(value, "src"); + const uri = getField(value, "uri"); + const val = getField(value, "val"); + const cts = getField(value, "cts"); + const cid = getField(value, "cid"); + const neg = getField(value, "neg"); + const exp = getField(value, "exp"); + validateDid(src, "label.src"); + validateLabelUri(uri); + validateLabelValue(val); + if (typeof cts !== "string") throw new TypeError("label.cts must be a valid RFC 3339 timestamp"); + parseInstant(cts, "label.cts"); + if (exp !== undefined) { + if (typeof exp !== "string") + throw new TypeError("label.exp must be a valid RFC 3339 timestamp"); + parseInstant(exp, "label.exp"); + } + if (cid !== undefined) validateCid(cid); + if (neg !== undefined && typeof neg !== "boolean") + throw new TypeError("label.neg must be a boolean"); + const sig = getField(value, "sig"); + + const canonical = { + ver: 1 as const, + src, + uri, + ...(cid === undefined ? {} : { cid }), + val, + ...(neg === true ? { neg: true } : {}), + cts, + ...(exp === undefined ? {} : { exp }), + }; + if (!signed) return canonical; + if (!(sig instanceof Uint8Array) || sig.length !== 64) + throw new TypeError("label.sig must be a 64-byte compact P-256 signature"); + return { ...canonical, sig }; +} + +function canonicalLabelBytes(label: UnsignedLabel): Uint8Array { + return encode(validateLabelObject(label, false)); +} + +function importPrivateScalar(value: string): Promise { + if (!BASE64URL.test(value)) + throw new TypeError("privateKey must be canonical unpadded base64url"); + let bytes: Uint8Array; + try { + bytes = fromBase64Url(value); + } catch { + throw new TypeError("privateKey must be canonical unpadded base64url"); + } + if (bytes.length !== 32 || toBase64Url(bytes) !== value) { + throw new TypeError("privateKey must be canonical unpadded base64url for exactly 32 bytes"); + } + const scalar = scalarToBigInt(bytes); + if (scalar === 0n || scalar >= P256_ORDER) + throw new TypeError("privateKey must be in the P-256 scalar range"); + return P256PrivateKey.importRaw(bytes); +} + +function normalizedMethodId(documentId: string, methodId: string): string { + if (methodId.startsWith("#")) return `${documentId}${methodId}`; + return methodId; +} + +async function resolveLabelPublicKey( + did: string, + resolveDid: LabelDidResolver, +): Promise { + const document = await resolveDid(did); + validateDid(document.id, "DID document id"); + if (document.id !== did) throw new TypeError("DID document id does not match label source"); + const methods = document.verificationMethod ?? []; + const ids = new Set(); + let signingMethod: DidVerificationMethod | undefined; + for (const method of methods) { + const id = normalizedMethodId(document.id, method.id); + if (ids.has(id)) throw new TypeError("DID document has duplicate verification method ids"); + ids.add(id); + if (id === `${did}#atproto_label`) signingMethod = { ...method, id }; + } + if (!signingMethod) throw new TypeError("DID document has no #atproto_label verification method"); + if (signingMethod.type !== "Multikey" || signingMethod.controller !== did) { + throw new TypeError("#atproto_label verification method must be a controller-owned Multikey"); + } + let parsed: ReturnType; + try { + parsed = parsePublicMultikey(signingMethod.publicKeyMultibase); + } catch { + throw new TypeError("#atproto_label verification method has an invalid Multikey"); + } + if ( + parsed.type !== "p256" || + parsed.publicKeyBytes.length !== 33 || + ![2, 3].includes(parsed.publicKeyBytes[0]!) + ) { + throw new TypeError("#atproto_label verification method must contain a compressed P-256 key"); + } + const key = await P256PublicKey.importRaw(parsed.publicKeyBytes); + if ((await key.exportPublicKey("multikey")) !== signingMethod.publicKeyMultibase) { + throw new TypeError("#atproto_label verification method uses a non-canonical P-256 Multikey"); + } + return key; +} + +/** Creates a signer whose scalar is bound to the issuer DID's exact `#atproto_label` key. */ +export async function createLabelSigner(input: CreateLabelSignerInput): Promise { + validateDid(input.issuerDid, "issuerDid"); + const key = await importPrivateScalar(input.privateKey); + const resolved = await resolveLabelPublicKey(input.issuerDid, input.resolveDid); + if ((await key.exportPublicKey("multikey")) !== (await resolved.exportPublicKey("multikey"))) { + throw new TypeError( + "privateKey does not match the issuer DID #atproto_label verification method", + ); + } + return { + issuerDid: input.issuerDid, + async sign(label) { + const unsigned = validateLabelObject({ ...label, src: input.issuerDid }, false); + return { ...unsigned, sig: await key.sign(canonicalLabelBytes(unsigned)) }; + }, + }; +} + +/** Verifies a signed label against the source DID's exact `#atproto_label` P-256 key. */ +export async function verifyLabel(input: LabelVerificationInput): Promise { + const label = validateLabelObject(input.label, true); + const key = await resolveLabelPublicKey(label.src, input.resolveDid); + const { sig, ...verified } = label; + if (!(await key.verify(sig, canonicalLabelBytes(verified)))) + throw new TypeError("label signature is invalid"); + const verifiedLabel: VerifiedModerationLabel = { ...verified, [verifiedModerationLabel]: true }; + Object.defineProperty(verifiedLabel, verifiedModerationLabel, { enumerable: false }); + return verifiedLabel; +} + function streamKey(label: ModerationLabel): string { return `${label.src}\u0000${label.uri}\u0000${label.val}`; } @@ -299,6 +586,15 @@ function orderedValues(labels: ModerationLabel[]): string[] { export function evaluateReleaseModeration( input: EvaluateReleaseModerationInput, ): ReleaseModeration { + for (const label of input.labels) { + if ( + typeof label !== "object" || + label === null || + Object.getOwnPropertyDescriptor(label, verifiedModerationLabel)?.value !== true + ) { + throw new TypeError("labels must be verified by verifyLabel before moderation evaluation"); + } + } const policies = new Map(); for (const policy of input.acceptedLabelers) { const existing = policies.get(policy.did); @@ -393,3 +689,13 @@ export function evaluateReleaseModeration( ), }; } + +/** Verifies labels before evaluating their moderation effect for one release. */ +export async function verifyAndEvaluateReleaseModeration( + input: VerifyAndEvaluateReleaseModerationInput, +): Promise { + const labels = await Promise.all( + input.labels.map((label) => verifyLabel({ label, resolveDid: input.resolveDid })), + ); + return evaluateReleaseModeration({ ...input, labels }); +} diff --git a/packages/registry-moderation/tests/fixtures/label-crypto.json b/packages/registry-moderation/tests/fixtures/label-crypto.json new file mode 100644 index 000000000..7d162b7b2 --- /dev/null +++ b/packages/registry-moderation/tests/fixtures/label-crypto.json @@ -0,0 +1,15 @@ +{ + "privateKey": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", + "multikey": "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + "otherMultikey": "zDnaer52RTwabaBeMkKYYwZmEFqPabLW78cRK62iovMUQhFif", + "nonP256Multikey": "zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9", + "label": { + "ver": 1, + "src": "did:example:labeller", + "uri": "at://did:example:publisher/com.example.release/1", + "cid": "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m", + "val": "assessment-passed", + "neg": false, + "cts": "2026-07-10T12:00:00.000Z" + } +} diff --git a/packages/registry-moderation/tests/label-crypto.test.ts b/packages/registry-moderation/tests/label-crypto.test.ts new file mode 100644 index 000000000..03e354895 --- /dev/null +++ b/packages/registry-moderation/tests/label-crypto.test.ts @@ -0,0 +1,200 @@ +import { readFileSync } from "node:fs"; + +import { encode } from "@atcute/cbor"; +import { verifySignature } from "@atproto/crypto"; +import { describe, expect, it } from "vitest"; + +import { + createLabelSigner, + verifyAndEvaluateReleaseModeration, + verifyLabel, + type LabelDidDocument, + type SignedLabel, + type UnsignedLabel, +} from "../src/index.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/label-crypto.json", import.meta.url), "utf8"), +) as { + privateKey: string; + multikey: string; + otherMultikey: string; + nonP256Multikey: string; + label: UnsignedLabel; +}; + +function document( + options: Partial & { methods?: LabelDidDocument["verificationMethod"] } = {}, +): LabelDidDocument { + return { + id: fixture.label.src, + verificationMethod: options.methods ?? [ + { + id: "#atproto_label", + type: "Multikey", + controller: fixture.label.src, + publicKeyMultibase: fixture.multikey, + }, + ], + ...options, + }; +} + +function signingLabel(): Omit { + const { src: _src, ...label } = fixture.label; + return label; +} + +async function signer(didDocument = document()) { + return createLabelSigner({ + issuerDid: fixture.label.src, + privateKey: fixture.privateKey, + resolveDid: async () => didDocument, + }); +} + +async function signedLabel(): Promise { + return (await signer()).sign(signingLabel()); +} + +async function verify(label: SignedLabel, didDocument = document()) { + return verifyLabel({ label, resolveDid: async () => didDocument }); +} + +function unsignedBytes(label: UnsignedLabel): Uint8Array { + const { neg: _neg, ...withoutNeg } = label; + return encode(withoutNeg); +} + +function asHighS(signature: Uint8Array): Uint8Array { + const order = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); + let s = 0n; + for (const byte of signature.slice(32)) s = (s << 8n) | BigInt(byte); + const high = order - s; + const result = signature.slice(); + for (let index = 63; index >= 32; index--) { + result[index] = Number((high >> BigInt((63 - index) * 8)) & 0xffn); + } + return result; +} + +describe("ATProto label v1 crypto", () => { + it("binds a signer to #atproto_label and signs canonical label CBOR", async () => { + const signed = await signedLabel(); + const verified = await verify(signed); + + const { neg: _neg, ...expected } = fixture.label; + expect(verified).toEqual(expected); + expect("neg" in verified).toBe(false); + expect( + await verifySignature( + `did:key:${fixture.multikey}`, + unsignedBytes(fixture.label), + signed.sig, + ), + ).toBe(true); + }); + + it("passes canonical bytes directly rather than a digest", async () => { + const signed = await signedLabel(); + const bytes = unsignedBytes(fixture.label); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + + expect(await verifySignature(`did:key:${fixture.multikey}`, bytes, signed.sig)).toBe(true); + expect(await verifySignature(`did:key:${fixture.multikey}`, digest, signed.sig)).toBe(false); + }); + + it("rejects unsupported fields, invalid dates, and values exceeding 128 UTF-8 bytes", async () => { + const boundSigner = await signer(); + await expect( + boundSigner.sign({ ...signingLabel(), $type: "com.atproto.label.defs#label" }), + ).rejects.toThrow("unsupported field"); + await expect( + boundSigner.sign({ ...signingLabel(), cts: "2026-02-30T12:00:00Z" }), + ).rejects.toThrow("valid RFC 3339"); + await expect(boundSigner.sign({ ...signingLabel(), val: "😀".repeat(33) })).rejects.toThrow( + "128 UTF-8 bytes", + ); + }); + + it("rejects non-canonical, zero, and out-of-range private scalar forms", async () => { + for (const privateKey of [ + fixture.privateKey + "=", + "AA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ]) { + await expect( + createLabelSigner({ + issuerDid: fixture.label.src, + privateKey, + resolveDid: async () => document(), + }), + ).rejects.toThrow("privateKey"); + } + }); + + it("requires the exact normalized #atproto_label method without fallback", async () => { + const signed = await signedLabel(); + await expect( + verify( + signed, + document({ + methods: [ + { ...document().verificationMethod![0]!, id: `${fixture.label.src}#atproto_label` }, + ], + }), + ), + ).resolves.toEqual(expect.objectContaining({ src: fixture.label.src })); + await expect( + verify( + signed, + document({ methods: [{ ...document().verificationMethod![0]!, id: "#atproto" }] }), + ), + ).rejects.toThrow("no #atproto_label"); + }); + + it("rejects malformed configured signer DID key material", async () => { + const baseMethod = document().verificationMethod![0]!; + for (const didDocument of [ + document({ methods: [] }), + document({ methods: [{ ...baseMethod, id: "#atproto" }] }), + document({ + methods: [baseMethod, { ...baseMethod, id: `${fixture.label.src}#atproto_label` }], + }), + document({ methods: [{ ...baseMethod, controller: "did:example:other" }] }), + document({ methods: [{ ...baseMethod, publicKeyMultibase: fixture.nonP256Multikey }] }), + document({ methods: [{ ...baseMethod, publicKeyMultibase: fixture.otherMultikey }] }), + ]) { + await expect(signer(didDocument)).rejects.toThrow(TypeError); + } + }); + + it("rejects malleable, DER, and altered signatures or labels", async () => { + const signed = await signedLabel(); + await expect(verify({ ...signed, sig: asHighS(signed.sig) })).rejects.toThrow( + "signature is invalid", + ); + await expect(verify({ ...signed, sig: new Uint8Array(70) })).rejects.toThrow("64-byte compact"); + await expect(verify({ ...signed, val: "assessment-pending" })).rejects.toThrow( + "signature is invalid", + ); + }); + + it("verifies labels before evaluating their moderation effect", async () => { + const result = await verifyAndEvaluateReleaseModeration({ + acceptedLabelers: [{ did: fixture.label.src, redact: false }], + context: { + publisherDid: "did:example:publisher", + package: { + uri: "at://did:example:publisher/com.example.package/profile", + cid: "package-cid", + }, + release: { uri: fixture.label.uri, cid: fixture.label.cid! }, + }, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [await signedLabel()], + resolveDid: async () => document(), + }); + expect(result.eligibility).toBe("eligible"); + }); +}); diff --git a/packages/registry-moderation/tests/moderation.test.ts b/packages/registry-moderation/tests/moderation.test.ts index 7474f0ea9..482089d7a 100644 --- a/packages/registry-moderation/tests/moderation.test.ts +++ b/packages/registry-moderation/tests/moderation.test.ts @@ -1,8 +1,15 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; -import { evaluateReleaseModeration, type ModerationLabel } from "../src/index.js"; +import { + createLabelSigner, + evaluateReleaseModeration, + verifyLabel, + type LabelDidDocument, + type ModerationLabel, + type VerifiedModerationLabel, +} from "../src/index.js"; const source = "did:example:trusted"; const otherSource = "did:example:other"; @@ -25,16 +32,69 @@ function label( }; } +let verifiedBrand: symbol; + +beforeAll(async () => { + const resolveDid = async (): Promise => ({ + id: source, + verificationMethod: [ + { + id: "#atproto_label", + type: "Multikey", + controller: source, + publicKeyMultibase: "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", + }, + ], + }); + const signer = await createLabelSigner({ + issuerDid: source, + privateKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", + resolveDid, + }); + const verifiedLabel = await verifyLabel({ + label: await signer.sign({ + ver: 1, + uri: context.release.uri, + cid: "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m", + val: "assessment-passed", + cts: "2026-07-10T12:00:00.000Z", + }), + resolveDid, + }); + const brand = Reflect.ownKeys(verifiedLabel).find( + (key): key is symbol => typeof key === "symbol", + ); + if (!brand) throw new Error("verified label is missing its runtime brand"); + verifiedBrand = brand; +}); + +// Evaluator policy tests use a brand recovered from a real verified label. +function verified(rawLabel: ModerationLabel): VerifiedModerationLabel { + Object.defineProperty(rawLabel, verifiedBrand, { value: true }); + return rawLabel as unknown as VerifiedModerationLabel; +} + function evaluate(labels: ModerationLabel[], redact = false) { return evaluateReleaseModeration({ acceptedLabelers: [{ did: source, redact }], context, evaluatedAt: "2026-07-10T13:00:00.000Z", - labels, + labels: labels.map(verified), }); } describe("release moderation", () => { + it("rejects a raw label forged with a TypeScript cast", () => { + expect(() => + evaluateReleaseModeration({ + acceptedLabelers: [{ did: source, redact: false }], + context, + evaluatedAt: "2026-07-10T13:00:00.000Z", + labels: [label({ val: "assessment-passed" }) as unknown as VerifiedModerationLabel], + }), + ).toThrow("must be verified"); + }); + it("accepts an exact-CID assessment pass", () => { expect(evaluate([label({ val: "assessment-passed" })])).toMatchObject({ eligibility: "eligible", @@ -173,7 +233,9 @@ describe("release moderation", () => { acceptedLabelers, context, evaluatedAt: "2026-07-10T13:00:00.000Z", - labels: [label({ val: "assessment-passed" }), label({ val: value, src: otherSource })], + labels: [label({ val: "assessment-passed" }), label({ val: value, src: otherSource })].map( + verified, + ), }); expect(result.eligibility).toBe( value === "assessment-error" @@ -233,7 +295,7 @@ describe("release moderation", () => { ], context, evaluatedAt: "2026-07-10T13:00:00.000Z", - labels: [label({ val: "!takedown", cid: undefined })], + labels: [label({ val: "!takedown", cid: undefined })].map(verified), }); expect(result).toMatchObject({ eligibility: "blocked", redacted: true }); }); @@ -334,21 +396,23 @@ describe("ratified moderation corpus", () => { release: { uri: subject.releaseUri!, cid: subject.releaseCid! }, }, evaluatedAt: corpus.evaluatedAt, - labels: labels.map((fixtureLabel) => ({ - ver: 1, - src: corpus.sources[fixtureLabel.src]!, - uri: - fixtureLabel.subject === "publisher" - ? subject.publisherDid! - : fixtureLabel.subject === "package" - ? subject.packageUri! - : subject.releaseUri!, - cid: fixtureLabel.cid, - val: fixtureLabel.val, - cts: fixtureLabel.cts, - neg: fixtureLabel.neg, - exp: fixtureLabel.exp, - })), + labels: labels.map((fixtureLabel) => + verified({ + ver: 1, + src: corpus.sources[fixtureLabel.src]!, + uri: + fixtureLabel.subject === "publisher" + ? subject.publisherDid! + : fixtureLabel.subject === "package" + ? subject.packageUri! + : subject.releaseUri!, + cid: fixtureLabel.cid, + val: fixtureLabel.val, + cts: fixtureLabel.cts, + neg: fixtureLabel.neg, + exp: fixtureLabel.exp, + }), + ), }); expect(result).toMatchObject({ eligibility: fixtureCase.expected.eligibility, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3187c6ce5..40c6bf7bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2233,10 +2233,26 @@ importers: version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) packages/registry-moderation: + dependencies: + '@atcute/cbor': + specifier: 'catalog:' + version: 2.3.3(@atcute/cid@2.4.1) + '@atcute/cid': + specifier: 'catalog:' + version: 2.4.1 + '@atcute/crypto': + specifier: 'catalog:' + version: 2.4.1 + '@atcute/multibase': + specifier: 'catalog:' + version: 1.2.0 devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' version: 0.18.2 + '@atproto/crypto': + specifier: 'catalog:' + version: 0.4.5 '@types/node': specifier: 'catalog:' version: 24.10.13 @@ -2924,9 +2940,6 @@ packages: '@atcute/cbor': ^2.0.0 '@atcute/cid': ^2.0.0 - '@atcute/cbor@2.3.2': - resolution: {integrity: sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ==} - '@atcute/cbor@2.3.3': resolution: {integrity: sha512-zZ4nHOK837zTMWJtta35YD7pcukrTzDc8jkpIGlSgoDYzu3l4BX3WVgpPJtRn3K6h2v97uyiWfiVjSpM7JSFzQ==} peerDependencies: @@ -12659,7 +12672,7 @@ snapshots: '@atcute/car@5.1.1': dependencies: - '@atcute/cbor': 2.3.2 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) '@atcute/cid': 2.4.1 '@atcute/uint8array': 1.1.1 '@atcute/varint': 2.0.0 @@ -12671,12 +12684,6 @@ snapshots: '@atcute/uint8array': 1.1.1 '@atcute/varint': 2.0.0 - '@atcute/cbor@2.3.2': - dependencies: - '@atcute/cid': 2.4.1 - '@atcute/multibase': 1.2.0 - '@atcute/uint8array': 1.1.1 - '@atcute/cbor@2.3.3(@atcute/cid@2.4.1)': dependencies: '@atcute/cid': 2.4.1 @@ -12825,7 +12832,7 @@ snapshots: '@atcute/mst@1.0.0': dependencies: - '@atcute/cbor': 2.3.2 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) '@atcute/cid': 2.4.1 '@atcute/uint8array': 1.1.1 @@ -12920,7 +12927,7 @@ snapshots: '@atcute/repo@0.1.4': dependencies: '@atcute/car': 5.1.1 - '@atcute/cbor': 2.3.2 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) '@atcute/cid': 2.4.1 '@atcute/crypto': 2.4.1 '@atcute/lexicons': 1.3.0