OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity / lib / atproto-identity.js

222 lines · javascript ref:
// 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 }