| … | … | @@ -0,0 +1,47 @@ |
| 1 | // com.atproto.label — build / sign / verify atproto moderation labels (com.atproto.label.defs#label). | |
| 2 | // | |
| 3 | // A label is a SIGNED tag on a subject: `uri` is an AT-URI (a record, e.g. a post) OR an account DID; | |
| 4 | // `val` is the label value ('spam', '!hide', a custom value); `src` is the labeler's DID. Retraction is | |
| 5 | // NEGATION, not deletion (`neg:true` with the same src/uri/val). The signature is a raw 64-byte | |
| 6 | // secp256k1 (low-S) signature over the DAG-CBOR of the label WITHOUT its `sig` field — byte-matching | |
| 7 | // atproto/ozone (which signs with an #atproto_label key; verified against that did:key). | |
| 8 | // | |
| 9 | // Lives in atproto-identity because the crypto (dag-cbor + secp256k1 + verifySig + did:key) already | |
| 10 | // does; extractable to a dedicated `atproto-label` module later (step 6). The moderation POLICY (which | |
| 11 | // labelers to trust, how a `val` renders) is the CONSUMER's job, not this codec's. | |
| 12 | import * as dagCbor from '@ipld/dag-cbor' | |
| 13 | import { secp256k1 } from '@noble/curves/secp256k1' | |
| 14 | import { sha256 } from '@noble/hashes/sha2' | |
| 15 | import { verifySig } from './plc-directory.js' | |
| 16 | ||
| 17 | // Canonical bytes signed/verified: DAG-CBOR of the label minus `sig`, undefined fields omitted. DAG-CBOR | |
| 18 | // sorts map keys deterministically, so signer and verifier agree regardless of field insertion order. | |
| 19 | export function labelSigningBytes (label) { | |
| 20 | const clean = {} | |
| 21 | for (const k of Object.keys(label)) { if (k !== 'sig' && label[k] !== undefined) clean[k] = label[k] } | |
| 22 | return dagCbor.encode(clean) | |
| 23 | } | |
| 24 | ||
| 25 | // Build an UNSIGNED label. cts (created-at, ISO datetime) is caller-supplied for determinism. | |
| 26 | export function buildLabel ({ src, uri, val, cts, cid, neg, exp, ver }) { | |
| 27 | if (!src || !uri || !val || !cts) throw new Error('buildLabel: src, uri, val, cts are required') | |
| 28 | const label = { src, uri, val, cts } | |
| 29 | if (cid !== undefined) label.cid = cid | |
| 30 | if (neg !== undefined) label.neg = neg | |
| 31 | if (exp !== undefined) label.exp = exp | |
| 32 | if (ver !== undefined) label.ver = ver | |
| 33 | return label | |
| 34 | } | |
| 35 | ||
| 36 | // Sign a label with the labeler's raw secp256k1 private key -> the label with a 64-byte `sig` (bytes). | |
| 37 | export function signLabel (label, privateKey) { | |
| 38 | const sig = secp256k1.sign(sha256(labelSigningBytes(label)), privateKey, { lowS: true }).toCompactRawBytes() | |
| 39 | return { ...label, sig } | |
| 40 | } | |
| 41 | ||
| 42 | // Verify a signed label against the labeler's signing did:key. Returns false on any mismatch/decode | |
| 43 | // error (never throws). Reuses the audited verifySig (raw 64-byte, low-S enforced, DER rejected). | |
| 44 | export function verifyLabel (label, labelerDidKey) { | |
| 45 | if (!label || !label.sig) return false | |
| 46 | return verifySig(labelSigningBytes(label), label.sig, labelerDidKey) | |
| 47 | } |
| … | … | @@ -5,7 +5,7 @@ |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/atproto-identity.js", |
| 7 | 7 | "scripts": { |
| 8 | "test": "brittle-node test/*.test.js", | |
| 8 | "test": "brittle-node test/*.test.js && node test/labels.test.mjs", | |
| 9 | 9 | "postinstall": "node scripts/bare-deps.mjs" |
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| … | … | @@ -0,0 +1,37 @@ |
| 1 | // com.atproto.label sign/verify conformance — build + sign + verify + tamper/neg/determinism. | |
| 2 | // Run: node test/labels.test.mjs | |
| 3 | import { keyFromPrivate } from '../lib/atproto-identity.js' | |
| 4 | import { buildLabel, signLabel, verifyLabel, labelSigningBytes } from '../lib/labels.js' | |
| 5 | ||
| 6 | const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) } | |
| 7 | ||
| 8 | const labeler = keyFromPrivate(new Uint8Array(32).fill(3)) // { privateKey, publicKey, didKey } | |
| 9 | const cts = '2026-07-01T00:00:00.000Z' | |
| 10 | const uri = 'at://did:plc:alice/app.bsky.feed.post/1' | |
| 11 | ||
| 12 | const label = buildLabel({ src: 'did:plc:labeler', uri, val: 'spam', cts }) | |
| 13 | const signed = signLabel(label, labeler.privateKey) | |
| 14 | assert(signed.sig && signed.sig.length === 64, 'signLabel produces a 64-byte sig') | |
| 15 | assert(verifyLabel(signed, labeler.didKey) === true, 'verifyLabel accepts a good signature') | |
| 16 | assert(verifyLabel({ ...signed, val: 'nice' }, labeler.didKey) === false, 'tampered val fails verification') | |
| 17 | assert(verifyLabel({ ...signed, uri: 'at://did:plc:bob/app.bsky.feed.post/2' }, labeler.didKey) === false, 'tampered uri fails verification') | |
| 18 | ||
| 19 | const other = keyFromPrivate(new Uint8Array(32).fill(5)) | |
| 20 | assert(verifyLabel(signed, other.didKey) === false, 'a different labeler key fails verification') | |
| 21 | assert(verifyLabel(label, labeler.didKey) === false, 'an unsigned label fails verification') | |
| 22 | ||
| 23 | // negation (retraction) label | |
| 24 | const neg = signLabel(buildLabel({ src: 'did:plc:labeler', uri, val: 'spam', cts: '2026-07-02T00:00:00.000Z', neg: true }), labeler.privateKey) | |
| 25 | assert(verifyLabel(neg, labeler.didKey) === true && neg.neg === true, 'negation label signs+verifies + carries neg:true') | |
| 26 | ||
| 27 | // determinism: signing bytes are canonical regardless of field insertion order | |
| 28 | const a = labelSigningBytes({ uri: 'x', src: 'y', val: 'z', cts: 't' }) | |
| 29 | const b = labelSigningBytes({ cts: 't', val: 'z', src: 'y', uri: 'x' }) | |
| 30 | assert(Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0, 'signing bytes are canonical (field-order independent)') | |
| 31 | ||
| 32 | // buildLabel requires the mandatory fields | |
| 33 | let threw = false | |
| 34 | try { buildLabel({ src: 'x', uri: 'y', val: 'z' }) } catch { threw = true } | |
| 35 | assert(threw, 'buildLabel throws without cts (required field)') | |
| 36 | ||
| 37 | console.log('\nLABELS OK: build + sign + verify + tamper/neg/determinism (com.atproto.label)') |