// Seed-anchored atproto identity — the secp256k1 / did:plc half.
//
// One BIP39 mnemonic anchors both worlds:
// mnemonic -> Ed25519 (keet-identity-key) -> Hypercore log signing [NEXT increment]
// mnemonic -> secp256k1 (this module) -> did:plc + atproto commits
//
// This module owns the atproto side: deterministic secp256k1 derivation, did:key
// encoding, and building/signing a did:plc genesis operation — entirely offline.
// It never submits to the PLC directory (the op-log is permanent; don't churn).
import { mnemonicToSeedSync } from '@scure/bip39'
import { HDKey } from '@scure/bip32'
import { secp256k1 } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha2'
import { base64url } from 'multiformats/bases/base64'
import { encodeDidKey, CURVE_SECP256K1 } from './did-key.js'
import { encodeOp, unsignedOp, didForCreateOp, opCid } from './did-plc.js'
// Our fixed derivation path. No BIP44 coin type is registered for atproto, so this
// is a project convention — the *mnemonic* is the recovery anchor; the path is fixed
// by this constant so the same phrase always yields the same atproto key.
export const ATPROTO_SIGNING_PATH = "m/44'/0'/0'/0/0"
// A SEPARATE derivation for the swarm's ROOT recovery/rotation key — held only by the root, NEVER
// distributed to devices. Distinct account index ('1') so a device that holds the signing key
// cannot derive this. See atproto-base-REVOCATION.md §4-5.
export const ATPROTO_RECOVERY_PATH = "m/44'/0'/1'/0/0"
// raw base64url (no multibase prefix, no padding) — atproto's sig encoding.
function sigToBase64url (bytes) {
return base64url.encode(bytes).slice(1) // drop multibase 'u'
}
export function base64urlToBytes (str) {
return base64url.decode('u' + str)
}
// mnemonic -> deterministic secp256k1 keypair for atproto signing.
export function deriveAtprotoKey (mnemonic, path = ATPROTO_SIGNING_PATH) {
const seed = mnemonicToSeedSync(mnemonic)
const node = HDKey.fromMasterSeed(seed).derive(path)
if (!node.privateKey) throw new Error('derivation produced no private key')
return keyFromPrivate(node.privateKey)
}
// raw 32-byte secp256k1 private key -> { privateKey, publicKey, didKey }.
export function keyFromPrivate (privateKey) {
const publicKey = secp256k1.getPublicKey(privateKey, true) // 33-byte compressed
return { privateKey, publicKey, didKey: encodeDidKey(CURVE_SECP256K1, publicKey) }
}
// A fresh random atproto signing keypair (server-minted accounts with no seed anchor).
export function generateAtprotoKey () {
return keyFromPrivate(secp256k1.utils.randomPrivateKey())
}
// ECDSA sign the sha256 of DAG-CBOR(unsignedOp), low-S, compact -> base64url.
export function signOp (unsigned, privateKey) {
const hash = sha256(encodeOp(unsigned))
const sig = secp256k1.sign(hash, privateKey, { lowS: true })
return sigToBase64url(sig.toCompactRawBytes())
}
// verify a signature string against an unsigned op + did:key-encoded pubkey bytes.
export function verifyOp (unsigned, sigB64url, publicKey) {
const hash = sha256(encodeOp(unsigned))
return secp256k1.verify(base64urlToBytes(sigB64url), hash, publicKey, { lowS: true })
}
// Build an UNSIGNED did:plc genesis op. rotationKey defaults to the signing key
// (single-key setup); pass a distinct rotation didKey for key-separation.
// A HANDLE AND A PDS ARE OPTIONAL, and omitting both is the P2P-only genesis (hyper-social #54 §8):
// an identity that names nobody's infrastructure. Creating a did:plc and joining someone's service are
// different decisions, and requiring both at once is what made minting undecidable — you could not have
// an identity without first answering "whose server are you on?". Accepting an invite from
// atproto-bridge or ap-bridge is a LATER PLC UPDATE op that adds the domain handle and the endpoint, so
// creation stays free and joining stays reversible.
//
// ⛔ THEY USED TO BE UNCONDITIONAL, and the two failure modes were not equally survivable. A missing
// pdsUrl put the VALUE `undefined` in the op and dag-cbor refused to encode it — loud, and safe. A
// missing handle produced the STRING 'at://undefined', which encodes perfectly and would have been
// appended to a global, permanent, public log. The silent one is the one this guards.
//
// The directory requires neither: checkGenesisOp (lib/plc-directory.js) asks only for a plc_operation
// with prev:null, rotationKeys, a verificationMethods.atproto and a self-signature.
export function buildGenesisOp ({ signingDidKey, rotationDidKeys, handle = null, pdsUrl = null, labelDidKey = null, feedGenEndpoint = null, labelerEndpoint = null }) {
const verificationMethods = { atproto: signingDidKey }
const services = {}
if (pdsUrl) services.atproto_pds = { type: 'AtprotoPersonalDataServer', endpoint: pdsUrl }
// Lens-export service entries (a self-custody feed-generator + labeler — e.g. a favela community DID).
// The PLC op's `services`/`verificationMethods` KEYS become DID-doc fragments: services.bsky_fg → the
// #bsky_fg service, verificationMethods.atproto_label → the #atproto_label VM. Endpoints point at where
// the getFeedSkeleton / queryLabels+subscribeLabels XRPC is served (the membrane). #atproto_label
// defaults to the #atproto signing key (labels co-signed by the shared key) unless a distinct key is given.
if (feedGenEndpoint) services.bsky_fg = { type: 'BskyFeedGenerator', endpoint: feedGenEndpoint }
if (labelerEndpoint) {
services.atproto_labeler = { type: 'AtprotoLabeler', endpoint: labelerEndpoint }
verificationMethods.atproto_label = labelDidKey || signingDidKey
} else if (labelDidKey) {
verificationMethods.atproto_label = labelDidKey
}
return {
type: 'plc_operation',
rotationKeys: rotationDidKeys && rotationDidKeys.length ? rotationDidKeys : [signingDidKey],
verificationMethods,
// Empty, not absent: an identity with no handle claims no alias. The domain handle arrives with the
// invite, as an update op — it is not a thing you are born with.
alsoKnownAs: handle ? [`at://${handle}`] : [],
services,
prev: null
}
}
// key { privateKey, publicKey, didKey } -> signed genesis op + did:plc.
export function identityFromKey (key, { handle, pdsUrl }) {
const unsigned = buildGenesisOp({ signingDidKey: key.didKey, handle, pdsUrl })
const sig = signOp(unsigned, key.privateKey)
const signedOp = { ...unsigned, sig }
const did = didForCreateOp(signedOp)
return { did, didKey: key.didKey, publicKey: key.publicKey, privateKey: key.privateKey, signedOp }
}
// Full offline flow: mnemonic -> signed genesis op + did:plc.
export function createAtprotoIdentity (mnemonic, { handle, pdsUrl, path = ATPROTO_SIGNING_PATH }) {
return identityFromKey(deriveAtprotoKey(mnemonic, path), { handle, pdsUrl })
}
// Same, but from a raw private key (server-minted / bring-your-own accounts).
export function createAtprotoIdentityFromKey (privateKey, { handle, pdsUrl }) {
return identityFromKey(keyFromPrivate(privateKey), { handle, pdsUrl })
}
// The atproto-base SWARM identity: ONE did:plc whose #atproto signing key is SHARED among N
// attested devices, but whose ROTATION authority is a SEPARATE root recovery key that is NEVER
// distributed to devices. Devices sign commits (verificationMethods.atproto = shared signing key)
// but have ZERO PLC authority — only the root (recovery key) can rotate/revoke. Revocation = the
// root rotates the shared signing key via a PLC update; a device holds only the old signing key,
// not the root, so it cannot self-recover. Contrast createAtprotoIdentity (rotationKeys =
// [signingKey], no separation — a device could rotate the DID). The genesis is signed by the
// recovery key (a genesis must be signed by one of its own rotationKeys). See
// atproto-base-REVOCATION.md §5. Returns { did, signedOp, signingKey, recoveryKey } — `signingKey`
// is distributed to attested devices; `recoveryKey` STAYS WITH THE ROOT.
export function createSwarmIdentity (mnemonic, { handle, pdsUrl, feedGenEndpoint = null, labelerEndpoint = null, labelDidKey = null } = {}) {
const signingKey = deriveAtprotoKey(mnemonic, ATPROTO_SIGNING_PATH)
const recoveryKey = deriveAtprotoKey(mnemonic, ATPROTO_RECOVERY_PATH)
const unsigned = buildGenesisOp({ signingDidKey: signingKey.didKey, rotationDidKeys: [recoveryKey.didKey], handle, pdsUrl, feedGenEndpoint, labelerEndpoint, labelDidKey })
const sig = signOp(unsigned, recoveryKey.privateKey)
const signedOp = { ...unsigned, sig }
const did = didForCreateOp(signedOp)
return { did, signedOp, signingKey, recoveryKey, didKey: signingKey.didKey }
}
// ── Post-genesis PLC operations (device attestation = rotation, NOT genesis) ──
//
// A did:plc is fixed at genesis; you evolve it with UPDATE operations that chain via
// `prev` (the CID of the previous signed op) and are signed by one of the CURRENT
// rotationKeys. This is how a device signing key is attested AFTER genesis without
// changing the DID — the atproto-correct model (see DESIGN.md §4a). The genesis stays
// deterministic (root-derived signing key → reproducible DID); the device key arrives
// as a rotation of verificationMethods.atproto.
// Build an UNSIGNED update op. `prevSignedOp` is the previous op in the log (its CID
// becomes `prev`). The remaining fields are the FULL new operation state — carry
// forward what you're not changing (a PLC op replaces the whole doc state, it is not a
// patch). This module never submits to the PLC directory; it only builds/signs offline.
export function buildUpdateOp ({ prevSignedOp, rotationDidKeys, verificationMethods, alsoKnownAs, services }) {
if (!prevSignedOp) throw new Error('buildUpdateOp: prevSignedOp required')
return {
type: 'plc_operation',
rotationKeys: rotationDidKeys,
verificationMethods,
alsoKnownAs,
services,
prev: opCid(prevSignedOp)
}
}
// Attest a device by ROTATING verificationMethods.atproto to the device's did:key,
// carrying every other field forward from `prevSignedOp`, signed by a rotation key
// (the root — `signerPrivateKey` must correspond to one of prevSignedOp.rotationKeys).
// The DID is UNCHANGED. Returns the signed update op ready to submit to the PLC log.
export function rotateVerificationMethod ({ prevSignedOp, newAtprotoDidKey, signerPrivateKey }) {
const unsigned = buildUpdateOp({
prevSignedOp,
rotationDidKeys: prevSignedOp.rotationKeys,
verificationMethods: { ...prevSignedOp.verificationMethods, atproto: newAtprotoDidKey },
alsoKnownAs: prevSignedOp.alsoKnownAs,
services: prevSignedOp.services
})
const sig = signOp(unsigned, signerPrivateKey)
return { ...unsigned, sig }
}
// Full self-custody onboarding — the atproto-correct device-attested model. A DETERMINISTIC,
// root-derived genesis pins the DID (reproducible from rootMnemonic+handle+pdsUrl), then a
// POST-genesis rotation attests a per-device signing key. The DID is f(root,handle,pds) — NOT
// f(device key) — so it reproduces across runs and can span multiple devices. The root key
// signs the genesis + the rotation and is then discarded (the user keeps it via hyperident);
// the peer holds ONLY the device key. This supersedes buildAttestedIdentity, which baked the
// device key INTO the genesis (making did:plc = f(device key) — non-reproducible, single-device).
// Pass a device secp256k1 privateKey to pin it; otherwise a fresh device key is minted.
export function selfCustodyIdentity ({ rootMnemonic, rootKey, handle, pdsUrl, deviceKey, path = ATPROTO_SIGNING_PATH }) {
const genesis = rootKey
? createAtprotoIdentityFromKey(rootKey, { handle, pdsUrl })
: createAtprotoIdentity(rootMnemonic, { handle, pdsUrl, path })
const device = deviceKey ? keyFromPrivate(deviceKey) : generateAtprotoKey()
const rotationOp = rotateVerificationMethod({
prevSignedOp: genesis.signedOp,
newAtprotoDidKey: device.didKey,
signerPrivateKey: genesis.privateKey // the root — a rotationKey of the genesis
})
return {
did: genesis.did, // = f(root, handle, pds) — reproducible, device-independent
genesisOp: genesis.signedOp, // vm = root-derived key, signed by root, prev:null
rotationOp, // vm → device key, signed by root, prev = cid(genesis)
deviceKey: device, // { privateKey, publicKey, didKey } — the peer holds THIS
rootDidKey: genesis.didKey, // the root (rotation / recovery) key's did:key
rootPublicKey: genesis.publicKey,
rotationKeys: genesis.signedOp.rotationKeys
}
}
export { unsignedOp, didForCreateOp, opCid }