| … | … | @@ -23,13 +23,18 @@ export function labelSigningBytes (label) { |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 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 }) { | |
| 26 | // | |
| 27 | // ⛔ `ver` DEFAULTS TO 1 and is part of the signed bytes. The label spec (atproto.com/specs/label): "ver (integer, | |
| 28 | // required): label schema version. Current version is always 1", required whenever labels move between | |
| 29 | // services, and the signature covers "a complete version of the label … including the `ver` field". The | |
| 30 | // lexicon marks it optional, which is how it was left out: loser-club's member labels went to bsky without it | |
| 31 | // and its AppView never showed them (2026-10-14). test/fixtures/bsky-mod-label.json pins bsky's own shape. | |
| 32 | export function buildLabel ({ src, uri, val, cts, cid, neg, exp, ver = 1 }) { | |
| 27 | 33 | if (!src || !uri || !val || !cts) throw new Error('buildLabel: src, uri, val, cts are required') |
| 28 | const label = { src, uri, val, cts } | |
| 34 | const label = { ver, src, uri, val, cts } | |
| 29 | 35 | if (cid !== undefined) label.cid = cid |
| 30 | 36 | if (neg !== undefined) label.neg = neg |
| 31 | 37 | if (exp !== undefined) label.exp = exp |
| 32 | if (ver !== undefined) label.ver = ver | |
| 33 | 38 | return label |
| 34 | 39 | } |
| 35 | 40 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-identity", |
| 3 | "version": "0.0.4", | |
| 3 | "version": "0.0.5", | |
| 4 | 4 | "description": "Seed-anchored atproto identity: one BIP39 mnemonic -> Ed25519 (Hypercore) + secp256k1 (did:plc). Offline did:plc genesis + verification.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/atproto-identity.js", |
| … | … | @@ -0,0 +1,12 @@ |
| 1 | { | |
| 2 | "didKey": "did:key:zQ3shmV1BNcX17coaDbfen6zArEad6SCLT3jVWCbC6Y9iinTa", | |
| 3 | "label": { | |
| 4 | "cid": "bafyreihu6jo2nciwlckipwfjebgcvqusfqwv6kljvjtvntd2g3gpkhap4q", | |
| 5 | "cts": "2023-04-13T05:48:27.340Z", | |
| 6 | "sig": "1c7205be32540d4f39a2e245ac0ea919f7d542d8ed67a06cbedf62ac7d0987fb407f515af029a6dd4883a2e022d1220d5172700d35523098c4a79211a9c87fb3", | |
| 7 | "src": "did:plc:ar7c4by46qjdydhdevvrndac", | |
| 8 | "uri": "at://did:plc:t7bqlifbexxhsx7zcj5dablt/app.bsky.feed.post/3jta74y3luk2w", | |
| 9 | "val": "nudity", | |
| 10 | "ver": 1 | |
| 11 | } | |
| 12 | } |
| … | … | @@ -34,4 +34,22 @@ let threw = false |
| 34 | 34 | try { buildLabel({ src: 'x', uri: 'y', val: 'z' }) } catch { threw = true } |
| 35 | 35 | assert(threw, 'buildLabel throws without cts (required field)') |
| 36 | 36 | |
| 37 | console.log('\nLABELS OK: build + sign + verify + tamper/neg/determinism (com.atproto.label)') | |
| 37 | // ⛔ `ver` IS PART OF THE SIGNED OBJECT. The label spec (atproto.com/specs/label): "ver (integer, required): | |
| 38 | // label schema version. Current version is always 1" — required whenever labels move between services — and the | |
| 39 | // signature covers "a complete version of the label … This means including the `ver` field". Measured | |
| 40 | // 2026-10-14: loser-club's member labels were served without `ver`, and bsky's AppView never showed them while | |
| 41 | // 7 of its subscribeLabels connections received both frames. | |
| 42 | assert(label.ver === 1, 'buildLabel sets ver: 1 (the spec\'s only version) when the caller passes none') | |
| 43 | assert(buildLabel({ src: 'x', uri: 'y', val: 'z', cts, ver: 2 }).ver === 2, 'an explicit ver is kept') | |
| 44 | ||
| 45 | // A REAL label from bsky's own labeler (mod.bsky.app subscribeLabels, cursor=0, captured 2026-10-14) with the | |
| 46 | // issuer's #atproto_label did:key. Our signing bytes must match theirs byte for byte: it verifies as served, and | |
| 47 | // stripping `ver` — the shape we used to emit — breaks it. | |
| 48 | const { readFileSync } = await import('node:fs') | |
| 49 | const fx = JSON.parse(readFileSync(new URL('./fixtures/bsky-mod-label.json', import.meta.url), 'utf8')) | |
| 50 | const real = { ...fx.label, sig: Uint8Array.from(Buffer.from(fx.label.sig, 'hex')) } | |
| 51 | assert(verifyLabel(real, fx.didKey) === true, 'a real bsky label verifies with our codec (signing bytes match bsky\'s)') | |
| 52 | const { ver: _ver, ...withoutVer } = real | |
| 53 | assert(verifyLabel(withoutVer, fx.didKey) === false, 'the same label without `ver` does NOT verify — ver is in the signed bytes') | |
| 54 | ||
| 55 | console.log('\nLABELS OK: build + sign + verify + tamper/neg/determinism + ver conformance (com.atproto.label)') |