OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 626d09d

atproto-identity: com.atproto.label build/sign/verify
signLabel/verifyLabel/buildLabel for atproto moderation labels — a signed tag on a subject (an AT-URI or account DID). Sig is a raw 64-byte secp256k1 (low-S) signature over DAG-CBOR(label minus sig), byte-matching atproto/ozone; verify reuses the audited verifySig (64-byte raw, low-S, DER rejected) against the labeler's did:key. Retraction is negation (neg:true), not deletion. Lives here because the crypto (dag-cbor + secp256k1 + verifySig + did:key) already does; extractable to an atproto-label module later. Test 9/9 (sign/verify + tamper + wrong-key + negation + canonical-bytes determinism), wired into npm test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
626d09d87bee15af20251b63a99c95c0748377ab
by plankatron <fleeky@gmail.com> · 2026-07-14T17:55:55.000Z

Changed files (3) +85 -1

lib/labels.js +47 -0
……@@ -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.
12import * as dagCbor from '@ipld/dag-cbor'
13import { secp256k1 } from '@noble/curves/secp256k1'
14import { sha256 } from '@noble/hashes/sha2'
15import { 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.
19export 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.
26export 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).
37export 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).
44export function verifyLabel (label, labelerDidKey) {
45 if (!label || !label.sig) return false
46 return verifySig(labelSigningBytes(label), label.sig, labelerDidKey)
47}
package.json +1 -1
……@@ -5,7 +5,7 @@
55 "type": "module",
66 "main": "lib/atproto-identity.js",
77 "scripts": {
8 "test": "brittle-node test/*.test.js",
8 "test": "brittle-node test/*.test.js && node test/labels.test.mjs",
99 "postinstall": "node scripts/bare-deps.mjs"
1010 },
1111 "dependencies": {
test/labels.test.mjs +37 -0
……@@ -0,0 +1,37 @@
1// com.atproto.label sign/verify conformance — build + sign + verify + tamper/neg/determinism.
2// Run: node test/labels.test.mjs
3import { keyFromPrivate } from '../lib/atproto-identity.js'
4import { buildLabel, signLabel, verifyLabel, labelSigningBytes } from '../lib/labels.js'
5
6const assert = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m) }
7
8const labeler = keyFromPrivate(new Uint8Array(32).fill(3)) // { privateKey, publicKey, didKey }
9const cts = '2026-07-01T00:00:00.000Z'
10const uri = 'at://did:plc:alice/app.bsky.feed.post/1'
11
12const label = buildLabel({ src: 'did:plc:labeler', uri, val: 'spam', cts })
13const signed = signLabel(label, labeler.privateKey)
14assert(signed.sig && signed.sig.length === 64, 'signLabel produces a 64-byte sig')
15assert(verifyLabel(signed, labeler.didKey) === true, 'verifyLabel accepts a good signature')
16assert(verifyLabel({ ...signed, val: 'nice' }, labeler.didKey) === false, 'tampered val fails verification')
17assert(verifyLabel({ ...signed, uri: 'at://did:plc:bob/app.bsky.feed.post/2' }, labeler.didKey) === false, 'tampered uri fails verification')
18
19const other = keyFromPrivate(new Uint8Array(32).fill(5))
20assert(verifyLabel(signed, other.didKey) === false, 'a different labeler key fails verification')
21assert(verifyLabel(label, labeler.didKey) === false, 'an unsigned label fails verification')
22
23// negation (retraction) label
24const neg = signLabel(buildLabel({ src: 'did:plc:labeler', uri, val: 'spam', cts: '2026-07-02T00:00:00.000Z', neg: true }), labeler.privateKey)
25assert(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
28const a = labelSigningBytes({ uri: 'x', src: 'y', val: 'z', cts: 't' })
29const b = labelSigningBytes({ cts: 't', val: 'z', src: 'y', uri: 'x' })
30assert(Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0, 'signing bytes are canonical (field-order independent)')
31
32// buildLabel requires the mandatory fields
33let threw = false
34try { buildLabel({ src: 'x', uri: 'y', val: 'z' }) } catch { threw = true }
35assert(threw, 'buildLabel throws without cts (required field)')
36
37console.log('\nLABELS OK: build + sign + verify + tamper/neg/determinism (com.atproto.label)')