OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / lib / core / anproto.js

224 lines · javascript ref:
/**
 * ANProto Integration for Fedicore
 *
 * Implements the ANProto protocol:
 * - ed25519 keypairs
 * - Sign timestamp + hash in base64
 * - Portable message format
 */

import sodium from 'sodium-universal'
import b4a from 'b4a'

export class ANProto {
  /**
   * Deterministic JSON stringify (sorts keys)
   * @param {Object} obj - Object to stringify
   * @returns {string} Deterministic JSON string
   */
  static deterministicStringify(obj) {
    if (obj === null) return 'null'
    if (typeof obj !== 'object') return JSON.stringify(obj)
    if (Array.isArray(obj)) return '[' + obj.map(item => this.deterministicStringify(item)).join(',') + ']'

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

  /**
   * Generate a new ed25519 keypair
   * @returns {Object} { 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 base64 string
   * @param {string} keypairString - Base64 encoded public+secret key
   * @returns {Object} { publicKey: Buffer, secretKey: Buffer }
   */
  static loadKeypair(keypairString) {
    if (keypairString.length !== 132) {
      throw new Error('Invalid keypair length (expected 132 chars: 44 pubkey + 88 secretkey)')
    }

    const publicKey = b4a.from(keypairString.substring(0, 44), 'base64')
    const secretKey = b4a.from(keypairString.substring(44, 132), 'base64')

    return { publicKey, secretKey }
  }

  /**
   * Hash data using SHA-256
   * @param {string|Buffer} data - Data to hash
   * @returns {string} Base64 encoded hash
   */
  static hash(data) {
    const buffer = typeof data === 'string' ? b4a.from(data, 'utf8') : data
    const hash = b4a.allocUnsafe(sodium.crypto_hash_sha256_BYTES)

    sodium.crypto_hash_sha256(hash, buffer)

    return b4a.toString(hash, 'base64')
  }

  /**
   * Alias for hash() (ANProto naming)
   */
  static hashData(data) {
    return this.hash(data)
  }

  /**
   * Sign a hash with keypair
   * @param {string} hash - Base64 hash to sign
   * @param {Object} keypair - { publicKey, secretKey }
   * @returns {string} ANProto message: publicKey+signature
   */
  static sign(hash, keypair) {
    const timestamp = Date.now().toString()
    const message = timestamp + hash
    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
  }

  /**
   * Alias for sign() (ANProto naming)
   */
  static signHash(hash, keypair) {
    return this.sign(hash, keypair)
  }

  /**
   * Verify signature and extract timestamp + hash
   * @param {string} signature - ANProto signature
   * @returns {string} timestamp + hash (e.g. "1768779092104pZGm1Av0...")
   */
  static verifySignature(signature) {
    const publicKeyB64 = signature.substring(0, 44)
    const signedB64 = signature.substring(44)

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

    // Buffer must be sized for the message without the signature
    const message = b4a.allocUnsafe(signedMessage.length - sodium.crypto_sign_BYTES)
    const opened = sodium.crypto_sign_open(message, signedMessage, publicKey)

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

    return b4a.toString(message, 'utf8')
  }

  /**
   * Open (verify and extract) an ANProto message
   * @param {string} message - ANProto signed message
   * @returns {Object} { timestamp: number, hash: string, publicKey: string }
   */
  static open(message) {
    if (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')

    // Buffer must be sized for the message without the signature
    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')
    const timestampStr = messageStr.match(/^\d+/)

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

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

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

  /**
   * Create and sign a message
   * @param {Object} content - Message content object
   * @param {Object} keypair - { publicKey, secretKey }
   * @returns {Object} Signed message with ANProto signature
   */
  static createMessage(content, keypair) {
    const contentJSON = this.deterministicStringify(content)
    const contentHash = this.hash(contentJSON)
    const anprotoSignature = this.sign(contentHash, keypair)

    return {
      anproto: anprotoSignature,
      content,
      timestamp: Date.now(),
      hash: contentHash
    }
  }

  /**
   * Verify and extract message
   * @param {Object} message - Message with anproto signature
   * @returns {Object} Verified message with metadata
   */
  static verifyMessage(message) {
    if (!message.anproto) {
      throw new Error('Missing ANProto signature')
    }

    const verification = this.open(message.anproto)

    // Verify content hash matches using deterministic stringify
    const contentJSON = this.deterministicStringify(message.content)
    const expectedHash = this.hash(contentJSON)

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

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

export default ANProto