| … | … | @@ -67,15 +67,27 @@ export function verifyOp (unsigned, sigB64url, publicKey) { |
| 67 | 67 | |
| 68 | 68 | // Build an UNSIGNED did:plc genesis op. rotationKey defaults to the signing key |
| 69 | 69 | // (single-key setup); pass a distinct rotation didKey for key-separation. |
| 70 | export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle, pdsUrl }) { | |
| 70 | export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle, pdsUrl, labelDidKey = null, feedGenEndpoint = null, labelerEndpoint = null }) { | |
| 71 | const verificationMethods = { atproto: signingDidKey } | |
| 72 | const services = { atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl } } | |
| 73 | // Lens-export service entries (a self-custody feed-generator + labeler — e.g. a favela community DID). | |
| 74 | // The PLC op's `services`/`verificationMethods` KEYS become DID-doc fragments: services.bsky_fg → the | |
| 75 | // #bsky_fg service, verificationMethods.atproto_label → the #atproto_label VM. Endpoints point at where | |
| 76 | // the getFeedSkeleton / queryLabels+subscribeLabels XRPC is served (the membrane). #atproto_label | |
| 77 | // defaults to the #atproto signing key (labels co-signed by the shared key) unless a distinct key is given. | |
| 78 | if (feedGenEndpoint) services.bsky_fg = { type: 'BskyFeedGenerator', endpoint: feedGenEndpoint } | |
| 79 | if (labelerEndpoint) { | |
| 80 | services.atproto_labeler = { type: 'AtprotoLabeler', endpoint: labelerEndpoint } | |
| 81 | verificationMethods.atproto_label = labelDidKey || signingDidKey | |
| 82 | } else if (labelDidKey) { | |
| 83 | verificationMethods.atproto_label = labelDidKey | |
| 84 | } | |
| 71 | 85 | return { |
| 72 | 86 | type: 'plc_operation', |
| 73 | 87 | rotationKeys: rotationDidKeys && rotationDidKeys.length ? rotationDidKeys : [signingDidKey], |
| 74 | verificationMethods: { atproto: signingDidKey }, | |
| 88 | verificationMethods, | |
| 75 | 89 | alsoKnownAs: [`at://${handle}`], |
| 76 | services: { | |
| 77 | atproto_pds: { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl } | |
| 78 | }, | |
| 90 | services, | |
| 79 | 91 | prev: null |
| 80 | 92 | } |
| 81 | 93 | } |
| … | … | @@ -109,10 +121,10 @@ export function createAtprotoIdentityFromKey (privateKey, { handle, pdsUrl }) { |
| 109 | 121 | // recovery key (a genesis must be signed by one of its own rotationKeys). See |
| 110 | 122 | // atproto-base-REVOCATION.md §5. Returns { did, signedOp, signingKey, recoveryKey } — `signingKey` |
| 111 | 123 | // is distributed to attested devices; `recoveryKey` STAYS WITH THE ROOT. |
| 112 | export function createSwarmIdentity (mnemonic, { handle, pdsUrl }) { | |
| 124 | export function createSwarmIdentity (mnemonic, { handle, pdsUrl, feedGenEndpoint = null, labelerEndpoint = null, labelDidKey = null } = {}) { | |
| 113 | 125 | const signingKey = deriveAtprotoKey(mnemonic, ATPROTO_SIGNING_PATH) |
| 114 | 126 | const recoveryKey = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH) |
| 115 | const unsigned = buildGenesisOp({ signingDidKey: signingKey.didKey, rotationDidKeys: [recoveryKey.didKey], handle, pdsUrl }) | |
| 127 | const unsigned = buildGenesisOp({ signingDidKey: signingKey.didKey, rotationDidKeys: [recoveryKey.didKey], handle, pdsUrl, feedGenEndpoint, labelerEndpoint, labelDidKey }) | |
| 116 | 128 | const sig = signOp(unsigned, recoveryKey.privateKey) |
| 117 | 129 | const signedOp = { ...unsigned, sig } |
| 118 | 130 | const did = didForCreateOp(signedOp) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "atproto-identity", |
| 3 | "version": "0.0.1", | |
| 3 | "version": "0.0.2", | |
| 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,46 @@ |
| 1 | // Lens-export service entries in the did:plc genesis op — a self-custody feed-generator + labeler | |
| 2 | // (e.g. a favela community DID) declares #bsky_fg / #atproto_labeler services + a #atproto_label VM at | |
| 3 | // genesis. The PLC op's services/verificationMethods KEYS project to DID-doc fragments. Offline only — | |
| 4 | // no PLC submit. (favela-web/spec/lens-export.md §2/§5.) | |
| 5 | import test from 'brittle' | |
| 6 | import { createSwarmIdentity } from '../lib/atproto-identity.js' | |
| 7 | import { checkGenesisOp } from '../lib/plc-directory.js' | |
| 8 | ||
| 9 | const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' | |
| 10 | const HANDLE = 'catlovers.7rnx.net' | |
| 11 | const PDS = 'https://pds.7rnx.net' | |
| 12 | const MEMBRANE = 'https://pds.7rnx.net' | |
| 13 | ||
| 14 | test('genesis with feed-gen + labeler service entries', (t) => { | |
| 15 | const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE }) | |
| 16 | const op = id.signedOp | |
| 17 | ||
| 18 | // the PDS service + #atproto VM are unchanged | |
| 19 | t.is(op.services.atproto_pds.type, 'AtprotoPersonalDataServer', 'atproto_pds service preserved') | |
| 20 | t.is(op.verificationMethods.atproto, id.signingKey.didKey, '#atproto VM = the signing key') | |
| 21 | ||
| 22 | // NEW: feed generator + labeler services (keys → #bsky_fg / #atproto_labeler in the DID doc) | |
| 23 | t.alike(op.services.bsky_fg, { type: 'BskyFeedGenerator', endpoint: MEMBRANE }, '#bsky_fg service points at the membrane') | |
| 24 | t.alike(op.services.atproto_labeler, { type: 'AtprotoLabeler', endpoint: MEMBRANE }, '#atproto_labeler service points at the membrane') | |
| 25 | t.is(op.verificationMethods.atproto_label, id.signingKey.didKey, '#atproto_label VM defaults to the shared #atproto signing key') | |
| 26 | ||
| 27 | // the op is still a valid, self-consistent genesis (did derives from it) | |
| 28 | t.is(checkGenesisOp(op, id.did).ok, true, 'genesis op still valid with the extra services') | |
| 29 | t.ok(/^did:plc:/.test(id.did), 'did:plc derives') | |
| 30 | }) | |
| 31 | ||
| 32 | test('a distinct #atproto_label key can be supplied', (t) => { | |
| 33 | const other = createSwarmIdentity('legal winner thank year wave sausage worth useful legal winner thank yellow', { handle: 'x.7rnx.net', pdsUrl: PDS }) | |
| 34 | const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS, feedGenEndpoint: MEMBRANE, labelerEndpoint: MEMBRANE, labelDidKey: other.signingKey.didKey }) | |
| 35 | t.is(id.signedOp.verificationMethods.atproto_label, other.signingKey.didKey, '#atproto_label VM = the supplied distinct key') | |
| 36 | }) | |
| 37 | ||
| 38 | test('backward-compatible: no extras → plain genesis', (t) => { | |
| 39 | const id = createSwarmIdentity(MNEMONIC, { handle: HANDLE, pdsUrl: PDS }) | |
| 40 | const op = id.signedOp | |
| 41 | t.absent(op.services.bsky_fg, 'no #bsky_fg without an endpoint') | |
| 42 | t.absent(op.services.atproto_labeler, 'no #atproto_labeler without an endpoint') | |
| 43 | t.absent(op.verificationMethods.atproto_label, 'no #atproto_label without a labeler/key') | |
| 44 | t.is(Object.keys(op.services).length, 1, 'only the atproto_pds service') | |
| 45 | t.is(checkGenesisOp(op, id.did).ok, true, 'plain genesis still valid') | |
| 46 | }) |