OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-registry / lib / registry.js

372 lines · javascript ref:
/**
 * Hyper-Registry - P2P Name Registry using Autobase
 *
 * STATUS: Implementation sketch - not yet functional
 */

import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import crypto from 'hypercore-crypto'
import { mineProof, validateProof, getDifficulty } from './proof-of-work.js'

export class HyperRegistry {
  constructor(corestore, options = {}) {
    this.store = corestore
    this.bootstrapKeys = options.bootstrapKeys || []
    this.namespace = null
  }

  async ready() {
    // Local writer for this device
    this.localWriter = this.store.get({ name: 'hyper-registry-writer' })
    await this.localWriter.ready()

    // Create Autobase
    this.base = new Autobase(this.store, {
      localInput: this.localWriter,
      autostart: true,
      apply: this._apply.bind(this),
      valueEncoding: 'json',
      open: (store) => new Hyperbee(store.get('registry-view'), {
        keyEncoding: 'utf-8',
        valueEncoding: 'json'
      })
    })

    await this.base.ready()

    // Add bootstrap writers
    for (const key of this.bootstrapKeys) {
      try {
        await this.base.addInput(Buffer.from(key, 'hex'))
      } catch (err) {
        console.warn(`Failed to add bootstrap: ${key.slice(0, 8)}...`)
      }
    }

    // Load our namespace
    await this._loadLocalNamespace()
  }

  async _loadLocalNamespace() {
    const pubkey = this.localWriter.key.toString('hex')

    for await (const entry of this.base.view.createReadStream({
      gte: 'name:',
      lt: 'name:\xff'
    })) {
      const ns = entry.value
      if (ns.devices && ns.devices[pubkey]) {
        this.namespace = entry.key.replace('name:', '')
        console.log(`Loaded namespace: ${this.namespace}`)
        return
      }
    }
  }

  // === Autobase Apply Function ===

  async _apply(batch, view, base) {
    for (const node of batch) {
      const op = node.value
      const writer = node.from.key.toString('hex')
      const seq = base.linearizer.indexedLength

      try {
        switch (op.type) {
          case 'claim-namespace':
            await this._applyClaim(op, writer, seq, view)
            break
          case 'register-resource':
            await this._applyRegister(op, writer, view)
            break
          case 'authorize-device':
            await this._applyAuthorize(op, writer, view)
            break
          case 'revoke-device':
            await this._applyRevoke(op, writer, view)
            break
        }
      } catch (err) {
        console.error(`Apply error: ${err.message}`)
      }
    }
  }

  async _applyClaim(op, writer, seq, view) {
    const { name, rootKey, proof, initialDevice, rootSignature } = op

    // Validate name format
    if (!this._isValidName(name)) return

    // Check not already claimed
    const existing = await view.get(`name:${name}`)
    if (existing) return

    // Validate proof-of-work against ROOT key
    const difficulty = getDifficulty(name)
    if (!validateProof(name, rootKey, proof, difficulty)) return

    // Validate initial device is signed by root
    if (!this._verifySignature(initialDevice, rootKey, rootSignature)) return

    // Accept claim
    await view.put(`name:${name}`, {
      owner: rootKey,
      claimedAt: Date.now(),
      seq,
      proofNonce: proof.nonce,
      devices: {
        [initialDevice.key]: {
          name: initialDevice.name,
          authorizedAt: Date.now(),
          authorizedBy: 'root',
          canAuthorize: initialDevice.canAuthorize !== false,
          canRevoke: initialDevice.canRevoke !== false,
          resources: initialDevice.resources || null
        }
      },
      revoked: {}
    })

    console.log(`[Registry] Namespace claimed: ${name}`)
  }

  async _applyRegister(op, writer, view) {
    const { namespace, resource, metadata } = op

    const ns = await view.get(`name:${namespace}`)
    if (!ns) return

    // Verify writer is authorized device
    const device = ns.value.devices[writer]
    if (!device) return

    // Check device can write this resource
    if (device.resources && !device.resources.includes(resource)) return

    // Derive topic from ROOT key for consistency
    const topic = this._deriveTopic(ns.value.owner, resource)

    await view.put(`resource:${namespace}/${resource}`, {
      namespace,
      resource,
      owner: ns.value.owner,
      registeredBy: writer,
      topic: topic.toString('hex'),
      metadata: metadata || {},
      registeredAt: Date.now()
    })

    console.log(`[Registry] Resource registered: ${namespace}/${resource}`)
  }

  async _applyAuthorize(op, writer, view) {
    const { namespace, device, authorizedBy, signature } = op

    const ns = await view.get(`name:${namespace}`)
    if (!ns) return

    // Verify authorizer has permission
    const authorizer = ns.value.devices[authorizedBy]
    const isRoot = authorizedBy === ns.value.owner

    if (!isRoot && (!authorizer || !authorizer.canAuthorize)) return

    // Verify signature
    if (!this._verifyDeviceAuth(device, authorizedBy, signature)) return

    // Check not already present
    if (ns.value.devices[device.key]) return
    if (ns.value.revoked[device.key]) return

    // Add device
    ns.value.devices[device.key] = {
      name: device.name,
      authorizedAt: Date.now(),
      authorizedBy,
      canAuthorize: device.canAuthorize || false,
      canRevoke: device.canRevoke || false,
      resources: device.resources || null
    }

    await view.put(`name:${namespace}`, ns.value)
    console.log(`[Registry] Device authorized: ${device.name}`)
  }

  async _applyRevoke(op, writer, view) {
    const { namespace, deviceKey, reason, revokedBy, signature } = op

    const ns = await view.get(`name:${namespace}`)
    if (!ns) return

    // Verify revoker has permission
    const revoker = ns.value.devices[revokedBy]
    const isRoot = revokedBy === ns.value.owner

    if (!isRoot && (!revoker || !revoker.canRevoke)) return

    // Can't revoke self (unless root)
    if (deviceKey === revokedBy && !isRoot) return

    // Can't revoke root
    if (deviceKey === ns.value.owner) return

    // Verify signature
    if (!this._verifyRevocation(deviceKey, revokedBy, signature)) return

    // Move to revoked
    const device = ns.value.devices[deviceKey]
    if (!device) return

    delete ns.value.devices[deviceKey]
    ns.value.revoked[deviceKey] = {
      ...device,
      revokedAt: Date.now(),
      revokedBy,
      reason
    }

    await view.put(`name:${namespace}`, ns.value)
    console.log(`[Registry] Device revoked: ${device.name}`)
  }

  // === Helper Methods ===

  _deriveTopic(rootPubkey, resourceName) {
    return crypto.hash(Buffer.concat([
      Buffer.from(rootPubkey, 'hex'),
      Buffer.from(resourceName, 'utf-8')
    ])).subarray(0, 32)
  }

  _isValidName(name) {
    return /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(name) ||
           /^[a-z0-9]$/.test(name)
  }

  _verifySignature(data, pubkey, signature) {
    // TODO: Implement ed25519 signature verification
    return true
  }

  _verifyDeviceAuth(device, authorizer, signature) {
    // TODO: Implement
    return true
  }

  _verifyRevocation(deviceKey, revoker, signature) {
    // TODO: Implement
    return true
  }

  // === Public API ===

  async claimNamespace(name, options = {}) {
    if (!this._isValidName(name)) {
      throw new Error('Invalid namespace name')
    }

    const existing = await this.base.view.get(`name:${name}`)
    if (existing) {
      throw new Error(`Namespace "${name}" already claimed`)
    }

    // TODO: Handle mnemonic-derived root key
    const rootKey = options.rootKey || this.localWriter.key.toString('hex')
    const difficulty = getDifficulty(name)

    console.log(`Mining proof for "${name}" (difficulty: ${difficulty})...`)
    const proof = await mineProof(name, rootKey, difficulty)

    const deviceKey = this.localWriter.key.toString('hex')
    const initialDevice = {
      key: deviceKey,
      name: options.deviceName || 'primary',
      canAuthorize: true,
      canRevoke: true
    }

    await this.base.append({
      type: 'claim-namespace',
      name,
      rootKey,
      proof,
      initialDevice,
      rootSignature: 'TODO'
    })

    await this.base.update()
    this.namespace = name

    return { namespace: name, rootKey, deviceKey }
  }

  async registerResource(resource, metadata = {}) {
    if (!this.namespace) {
      throw new Error('No namespace claimed')
    }

    await this.base.append({
      type: 'register-resource',
      namespace: this.namespace,
      resource,
      metadata
    })

    await this.base.update()

    const entry = await this.base.view.get(`resource:${this.namespace}/${resource}`)
    return {
      name: `${this.namespace}/${resource}`,
      topic: entry.value.topic
    }
  }

  async resolve(path) {
    const entry = await this.base.view.get(`resource:${path}`)
    if (!entry) return null

    return {
      ...entry.value,
      topicBuffer: Buffer.from(entry.value.topic, 'hex')
    }
  }

  async resolveNamespace(name) {
    const entry = await this.base.view.get(`name:${name}`)
    return entry ? entry.value : null
  }

  async authorizeDevice(devicePubkey, options = {}) {
    // TODO: Implement
    throw new Error('Not implemented')
  }

  async revokeDevice(devicePubkey, reason = '') {
    // TODO: Implement
    throw new Error('Not implemented')
  }

  async listDevices() {
    if (!this.namespace) return []

    const ns = await this.resolveNamespace(this.namespace)
    if (!ns) return []

    return Object.entries(ns.devices).map(([key, info]) => ({
      key,
      ...info,
      isCurrentDevice: key === this.localWriter.key.toString('hex')
    }))
  }

  getPublicKey() {
    return this.localWriter.key.toString('hex')
  }

  getNamespace() {
    return this.namespace
  }
}