OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / name-registry.js

559 lines · javascript ref:
/**
 * NameRegistry — Autobase-backed P2P app-name registry.
 *
 * A global shared registry mapping flat app names (e.g., "pear-git-v3")
 * to publisher identity + release-registry location. Names are claimed
 * TOFU: first writer to append `claim-name` for a given name owns it.
 * Apply rejects duplicate claims — Autobase linearization gives this
 * for free.
 *
 * Moderation: because Autobase only replays ops from registered writers,
 * a toxic writer can be REMOVED via `remove-writer` and their claims
 * vanish from the linearized view on next reindex. Invites (ADD_WRITER)
 * are the anti-spam gate — you choose who earns write access.
 *
 * Autobase shape (same pattern as ReleaseRegistry / IdentityRegistry):
 *   - Base keyed by the first writer's hypercore
 *   - Writers append JSON-encoded ops
 *   - Apply translates ops → Hyperbee view
 *
 * Ops:
 *   { type: 'claim-name', entry }          — claim a name for a publisher
 *   { type: 'update-name', name, updates } — publisher only: rotate registryKey/metadata
 *   { type: 'release-name', name }         — publisher only: tombstone, frees for reclaim
 *   { type: 'add-writer', publicKey }      — grant another device claim authority
 *   { type: 'remove-writer', publicKey }   — revoke write access
 *
 * Entry schema (for claim-name):
 *   name              — flat string; unique across the registry
 *   publisher: {
 *     devicePubkey     — hyperident device pubkey that signed the claim
 *     registryKey      — the publisher's ReleaseRegistry autobase key
 *     serverKey        — hyperident deploy topic (same as publisher's device pubkey today)
 *     identityRoot     — the publisher's identity root pubkey (survives device rotation)
 *   }
 *   description?       — human-readable summary
 *   claimedAt          — ms epoch
 *
 * Hyperbee view keys:
 *   claim:<name>                   → entry
 *   by-publisher:<idRoot>:<name>   → { name } (reverse index: "names this identity owns")
 *   by-device:<devicePubkey>:<name> → { name } (reverse index: "names this device signed")
 *   tombstone:<name>               → { releasedAt, releasedBy } (soft delete; reclaimable)
 */

import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import b4a from 'b4a'
import sodium from 'sodium-native'
// Imported UNCONDITIONALLY, and never injected. apply() must reach the same
// verdict on every peer replaying the same log — a registry that could not
// verify a chain would reject ops its peers accept, and the two views would
// diverge on identical history. Optional verification is not an option here.
import IdentityKey from 'keet-identity-key'

export const OP_CLAIM_NAME    = 'claim-name'
export const OP_UPDATE_NAME   = 'update-name'
export const OP_RELEASE_NAME  = 'release-name'
export const OP_REANCHOR_NAME = 'reanchor-name'
export const OP_ADD_WRITER    = 'add-writer'
export const OP_REMOVE_WRITER = 'remove-writer'

// Op version. Absent/1 = the legacy unsigned form, authorised by a bare string
// compare on `signedBy`. 2 = device-signed with an attestation chain back to
// the claim's identityRoot.
//
// Legacy ops are still accepted so that replaying EXISTING history reaches the
// same view it always did — `release-name` has been callable, so tombstones may
// already be in the log, and rejecting them on reindex would resurrect released
// names. Writers only ever emit v2. Closing the legacy path is a follow-up once
// the fleet is known to be on v2 (same in-band pattern as pgv3's schema guard).
export const OP_VERSION_SIGNED = 2


export class NameRegistry {
  /**
   * @param {Corestore} corestore
   * @param {object} [opts]
   * @param {string} [opts.namespace='name-registry']
   * @param {Buffer|string} [opts.bootstrap] — hex or Buffer autobase key. If
   *   omitted, creates a NEW registry and this node becomes the first writer.
   */
  constructor (corestore, opts = {}) {
    this._store = corestore.namespace(opts.namespace || 'name-registry')
    this._bootstrap = opts.bootstrap
      ? (Buffer.isBuffer(opts.bootstrap) ? opts.bootstrap : b4a.from(opts.bootstrap, 'hex'))
      : null
    this._base = null
    this._view = null
    this._ready = false
  }

  async ready () {
    if (this._ready) return

    const apply = async (nodes, view, base) => {
      for (const node of nodes) {
        let op
        try { op = JSON.parse(node.value.toString()) } catch { continue }

        if (op.type === OP_CLAIM_NAME) {
          const e = op.entry
          if (!e || !e.name || !e.publisher) continue
          if (!e.publisher.devicePubkey || !e.publisher.registryKey) continue

          const key = 'claim:' + e.name
          const existing = await view.get(key)

          // First-claim-wins. If a live claim exists, reject.
          // A tombstoned name (released by publisher) is reclaimable.
          if (existing) {
            const tomb = await view.get('tombstone:' + e.name)
            if (!tomb) continue // name actively owned — reject
            // tombstoned → allow reclaim, clear tombstone
            await view.del('tombstone:' + e.name)
          }

          const entry = {
            name: e.name,
            publisher: {
              devicePubkey: e.publisher.devicePubkey,
              registryKey: e.publisher.registryKey,
              serverKey: e.publisher.serverKey || e.publisher.devicePubkey,
              identityRoot: e.publisher.identityRoot || e.publisher.devicePubkey
            },
            description: e.description || '',
            claimedAt: e.claimedAt || Date.now()
          }
          await view.put(key, entry)
          await view.put('by-publisher:' + entry.publisher.identityRoot + ':' + e.name, { name: e.name })
          await view.put('by-device:' + entry.publisher.devicePubkey + ':' + e.name, { name: e.name })
        }

        if (op.type === OP_UPDATE_NAME) {
          if (!op.name || !op.signedBy) continue
          const existing = await view.get('claim:' + op.name)
          if (!existing) continue
          // v2: device signature + chain back to the claim's root.
          // Legacy: the original string compare. See OP_VERSION_SIGNED.
          if (!opAuthorised(op, existing.value)) continue

          // Deep-copy `publisher`: `{ ...existing.value }` is shallow, so
          // assigning through updated.publisher mutated the object read out of
          // the bee rather than a copy of it.
          const updated = {
            ...existing.value,
            publisher: { ...existing.value.publisher }
          }
          if (op.updates && typeof op.updates === 'object') {
            if (op.updates.registryKey) updated.publisher.registryKey = op.updates.registryKey
            if (op.updates.serverKey) updated.publisher.serverKey = op.updates.serverKey
            if (op.updates.description !== undefined) updated.description = op.updates.description
            updated.updatedAt = Date.now()
          }
          await view.put('claim:' + op.name, updated)
        }

        // Re-anchor a claim whose identityRoot still holds a DEVICE key
        // (written before org #142). Authorised by the key being replaced:
        // the stale anchor IS this device, so signing proves control of it,
        // and the chain proves which root that device belongs to. A claim
        // already anchored to a root fails the anchor===device test and is
        // left alone. See verifyReanchorOp.
        if (op.type === OP_REANCHOR_NAME) {
          if (!op.name) continue
          const existing = await view.get('claim:' + op.name)
          if (!existing) continue
          const claim = existing.value
          const newRoot = verifyReanchorOp(op, claim)
          if (!newRoot) continue

          const oldRoot = claim.publisher.identityRoot
          await view.put('claim:' + op.name, {
            ...claim,
            publisher: { ...claim.publisher, identityRoot: newRoot },
            updatedAt: Date.now()
          })
          // The reverse index is keyed by the anchor, so it has to move with
          // it — otherwise `listByPublisher(root)` misses the name and
          // `listByPublisher(oldDeviceKey)` keeps reporting it.
          await view.del('by-publisher:' + oldRoot + ':' + op.name)
          await view.put('by-publisher:' + newRoot + ':' + op.name, { name: op.name })
        }

        if (op.type === OP_RELEASE_NAME) {
          if (!op.name || !op.signedBy) continue
          const existing = await view.get('claim:' + op.name)
          if (!existing) continue
          if (!opAuthorised(op, existing.value)) continue
          // Don't delete the claim; tombstone allows audit + reclaim semantics
          await view.put('tombstone:' + op.name, {
            releasedAt: Date.now(),
            releasedBy: op.signedBy
          })
        }

        if (op.type === OP_ADD_WRITER && op.publicKey) {
          try {
            await base.addWriter(b4a.from(op.publicKey, 'hex'))
          } catch (err) {
            // eslint-disable-next-line no-console
            console.error('[name-registry] addWriter failed:', err.message)
          }
        }

        if (op.type === OP_REMOVE_WRITER && op.publicKey) {
          try {
            await base.removeWriter(b4a.from(op.publicKey, 'hex'))
          } catch (err) {
            // eslint-disable-next-line no-console
            console.error('[name-registry] removeWriter failed:', err.message)
          }
        }
      }
    }

    this._base = new Autobase(this._store, this._bootstrap, {
      open: (store) => new Hyperbee(store.get('view'), {
        keyEncoding: 'utf-8',
        valueEncoding: 'json'
      }),
      apply,
      valueEncoding: 'binary'
    })

    await this._base.ready()
    this._view = this._base.view
    this._ready = true
  }

  async close () {
    if (this._base) {
      try { await this._base.close() } catch {}
      this._base = null
    }
    this._view = null
    this._ready = false
  }

  get keyHex () {
    if (!this._base) return null
    return b4a.toString(this._base.key, 'hex')
  }

  get writable () {
    return !!(this._base && this._base.writable)
  }

  // ── Writes ───────────────────────────────────────────────────

  async claim (entry) {
    if (!this._ready) await this.ready()
    if (!entry || !entry.name || !entry.publisher) {
      throw new Error('claim requires { name, publisher: { devicePubkey, registryKey } }')
    }
    const op = {
      type: OP_CLAIM_NAME,
      entry: {
        ...entry,
        claimedAt: entry.claimedAt || Date.now()
      }
    }
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  async updateName (name, updates, signer) {
    if (!this._ready) await this.ready()
    if (!name) throw new Error('updateName requires { name }')
    const op = buildAuthOp(OP_UPDATE_NAME, { name, updates: updates || {} }, signer)
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  /**
   * Move a claim's anchor from the device key it was mistakenly written with
   * (org #142) to the identity root that attests that device.
   *
   * Only the device currently named as the anchor can do this, and only to
   * its own chain's root — see verifyReanchorOp. No-op on a claim that is
   * already root-anchored.
   */
  async reanchor (name, signer) {
    if (!this._ready) await this.ready()
    if (!name) throw new Error('reanchor requires { name }')
    if (!signer || typeof signer === 'string' || !signer.identityRoot ||
        !signer.devicePubkey || !signer.deviceSecretKey || !signer.chain) {
      throw new Error('reanchor requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain } — there is no unsigned form')
    }
    const op = signOp({
      type: OP_REANCHOR_NAME,
      v: OP_VERSION_SIGNED,
      name,
      newRoot: signer.identityRoot,
      devicePubkey: signer.devicePubkey,
      chain: signer.chain
    }, signer.deviceSecretKey)
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  async release (name, signer) {
    if (!this._ready) await this.ready()
    if (!name) throw new Error('release requires { name }')
    const op = buildAuthOp(OP_RELEASE_NAME, { name }, signer)
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  async addWriter (publicKey) {
    if (!this._ready) await this.ready()
    const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
    const op = { type: OP_ADD_WRITER, publicKey: hex }
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  async removeWriter (publicKey) {
    if (!this._ready) await this.ready()
    const hex = typeof publicKey === 'string' ? publicKey : b4a.toString(publicKey, 'hex')
    const op = { type: OP_REMOVE_WRITER, publicKey: hex }
    await this._base.append(b4a.from(JSON.stringify(op)))
    await this.update()
  }

  // ── Reads ────────────────────────────────────────────────────

  async resolve (name) {
    if (!this._ready) await this.ready()
    const entry = await this._view.get('claim:' + name)
    if (!entry) return null
    // If tombstoned, treat as unclaimed
    const tomb = await this._view.get('tombstone:' + name)
    if (tomb) return null
    return entry.value
  }

  async list (opts = {}) {
    if (!this._ready) await this.ready()
    const out = []
    for await (const entry of this._view.createReadStream({ gt: 'claim:', lt: 'claim:\xff' })) {
      const tomb = await this._view.get('tombstone:' + entry.value.name)
      if (tomb && !opts.includeReleased) continue
      if (opts.publisher && entry.value.publisher.identityRoot !== opts.publisher) continue
      out.push(entry.value)
    }
    return out
  }

  async listByPublisher (identityRoot) {
    return this.list({ publisher: identityRoot })
  }

  async update () {
    if (!this._ready) await this.ready()
    return this._base.update()
  }

  replicate (stream) {
    return this._base.replicate(stream)
  }

  get discoveryKey () {
    return this._base ? this._base.discoveryKey : null
  }
}

// ── Signed ops (v2) ────────────────────────────────────────────
//
// A name is owned by an identity ROOT, but the root key is not available to
// sign with: a linked device holds only its own device key plus a keet proof
// that the root attested it. So the op is signed by the DEVICE and carries the
// chain, exactly as pear-git-v3 authenticates envelopes
// (lib/auth/keet-verifier.js). Verification resolves device -> root and then
// checks that root against the claim.

/**
 * Build an authorised update/release op.
 *
 * `signer` is the identity acting: `{ identityRoot, devicePubkey,
 * deviceSecretKey, chain }` — exactly the fields loadOrCreateIdentity already
 * returns. Produces a v2 signed op.
 *
 * A bare string is accepted as the legacy `signedBy` and produces an UNSIGNED
 * op. That path exists only so callers not yet migrated keep working; it will
 * stop being authorised when the legacy gate closes.
 */
export function buildAuthOp (type, fields, signer) {
  if (typeof signer === 'string') {
    if (!signer) throw new Error(type + ' requires a signer')
    return { type, ...fields, signedBy: signer }
  }

  if (!signer || !signer.identityRoot) {
    throw new Error(type + ' requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain }')
  }
  const { identityRoot, devicePubkey, deviceSecretKey, chain } = signer
  if (!devicePubkey || !deviceSecretKey || !chain) {
    // Refuse rather than silently emitting an op that apply will drop — a
    // dropped op is indistinguishable from replication lag at the call site.
    throw new Error(
      type + ': signer is missing ' +
      [!devicePubkey && 'devicePubkey', !deviceSecretKey && 'deviceSecretKey', !chain && 'chain']
        .filter(Boolean).join(', ') +
      ' (an identity with no attestation chain cannot prove it owns a name)'
    )
  }

  return signOp({
    type,
    v: OP_VERSION_SIGNED,
    ...fields,
    signedBy: identityRoot,
    devicePubkey,
    chain
  }, deviceSecretKey)
}

/**
 * Canonical bytes an op's signature covers: every field except `sig`, keys
 * sorted, JSON. Same shape as the release registry's canonicalBytes.
 */
export function canonicalOpBytes (op) {
  const { sig, ...rest } = op // eslint-disable-line no-unused-vars
  const ordered = {}
  for (const k of Object.keys(rest).sort()) ordered[k] = rest[k]
  return b4a.from(JSON.stringify(ordered))
}

/**
 * Sign an op with a device secret key, returning a copy carrying `sig`.
 */
export function signOp (op, deviceSecretKey) {
  const sig = b4a.alloc(sodium.crypto_sign_BYTES)
  const secret = b4a.isBuffer(deviceSecretKey)
    ? deviceSecretKey
    : b4a.from(deviceSecretKey, 'hex')
  sodium.crypto_sign_detached(sig, canonicalOpBytes(op), secret)
  return { ...op, sig: b4a.toString(sig, 'hex') }
}

/**
 * Is this op authorised to act on `claim`?
 *
 * Returns true only when the op is signed by a device that the claim's
 * identityRoot has attested. Fail-closed: any malformed field, any failed
 * check, any thrown verifier is a refusal.
 *
 * Deterministic and side-effect free — it is called from apply(), so every
 * peer must reach the same verdict from the same bytes.
 */
export function verifySignedOp (op, claim) {
  if (!op || !claim || !claim.publisher) return false
  const root = claim.publisher.identityRoot
  if (!root || typeof op.signedBy !== 'string') return false

  // The op must name the root that actually owns the claim.
  if (op.signedBy !== root) return false

  if (typeof op.devicePubkey !== 'string' ||
      typeof op.chain !== 'string' ||
      typeof op.sig !== 'string') {
    return false
  }

  // 1. The chain proves "root R attested device D".
  let verified
  try {
    const proof = b4a.from(op.chain, 'base64')
    if (!proof || proof.length === 0) return false
    verified = IdentityKey.verify(proof)
  } catch {
    return false
  }
  if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return false

  // 2. That R is the claim's root, and that D is who the op says signed it.
  //    Without the second check the chain proves nothing about THIS op.
  if (b4a.toString(verified.identityPublicKey, 'hex') !== root) return false
  if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return false

  // 3. D actually signed these exact bytes.
  try {
    const sig = b4a.from(op.sig, 'hex')
    if (sig.length !== sodium.crypto_sign_BYTES) return false
    const devicePub = b4a.from(op.devicePubkey, 'hex')
    if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return false
    return sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub)
  } catch {
    return false
  }
}

/**
 * Verify a re-anchor op against the claim it targets.
 *
 * Distinct from verifySignedOp because that one requires the chain's root to
 * equal the claim's anchor — which is false by construction here: the whole
 * point is that the anchor is a DEVICE key, not a root.
 *
 * Fail-closed; returns the new root on success, null otherwise.
 *
 * @returns {string|null} the verified root (hex), or null
 */
export function verifyReanchorOp (op, claim) {
  if (!op || !claim || !claim.publisher) return null
  if (!(op.v >= OP_VERSION_SIGNED)) return null
  if (typeof op.devicePubkey !== 'string' || typeof op.chain !== 'string' ||
      typeof op.sig !== 'string' || typeof op.newRoot !== 'string') {
    return null
  }

  // The stale anchor must BE this device. This is the authorisation: the
  // signature below proves control of the very key being replaced. It also
  // makes the op a no-op on an already-root-anchored claim, since a root is
  // not a device key anyone can sign with here.
  if (claim.publisher.identityRoot !== op.devicePubkey) return null

  let verified
  try {
    const proof = b4a.from(op.chain, 'base64')
    if (!proof || proof.length === 0) return null
    verified = IdentityKey.verify(proof)
  } catch {
    return null
  }
  if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return null

  // The chain must describe THIS device, and the op may only name the root
  // that chain actually attests — otherwise a name could be re-pointed at an
  // arbitrary identity.
  if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return null
  const root = b4a.toString(verified.identityPublicKey, 'hex')
  if (root !== op.newRoot) return null

  try {
    const sig = b4a.from(op.sig, 'hex')
    if (sig.length !== sodium.crypto_sign_BYTES) return null
    const devicePub = b4a.from(op.devicePubkey, 'hex')
    if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return null
    if (!sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub)) return null
  } catch {
    return null
  }

  return root
}

/**
 * Authorisation gate shared by update-name and release-name.
 *
 * v2 ops must carry a valid device signature + chain. Legacy ops (no `v`)
 * keep the original string compare so replaying existing history is unchanged
 * — see OP_VERSION_SIGNED for why that path still exists.
 */
export function opAuthorised (op, claim) {
  if (op && op.v >= OP_VERSION_SIGNED) return verifySignedOp(op, claim)
  return !!(claim && claim.publisher && claim.publisher.identityRoot === op.signedBy)
}