OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 6be1688

0.0.5: labels carry ver: 1 — the spec requires it and signs over it
The label spec (atproto.com/specs/label) makes ver required (always 1) whenever labels move between services, and the signature covers it. The lexicon marks it optional, which is how buildLabel left it out. Measured 2026-10-14: loser-club member labels reached 7 live bsky subscribeLabels connections and the AppView never showed them. A real bsky label (mod.bsky.app, fixture) verifies with our codec only with ver in the signed bytes. Test-first: red on the default, green after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6be16885ecf608d412e9d53a860bdf0d08e4f4bc
by plankatron <fleeky@gmail.com> · 2026-10-14T15:29:47.000Z

Changed files (4) +40 -5

lib/labels.js +8 -3
……@@ -23,13 +23,18 @@ export function labelSigningBytes (label) {
2323}
2424
2525// 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 }) {
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.
32export function buildLabel ({ src, uri, val, cts, cid, neg, exp, ver = 1 }) {
2733 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 }
2935 if (cid !== undefined) label.cid = cid
3036 if (neg !== undefined) label.neg = neg
3137 if (exp !== undefined) label.exp = exp
32 if (ver !== undefined) label.ver = ver
3338 return label
3439}
3540
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "atproto-identity",
3 "version": "0.0.4",
3 "version": "0.0.5",
44 "description": "Seed-anchored atproto identity: one BIP39 mnemonic -> Ed25519 (Hypercore) + secp256k1 (did:plc). Offline did:plc genesis + verification.",
55 "type": "module",
66 "main": "lib/atproto-identity.js",
test/fixtures/bsky-mod-label.json +12 -0
……@@ -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}
test/labels.test.mjs +19 -1
……@@ -34,4 +34,22 @@ let threw = false
3434try { buildLabel({ src: 'x', uri: 'y', val: 'z' }) } catch { threw = true }
3535assert(threw, 'buildLabel throws without cts (required field)')
3636
37console.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.
42assert(label.ver === 1, 'buildLabel sets ver: 1 (the spec\'s only version) when the caller passes none')
43assert(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.
48const { readFileSync } = await import('node:fs')
49const fx = JSON.parse(readFileSync(new URL('./fixtures/bsky-mod-label.json', import.meta.url), 'utf8'))
50const real = { ...fx.label, sig: Uint8Array.from(Buffer.from(fx.label.sig, 'hex')) }
51assert(verifyLabel(real, fx.didKey) === true, 'a real bsky label verifies with our codec (signing bytes match bsky\'s)')
52const { ver: _ver, ...withoutVer } = real
53assert(verifyLabel(withoutVer, fx.didKey) === false, 'the same label without `ver` does NOT verify — ver is in the signed bytes')
54
55console.log('\nLABELS OK: build + sign + verify + tamper/neg/determinism + ver conformance (com.atproto.label)')