OverviewFilesCommitsIssuesDocsReleasesPeers

trust-map / lib / trust-map.js

223 lines · javascript ref:
/**
 * TrustMap — pubkey → permissions store backed by Hyperbee.
 *
 * Two modes of access checking:
 *
 * 1. DIRECT: a peer's pubkey is directly registered with permissions.
 *    Used by services, CI runners, single-keypair clients.
 *
 *      tm.grant(pubkey, 'repo-x', ['read', 'write'])
 *      tm.check(pubkey, 'repo-x', 'write')   // → true
 *
 * 2. CHAIN: a peer presents a hyperident-style attestation chain (signed
 *    by their master identity via keet-identity-key). Trust-map verifies
 *    the chain, extracts the master (identityPublicKey), and looks it up.
 *    Devices inherit permissions from their master.
 *
 *      tm.grant(masterPubkey, 'repo-y', ['admin'])
 *      tm.checkChain({ proof, data, resource: 'repo-y', perm: 'admin' })
 *
 * The check() functions accept either Buffer or hex string pubkeys.
 *
 * Storage layout (Hyperbee, JSON values):
 *   trust:{pubkey-hex}:{resource}  → { perms: [...] }
 *
 * Wildcard resource '*' grants apply to ALL resources.
 */

import ReadyResource from 'ready-resource'
import Hyperbee from 'hyperbee'
import b4a from 'b4a'

export class TrustMap extends ReadyResource {
  /**
   * @param {object} opts
   * @param {Hypercore|Hyperbee} opts.core   - hypercore to wrap (or pass `bee`)
   * @param {Hyperbee} [opts.bee]            - existing hyperbee
   * @param {object} [opts.identityKey]      - keet-identity-key module for chain verify
   */
  constructor (opts = {}) {
    super()
    this._bee = opts.bee || null
    this._core = opts.core || null
    this._identityKey = opts.identityKey || null
  }

  async _open () {
    if (!this._bee && this._core) {
      this._bee = new Hyperbee(this._core, {
        keyEncoding: 'utf-8',
        valueEncoding: 'json'
      })
    }
    if (!this._bee) throw new Error('TrustMap requires `core` or `bee` opt')
    await this._bee.ready()
  }

  async _close () {
    // Don't close the bee/core — caller owns them
  }

  // ── Helpers ──────────────────────────────────────────────────

  static _toHex (pubkey) {
    if (typeof pubkey === 'string') return pubkey
    return b4a.toString(pubkey, 'hex')
  }

  _key (pubkey, resource) {
    return `trust:${TrustMap._toHex(pubkey)}:${resource}`
  }

  // ── Direct grants ────────────────────────────────────────────

  /**
   * Grant permissions to a pubkey for a resource.
   * @param {string|Buffer} pubkey
   * @param {string} resource - resource name, or '*' for all resources
   * @param {string[]} perms  - e.g. ['read', 'write', 'admin']
   */
  async grant (pubkey, resource, perms) {
    if (!Array.isArray(perms) || perms.length === 0) {
      throw new Error('perms must be a non-empty array')
    }
    const key = this._key(pubkey, resource)
    const existing = await this._bee.get(key)
    const merged = existing
      ? Array.from(new Set([...existing.value.perms, ...perms]))
      : [...perms]
    await this._bee.put(key, { perms: merged })
  }

  /**
   * Revoke specific permissions (or all if perms omitted) for a pubkey/resource.
   * @param {string|Buffer} pubkey
   * @param {string} resource
   * @param {string[]} [perms] - if omitted, removes all perms for this resource
   */
  async revoke (pubkey, resource, perms) {
    const key = this._key(pubkey, resource)
    const existing = await this._bee.get(key)
    if (!existing) return

    if (!perms || perms.length === 0) {
      await this._bee.del(key)
      return
    }
    const remaining = existing.value.perms.filter(p => !perms.includes(p))
    if (remaining.length === 0) {
      await this._bee.del(key)
    } else {
      await this._bee.put(key, { perms: remaining })
    }
  }

  /**
   * Check if a pubkey has a specific permission on a resource.
   * Falls back to wildcard '*' resource if exact match has no entry or
   * lacks the requested permission.
   * @param {string|Buffer} pubkey
   * @param {string} resource
   * @param {string} perm
   * @returns {Promise<boolean>}
   */
  async check (pubkey, resource, perm) {
    const exact = await this._bee.get(this._key(pubkey, resource))
    if (exact && exact.value.perms.includes(perm)) return true

    const wildcard = await this._bee.get(this._key(pubkey, '*'))
    if (wildcard && wildcard.value.perms.includes(perm)) return true

    return false
  }

  /**
   * List all entries for a pubkey across resources.
   * @param {string|Buffer} pubkey
   * @returns {Promise<Array<{ resource: string, perms: string[] }>>}
   */
  async listForPubkey (pubkey) {
    const hex = TrustMap._toHex(pubkey)
    const prefix = `trust:${hex}:`
    const out = []
    for await (const entry of this._bee.createReadStream({
      gte: prefix,
      lt: prefix + '\xff'
    })) {
      out.push({
        resource: entry.key.slice(prefix.length),
        perms: entry.value.perms
      })
    }
    return out
  }

  /**
   * List all trust map entries.
   * @returns {Promise<Array<{ pubkey: string, resource: string, perms: string[] }>>}
   */
  async list () {
    const out = []
    for await (const entry of this._bee.createReadStream({
      gte: 'trust:',
      lt: 'trust:\xff'
    })) {
      const parts = entry.key.split(':')
      // key format: trust:{pubkey-hex}:{resource}
      // resource may contain colons, so rejoin from index 2
      const pubkey = parts[1]
      const resource = parts.slice(2).join(':')
      out.push({ pubkey, resource, perms: entry.value.perms })
    }
    return out
  }

  // ── Chain verification (via keet-identity-key) ───────────────

  /**
   * Verify a hyperident attestation chain and check permissions for the
   * MASTER identity (root of the chain).
   *
   * Caller passes the proof bytes and the data that was signed. We use
   * keet-identity-key to verify the proof and extract the identityPublicKey
   * (master). We then look up the master in the trust map.
   *
   * @param {object} args
   * @param {Buffer} args.proof    - the attestation proof from hyperident
   * @param {Buffer} args.data     - the data that was signed
   * @param {string} args.resource - the resource being accessed
   * @param {string} args.perm     - the permission being checked
   * @returns {Promise<{ ok: boolean, masterPubkey?: string, devicePubkey?: string, reason?: string }>}
   */
  async checkChain ({ proof, data, resource, perm }) {
    if (!this._identityKey) {
      return { ok: false, reason: 'no identityKey module configured' }
    }
    if (!proof || !data) {
      return { ok: false, reason: 'proof and data required' }
    }

    let verification
    try {
      verification = this._identityKey.verify(proof, data)
    } catch (err) {
      return { ok: false, reason: 'verify threw: ' + err.message }
    }

    if (!verification) {
      return { ok: false, reason: 'invalid attestation' }
    }

    const masterPubkey = TrustMap._toHex(verification.identityPublicKey)
    const devicePubkey = TrustMap._toHex(verification.devicePublicKey)

    // Look up the master in the trust map (devices inherit master's permissions)
    const allowed = await this.check(masterPubkey, resource, perm)
    if (!allowed) {
      return { ok: false, masterPubkey, devicePubkey, reason: 'master not authorized for ' + perm + ' on ' + resource }
    }

    return { ok: true, masterPubkey, devicePubkey }
  }
}