OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / anproto.js

317 lines · javascript ref:
/**
 * ANProto — Unified Ed25519 identity protocol
 *
 * Superset of Wiredove content-signing and pear-git/hypernote challenge-response.
 * Same keypair works for both modes.
 *
 * Challenge-response (HTTP auth):
 *   sign(challenge, keypair) → detached 88-char base64 signature
 *   verify(challenge, signature, publicKey) → boolean
 *
 * Content signing (social posts, Wiredove-compatible):
 *   hash(data) → SHA-256 base64 hash
 *   signContent(hash, keypair) → pubkey(44) + base64(crypto_sign(ts+hash))
 *   open(message) → { timestamp, hash, publicKey, verified }
 */

import crypto from 'hypercore-crypto'
import sodium from 'sodium-native'
import b4a from 'b4a'

export class ANProto {
  // ---------------------------------------------------------------------------
  // Keypair management (shared by both modes)
  // ---------------------------------------------------------------------------

  /**
   * Generate a new Ed25519 keypair
   * @returns {{ publicKey: Buffer, secretKey: Buffer, keypair: string }}
   */
  static gen () {
    const publicKey = b4a.allocUnsafe(sodium.crypto_sign_PUBLICKEYBYTES)
    const secretKey = b4a.allocUnsafe(sodium.crypto_sign_SECRETKEYBYTES)
    sodium.crypto_sign_keypair(publicKey, secretKey)

    return {
      publicKey,
      secretKey,
      keypair: b4a.toString(publicKey, 'base64') + b4a.toString(secretKey, 'base64')
    }
  }

  /**
   * Load keypair from 132-char base64 string (44 pubkey + 88 secretkey)
   * @param {string} keypairString
   * @returns {{ publicKey: Buffer, secretKey: Buffer }}
   */
  static loadKeypair (keypairString) {
    if (typeof keypairString !== 'string' || keypairString.length !== 132) {
      throw new Error('Invalid keypair length (expected 132 chars: 44 pubkey + 88 secretkey)')
    }

    return {
      publicKey: b4a.from(keypairString.substring(0, 44), 'base64'),
      secretKey: b4a.from(keypairString.substring(44, 132), 'base64')
    }
  }

  /**
   * Extract base64 public key from keypair string
   * @param {string} keypairString - 132-char base64 keypair
   * @returns {string} 44-char base64 public key
   */
  static getPublicKey (keypairString) {
    if (typeof keypairString !== 'string' || keypairString.length !== 132) {
      throw new Error('Invalid keypair length')
    }
    return keypairString.substring(0, 44)
  }

  // ---------------------------------------------------------------------------
  // Challenge-response mode (HTTP auth)
  // Uses canonical ANProto wire format: hash → sign with timestamp → combined envelope
  // ---------------------------------------------------------------------------

  /**
   * Sign a challenge for HTTP auth using canonical ANProto format.
   *
   * Produces: pubkey(44 base64) + base64(combined_sign(timestamp + SHA256(challenge)))
   * This is wire-compatible with evbogue's ANProto and an.sign().
   *
   * @param {string|Buffer} challenge - Challenge to sign
   * @param {{ publicKey: Buffer, secretKey: Buffer }} keypair
   * @returns {string} canonical wire-format signature
   */
  static sign (challenge, keypair) {
    const challengeStr = typeof challenge === 'string' ? challenge : b4a.toString(challenge, 'utf8')
    const contentHash = ANProto.hash(challengeStr)
    return ANProto.signContent(contentHash, keypair)
  }

  /**
   * Verify a canonical ANProto challenge-response signature.
   *
   * Opens the combined-mode envelope, extracts timestamp + hash,
   * verifies hash matches SHA256(challenge), checks timestamp freshness.
   *
   * @param {string|Buffer} challenge - Original challenge
   * @param {string} signature - canonical wire-format signature (pubkey + combined envelope)
   * @returns {{ valid: boolean, publicKey: string|null, timestamp: number|null, error: string|null }}
   */
  static verify (challenge, signature) {
    try {
      if (typeof signature !== 'string' || signature.length < 44) {
        return { valid: false, publicKey: null, timestamp: null, error: 'invalid signature' }
      }

      const verification = ANProto.open(signature)
      const challengeStr = typeof challenge === 'string' ? challenge : b4a.toString(challenge, 'utf8')
      const expectedHash = ANProto.hash(challengeStr)

      if (verification.hash !== expectedHash) {
        return { valid: false, publicKey: verification.publicKey, timestamp: verification.timestamp, error: 'hash mismatch' }
      }

      // Check timestamp freshness (5 minute window)
      const age = Date.now() - verification.timestamp
      if (age < -60000 || age > 5 * 60 * 1000) {
        return { valid: false, publicKey: verification.publicKey, timestamp: verification.timestamp, error: 'timestamp expired' }
      }

      return { valid: true, publicKey: verification.publicKey, timestamp: verification.timestamp, error: null }
    } catch (err) {
      return { valid: false, publicKey: null, timestamp: null, error: err.message }
    }
  }

  // ---------------------------------------------------------------------------
  // Content signing mode (social posts — Wiredove-compatible)
  // Uses sodium-native combined signatures + SHA-256
  // ---------------------------------------------------------------------------

  /**
   * SHA-256 hash of data
   * @param {string|Buffer} data
   * @returns {string} 44-char base64 hash
   */
  static hash (data) {
    const buf = typeof data === 'string' ? b4a.from(data, 'utf8') : data
    const out = b4a.allocUnsafe(sodium.crypto_hash_sha256_BYTES)
    sodium.crypto_hash_sha256(out, buf)
    return b4a.toString(out, 'base64')
  }

  /**
   * Sign a content hash with embedded timestamp (Wiredove wire format)
   *
   * Output: pubkey(44 chars base64) + base64(crypto_sign(timestamp + hash))
   * This is the combined-mode signature where the signed data is embedded.
   *
   * @param {string} contentHash - Base64 SHA-256 hash from hash()
   * @param {{ publicKey: Buffer, secretKey: Buffer }} keypair
   * @returns {string} ANProto content signature
   */
  static signContent (contentHash, keypair) {
    const timestamp = Date.now().toString()
    const message = timestamp + contentHash
    const messageBuffer = b4a.from(message, 'utf8')

    const signedMessage = b4a.allocUnsafe(sodium.crypto_sign_BYTES + messageBuffer.length)
    sodium.crypto_sign(signedMessage, messageBuffer, keypair.secretKey)

    const publicKeyB64 = b4a.toString(keypair.publicKey, 'base64')
    const signedB64 = b4a.toString(signedMessage, 'base64')

    return publicKeyB64 + signedB64
  }

  /**
   * Open and verify a content signature (Wiredove wire format)
   *
   * Extracts the public key, verifies the combined-mode signature,
   * and returns the embedded timestamp + content hash.
   *
   * @param {string} message - ANProto content signature from signContent()
   * @returns {{ timestamp: number, hash: string, publicKey: string, verified: boolean }}
   */
  static open (message) {
    if (typeof message !== 'string' || message.length < 44) {
      throw new Error('Invalid ANProto message (too short)')
    }

    const publicKeyB64 = message.substring(0, 44)
    const signedB64 = message.substring(44)

    const publicKey = b4a.from(publicKeyB64, 'base64')
    const signedMessage = b4a.from(signedB64, 'base64')

    if (signedMessage.length <= sodium.crypto_sign_BYTES) {
      throw new Error('Invalid ANProto message (signed data too short)')
    }

    const originalMessage = b4a.allocUnsafe(signedMessage.length - sodium.crypto_sign_BYTES)
    const opened = sodium.crypto_sign_open(originalMessage, signedMessage, publicKey)

    if (!opened) {
      throw new Error('Invalid signature')
    }

    const messageStr = b4a.toString(originalMessage, 'utf8')
    // ⛔ EXACTLY 13 digits, never `\d+`.
    //
    // The message is `timestamp + base64(sha256)`. A greedy digit match swallows the hash's first
    // character whenever it happens to BE a digit — 10 of base64's 64 symbols, so ~1 in 6 messages
    // came back with a 43-character hash and failed as "hash mismatch". Measured 59/300 before this
    // line was pinned.
    //
    // 13 is what the canonical `anproto` package uses (lib/an.js:119, `/^(\d{13})(.+)$/`), which is
    // why pear-git-v3 never saw this: it depends on that package, and this file is a separate copy
    // that had drifted. Matching it is convergence, not a new rule.
    //
    // ⚠ Wire-compatible: the envelope bytes are unchanged, only the parse. A fixed verifier accepts
    // signatures from fixed and unfixed signers alike — signing was never wrong.
    const timestampMatch = messageStr.match(/^\d{13}/)

    if (!timestampMatch) {
      throw new Error('Invalid message format (no timestamp)')
    }

    const timestamp = parseInt(timestampMatch[0], 10)
    const hash = messageStr.substring(timestampMatch[0].length)

    return {
      timestamp,
      hash,
      publicKey: publicKeyB64,
      verified: true
    }
  }

  // ---------------------------------------------------------------------------
  // High-level helpers
  // ---------------------------------------------------------------------------

  /**
   * Deterministic JSON stringify (sorted keys, recursive)
   * Ensures same object always produces same hash.
   *
   * @param {*} obj
   * @returns {string}
   */
  static deterministicStringify (obj) {
    if (obj === null) return 'null'
    if (obj === undefined) return 'undefined'
    if (typeof obj !== 'object') return JSON.stringify(obj)
    if (Array.isArray(obj)) {
      return '[' + obj.map(item => ANProto.deterministicStringify(item)).join(',') + ']'
    }

    const keys = Object.keys(obj).sort()
    const pairs = keys.map(key =>
      JSON.stringify(key) + ':' + ANProto.deterministicStringify(obj[key])
    )
    return '{' + pairs.join(',') + '}'
  }

  /**
   * Create and sign a message (high-level content signing)
   *
   * Deterministically stringifies content, hashes it, signs the hash.
   * The returned object includes the ANProto signature and can be verified
   * with verifyMessage().
   *
   * @param {Object|string} content - Message content
   * @param {{ publicKey: Buffer, secretKey: Buffer }} keypair
   * @returns {{ anproto: string, content: *, hash: string }}
   */
  static createMessage (content, keypair) {
    const contentStr = typeof content === 'object'
      ? ANProto.deterministicStringify(content)
      : String(content)
    const contentHash = ANProto.hash(contentStr)
    const anproto = ANProto.signContent(contentHash, keypair)

    return {
      anproto,
      content,
      hash: contentHash
    }
  }

  /**
   * Verify a signed message (high-level content verification)
   *
   * Opens the ANProto signature, recomputes the content hash,
   * and verifies they match.
   *
   * @param {{ anproto: string, content: * }} message
   * @returns {{ author: string, verified: boolean, timestamp: number, content: *, hash: string }}
   */
  static verifyMessage (message) {
    if (!message || !message.anproto) {
      throw new Error('Missing ANProto signature')
    }

    const verification = ANProto.open(message.anproto)

    const contentStr = typeof message.content === 'object'
      ? ANProto.deterministicStringify(message.content)
      : String(message.content)
    const expectedHash = ANProto.hash(contentStr)

    if (verification.hash !== expectedHash) {
      throw new Error('Content hash mismatch')
    }

    return {
      content: message.content,
      hash: verification.hash,
      author: verification.publicKey,
      timestamp: verification.timestamp,
      verified: true
    }
  }
}

export default ANProto