OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / identity / attestation.js

336 lines · javascript ref:
/**
 * Key Attestation Protocol
 *
 * The cryptographic bridge between Ed25519 (Hyperswarm/P2P identity)
 * and secp256k1 (Lightning/Nostr/Bitcoin identity).
 *
 * A developer proves they control a secp256k1 key (via Lightning wallet)
 * and then signs an attestation binding their Ed25519 app key(s) to that
 * identity. The relay stores and serves these attestations so any peer
 * can verify "this app belongs to this developer."
 *
 * Attestation lifecycle:
 *   1. Developer authenticates via LNURL-auth (proves secp256k1 ownership)
 *   2. Developer submits attestation: signs(ed25519AppKey, secp256k1Pubkey, timestamp)
 *   3. Relay verifies signature and stores the binding
 *   4. Any peer can query: "who owns this app key?" → returns developer identity
 *   5. Attestations can be revoked by the developer
 *
 * Wire format (stored + transmitted):
 *   {
 *     version: 1,
 *     type: 'app-key-attestation',
 *     appKey: string (Ed25519 hex, 64 chars),
 *     developerKey: string (secp256k1 x-only hex, 64 chars),
 *     timestamp: number (unix seconds),
 *     signature: string (Schnorr sig hex, 128 chars),
 *     metadata: { appName?, appDescription?, appVersion? }
 *   }
 */

import { EventEmitter } from 'events'
import { readFile, writeFile, rename, unlink, mkdir } from 'fs/promises'
import { join } from 'path'
import {
  schnorrSign,
  schnorrVerify,
  createAttestationMessage
} from './crypto.js'

export class AttestationService extends EventEmitter {
  constructor (opts = {}) {
    super()
    this.storagePath = opts.storagePath || null
    // developerKey (secp256k1 hex) → { profile, appKeys: Map<appKey, attestation> }
    this.developers = new Map()
    // appKey (Ed25519 hex) → developerKey (secp256k1 hex) — reverse index
    this.appKeyIndex = new Map()
    // Attestation expiry (0 = never expire, default: 1 year)
    this.maxAge = opts.maxAge || 365 * 24 * 60 * 60
  }

  /**
   * Create and sign a new attestation binding an Ed25519 app key
   * to a secp256k1 developer identity.
   *
   * @param {string} appKey - Ed25519 app public key (hex)
   * @param {string} developerKey - secp256k1 x-only public key (hex)
   * @param {string} privateKey - secp256k1 private key (hex) — only used client-side
   * @param {object} metadata - Optional app metadata { appName, appDescription, appVersion }
   * @returns {object} Signed attestation object
   */
  async createAttestation (appKey, developerKey, privateKey, metadata = {}) {
    if (!appKey || appKey.length !== 64) throw new Error('INVALID_APP_KEY')
    if (!developerKey || developerKey.length !== 64) throw new Error('INVALID_DEVELOPER_KEY')

    const timestamp = Math.floor(Date.now() / 1000)
    const message = createAttestationMessage(appKey, developerKey, timestamp)
    const signature = await schnorrSign(message, privateKey)

    return {
      version: 1,
      type: 'app-key-attestation',
      appKey,
      developerKey,
      timestamp,
      signature,
      metadata: {
        appName: metadata.appName || null,
        appDescription: metadata.appDescription || null,
        appVersion: metadata.appVersion || null
      }
    }
  }

  /**
   * Verify and store an attestation submitted by a developer.
   * Called by the relay when a developer submits a signed attestation.
   *
   * @param {object} attestation - Signed attestation object
   * @returns {{ valid: boolean, reason?: string }}
   */
  async submitAttestation (attestation) {
    // Validate structure
    if (!attestation || attestation.version !== 1 || attestation.type !== 'app-key-attestation') {
      return { valid: false, reason: 'INVALID_FORMAT' }
    }

    const { appKey, developerKey, timestamp, signature, metadata } = attestation

    if (!appKey || appKey.length !== 64) return { valid: false, reason: 'INVALID_APP_KEY' }
    if (!developerKey || developerKey.length !== 64) return { valid: false, reason: 'INVALID_DEVELOPER_KEY' }
    if (!signature || signature.length !== 128) return { valid: false, reason: 'INVALID_SIGNATURE' }
    if (!timestamp || typeof timestamp !== 'number') return { valid: false, reason: 'INVALID_TIMESTAMP' }

    // Check timestamp freshness (reject if too old or in the future)
    const now = Math.floor(Date.now() / 1000)
    if (timestamp > now + 300) return { valid: false, reason: 'TIMESTAMP_FUTURE' }
    if (this.maxAge > 0 && (now - timestamp) > this.maxAge) return { valid: false, reason: 'ATTESTATION_EXPIRED' }

    // Check if app key is already claimed by a different developer
    const existingOwner = this.appKeyIndex.get(appKey)
    if (existingOwner && existingOwner !== developerKey) {
      return { valid: false, reason: 'APP_KEY_CLAIMED' }
    }

    // Verify Schnorr signature
    const message = createAttestationMessage(appKey, developerKey, timestamp)
    const valid = await schnorrVerify(signature, message, developerKey)
    if (!valid) return { valid: false, reason: 'INVALID_SIGNATURE' }

    // Store the attestation
    if (!this.developers.has(developerKey)) {
      this.developers.set(developerKey, {
        developerKey,
        appKeys: new Map(),
        registeredAt: now,
        lastSeen: now
      })
    }

    const dev = this.developers.get(developerKey)
    dev.appKeys.set(appKey, {
      attestation,
      verifiedAt: now,
      metadata: metadata || {}
    })
    dev.lastSeen = now

    // Update reverse index
    this.appKeyIndex.set(appKey, developerKey)

    this.emit('attestation', { appKey, developerKey, metadata })

    // Persist
    if (this.storagePath) await this.save()

    return { valid: true }
  }

  /**
   * Revoke an attestation. Only the developer who created it can revoke.
   *
   * @param {string} appKey - Ed25519 app key to unlink
   * @param {string} developerKey - Must match the original developer
   */
  async revokeAttestation (appKey, developerKey) {
    const owner = this.appKeyIndex.get(appKey)
    if (!owner || owner !== developerKey) {
      return { revoked: false, reason: 'NOT_OWNER' }
    }

    const dev = this.developers.get(developerKey)
    if (dev) dev.appKeys.delete(appKey)
    this.appKeyIndex.delete(appKey)

    this.emit('revocation', { appKey, developerKey })

    if (this.storagePath) await this.save()

    return { revoked: true }
  }

  /**
   * Look up the developer identity for an Ed25519 app key.
   *
   * @param {string} appKey - Ed25519 public key (hex)
   * @returns {{ developerKey, metadata, registeredAt } | null}
   */
  resolveDeveloper (appKey) {
    const developerKey = this.appKeyIndex.get(appKey)
    if (!developerKey) return null

    const dev = this.developers.get(developerKey)
    if (!dev) return null

    const entry = dev.appKeys.get(appKey)
    if (!entry) return null

    return {
      developerKey,
      metadata: entry.metadata,
      attestation: entry.attestation,
      registeredAt: dev.registeredAt
    }
  }

  /**
   * Get all app keys owned by a developer.
   *
   * @param {string} developerKey - secp256k1 x-only public key (hex)
   * @returns {Array<{ appKey, metadata, verifiedAt }>}
   */
  getDeveloperApps (developerKey) {
    const dev = this.developers.get(developerKey)
    if (!dev) return []

    const apps = []
    for (const [appKey, entry] of dev.appKeys) {
      apps.push({
        appKey,
        metadata: entry.metadata,
        verifiedAt: entry.verifiedAt
      })
    }
    return apps
  }

  /**
   * Get all registered developers.
   */
  listDevelopers () {
    const list = []
    for (const [key, dev] of this.developers) {
      list.push({
        developerKey: key,
        appCount: dev.appKeys.size,
        registeredAt: dev.registeredAt,
        lastSeen: dev.lastSeen
      })
    }
    return list
  }

  /**
   * Resolve the developer identity for a router context.
   * Used by middleware to enrich context with developer info.
   *
   * @param {object} context - Router dispatch context
   * @returns {object} Enriched context
   */
  enrichContext (context) {
    const appKey = context.remotePubkey || context.appKey
    if (!appKey) return context

    const developer = this.resolveDeveloper(appKey)
    if (developer) {
      context.developerKey = developer.developerKey
      context.developerMeta = developer.metadata
    }
    return context
  }

  // ─── Persistence ───

  async save () {
    if (!this.storagePath) return
    await mkdir(this.storagePath, { recursive: true })

    const data = {
      version: 1,
      savedAt: Date.now(),
      developers: []
    }

    for (const [key, dev] of this.developers) {
      const apps = []
      for (const [appKey, entry] of dev.appKeys) {
        apps.push({ appKey, attestation: entry.attestation, verifiedAt: entry.verifiedAt, metadata: entry.metadata })
      }
      data.developers.push({
        developerKey: key,
        registeredAt: dev.registeredAt,
        lastSeen: dev.lastSeen,
        apps
      })
    }

    const filePath = join(this.storagePath, 'attestations.json')
    // Atomic write (tmp + rename) so a crash mid-write can't corrupt the
    // attestation store. rename preserves the 0o600 mode set on the tmp file.
    const tmpPath = filePath + '.tmp'
    try {
      await writeFile(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 })
      await rename(tmpPath, filePath)
    } catch (err) {
      try { await unlink(tmpPath) } catch (_) {}
      throw err
    }
  }

  async load () {
    if (!this.storagePath) return
    const filePath = join(this.storagePath, 'attestations.json')

    let raw
    try {
      raw = await readFile(filePath, 'utf-8')
    } catch {
      return // No file yet
    }

    const data = JSON.parse(raw)
    if (data.version !== 1) return

    for (const dev of data.developers) {
      const appKeys = new Map()
      for (const entry of dev.apps) {
        appKeys.set(entry.appKey, {
          attestation: entry.attestation,
          verifiedAt: entry.verifiedAt,
          metadata: entry.metadata || {}
        })
        this.appKeyIndex.set(entry.appKey, dev.developerKey)
      }
      this.developers.set(dev.developerKey, {
        developerKey: dev.developerKey,
        appKeys,
        registeredAt: dev.registeredAt,
        lastSeen: dev.lastSeen
      })
    }
  }

  stats () {
    let totalAttestations = 0
    for (const dev of this.developers.values()) {
      totalAttestations += dev.appKeys.size
    }
    return {
      developers: this.developers.size,
      attestations: totalAttestations,
      appKeysLinked: this.appKeyIndex.size
    }
  }
}