OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / auth / attestation.js

124 lines · javascript ref:
/**
 * Permission Attestation
 *
 * Server-signed proofs of user permissions. Users store these alongside their
 * existing identity material (keypair, SSH key, etc.) and can present them
 * to restore access if the server's user database is lost.
 *
 * Auth-method-agnostic: the attestation binds to a userPublicKey regardless
 * of whether it came from ANProto, SSH, Keet, or Noise.
 *
 * Uses ServerIdentity.sign() for Ed25519 signatures.
 *
 * See Issue #30 for design rationale.
 */

import { ServerIdentity } from '../p2p/identity/server-identity.js'

export class PermissionAttestation {
  /**
   * Create a server-signed attestation for a user's permissions.
   *
   * @param {Object} options
   * @param {Object} options.user - User record from Hyperbee
   * @param {string} options.userPublicKey - User's public key (hex, any auth method)
   * @param {string} options.identityMethod - Auth method: web-identity|ssh-key|keet-identity|noise-key
   * @param {string} options.identityId - Identity record ID
   * @param {Object} options.serverIdentity - ServerIdentity instance (has sign())
   * @returns {Object} Attestation JSON with signature
   */
  static create ({ user, userPublicKey, identityMethod, identityId, serverIdentity }) {
    if (!user || !userPublicKey || !identityMethod || !serverIdentity) {
      throw new Error('Missing required fields for attestation')
    }

    const permissions = user.globalPermissions || []
    const repos = user.repos
      ? user.repos.map(r => r.name)
      : ['*']

    const payload = {
      version: 1,
      epoch: Date.now(),
      serverPublicKey: serverIdentity.getPublicKeyHex(),
      userPublicKey,
      identityMethod,
      identityId: identityId || userPublicKey,
      userId: user.userId,
      permissions,
      repos
    }

    const serialized = PermissionAttestation.serialize(payload)
    const signature = serverIdentity.sign(serialized)

    return {
      ...payload,
      signature: signature.toString('hex')
    }
  }

  /**
   * Verify an attestation was signed by a given server.
   *
   * @param {Object} attestation - The full attestation JSON (including signature)
   * @param {Buffer|string} serverPublicKey - Expected server public key
   * @returns {{ valid: boolean, grant: Object|null }}
   */
  static verify (attestation, serverPublicKey) {
    if (!attestation || !attestation.signature || !attestation.serverPublicKey) {
      return { valid: false, grant: null }
    }

    // Check version
    if (attestation.version !== 1) {
      return { valid: false, grant: null }
    }

    // Check server key matches
    const expectedKey = typeof serverPublicKey === 'string'
      ? serverPublicKey
      : serverPublicKey.toString('hex')

    if (attestation.serverPublicKey !== expectedKey) {
      return { valid: false, grant: null }
    }

    // Extract payload (everything except signature)
    const { signature, ...payload } = attestation
    const serialized = PermissionAttestation.serialize(payload)

    const valid = ServerIdentity.verifySignature(serialized, signature, serverPublicKey)

    if (!valid) {
      return { valid: false, grant: null }
    }

    return {
      valid: true,
      grant: {
        userPublicKey: attestation.userPublicKey,
        identityMethod: attestation.identityMethod,
        identityId: attestation.identityId,
        userId: attestation.userId,
        permissions: attestation.permissions,
        repos: attestation.repos,
        epoch: attestation.epoch
      }
    }
  }

  /**
   * Deterministic JSON serialization for signing.
   * Keys are sorted to ensure consistent byte representation.
   *
   * @param {Object} payload - Object to serialize (without signature)
   * @returns {string} Deterministic JSON string
   */
  static serialize (payload) {
    return JSON.stringify(payload, Object.keys(payload).sort())
  }
}

export default PermissionAttestation