OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity / lib / labels.js

58 lines · javascript ref:
// com.atproto.label — build / sign / verify atproto moderation labels (com.atproto.label.defs#label).
//
// A label is a SIGNED tag on a subject: `uri` is an AT-URI (a record, e.g. a post) OR an account DID;
// `val` is the label value ('spam', '!hide', a custom value); `src` is the labeler's DID. Retraction is
// NEGATION, not deletion (`neg:true` with the same src/uri/val). The signature is a raw 64-byte
// secp256k1 (low-S) signature over the DAG-CBOR of the label WITHOUT its `sig` field — byte-matching
// atproto/ozone (which signs with an #atproto_label key; verified against that did:key).
//
// Lives in atproto-identity because the crypto (dag-cbor + secp256k1 + verifySig + did:key) already
// does; extractable to a dedicated `atproto-label` module later (step 6). The moderation POLICY (which
// labelers to trust, how a `val` renders) is the CONSUMER's job, not this codec's.
import * as dagCbor from '@ipld/dag-cbor'
import { secp256k1 } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha2'
import { verifySig } from './plc-directory.js'

// Canonical bytes signed/verified: DAG-CBOR of the label minus `sig`, undefined fields omitted. DAG-CBOR
// sorts map keys deterministically, so signer and verifier agree regardless of field insertion order.
export function labelSigningBytes (label) {
  const clean = {}
  for (const k of Object.keys(label)) { if (k !== 'sig' && label[k] !== undefined) clean[k] = label[k] }
  return dagCbor.encode(clean)
}

// Build an UNSIGNED label. cts (created-at, ISO datetime) is caller-supplied for determinism.
//
// ⛔ `ver` DEFAULTS TO 1 and is part of the signed bytes. The label spec (atproto.com/specs/label): "ver (integer,
// required): label schema version. Current version is always 1", required whenever labels move between
// services, and the signature covers "a complete version of the label … including the `ver` field". The
// lexicon marks it optional, which is how it was left out: loser-club's member labels went to bsky without it
// and its AppView never showed them (2026-10-14). test/fixtures/bsky-mod-label.json pins bsky's own shape.
export function buildLabel ({ src, uri, val, cts, cid, neg, exp, ver = 1 }) {
  if (!src || !uri || !val || !cts) throw new Error('buildLabel: src, uri, val, cts are required')
  const label = { ver, src, uri, val, cts }
  if (cid !== undefined) label.cid = cid
  if (neg !== undefined) label.neg = neg
  if (exp !== undefined) label.exp = exp
  return label
}

// Sign a label with the labeler's raw secp256k1 private key -> the label with a 64-byte `sig` (bytes).
export function signLabel (label, privateKey) {
  const sig = secp256k1.sign(sha256(labelSigningBytes(label)), privateKey, { lowS: true }).toCompactRawBytes()
  return { ...label, sig }
}

// Verify a signed label against the labeler's signing did:key. Returns false on any mismatch/decode
// error (never throws). Reuses the audited verifySig (raw 64-byte, low-S enforced, DER rejected).
export function verifyLabel (label, labelerDidKey) {
  if (!label || !label.sig) return false
  return verifySig(labelSigningBytes(label), label.sig, labelerDidKey)
}

// Wire codec for a full (signed) label — one block of a label feed. `sig` (bytes) round-trips as
// DAG-CBOR bytes. Distinct from labelSigningBytes (which strips sig for the signature domain).
export function encodeLabel (label) { return dagCbor.encode(label) }
export function decodeLabel (bytes) { return dagCbor.decode(bytes) }