OverviewFilesCommitsIssuesDocsReleasesPeers

anproto / lib / an.js

126 lines · javascript ref:
/**
 * an.js — wire-compatible implementation of evbogue's ANProto
 *
 * Spec: https://anproto.com  (canonical at github.com/evbogue/ANProto)
 *
 * Same wire format as the canonical TweetNaCl-based implementation, but
 * uses sodium-native (via hypercore-crypto) so we don't carry a second
 * Ed25519 implementation. Both libraries implement the same Ed25519
 * standard and produce byte-identical signatures for the same inputs.
 *
 * The four canonical functions:
 *
 *   gen()              → 132-char keypair string (44 pubkey b64 + 88 secret b64)
 *   hash(data)         → 44-char base64 SHA-256
 *   sign(hash, kp)     → pubkey(44) + base64(combined-mode-sign(timestamp + hash))
 *   open(message)      → "{timestamp}{hash}" string (throws if invalid)
 */

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

const SIG_BYTES = 64 // Ed25519 detached signature size (crypto_sign_BYTES)

export const an = {}

/**
 * Generate a new Ed25519 keypair.
 * @returns {Promise<string>} 132-char string: base64(pubkey 32B) + base64(secret 64B)
 */
an.gen = async () => {
  const { publicKey, secretKey } = crypto.keyPair()
  return b4a.toString(publicKey, 'base64') + b4a.toString(secretKey, 'base64')
}

/**
 * SHA-256 hash data.
 * @param {string|Buffer} data
 * @returns {Promise<string>} 44-char base64 SHA-256 hash
 */
an.hash = async (data) => {
  const buf = typeof data === 'string' ? b4a.from(data, 'utf8') : data
  const out = b4a.alloc(32)
  sodium.crypto_hash_sha256(out, buf)
  return b4a.toString(out, 'base64')
}

/**
 * Sign a hash with a keypair, producing a wire-format ANProto signature.
 * The signed message is "{Date.now()}{hash}" — timestamp prefixed.
 *
 * Wire format: pubkey(44 b64) + base64(combined_mode_sign(message))
 * where combined_mode_sign(m) = signature(64B) || m
 *
 * @param {string} hash - 44-char base64 hash from an.hash()
 * @param {string} keypair - 132-char keypair string from an.gen()
 * @returns {Promise<string>} wire-format signature
 */
an.sign = async (hash, keypair) => {
  const ts = Date.now()
  const message = b4a.from(String(ts) + hash, 'utf8')
  const secretKey = b4a.from(keypair.substring(44), 'base64')
  const sig = crypto.sign(message, secretKey) // detached, 64 bytes
  // Combined-mode envelope: signature || message
  const combined = b4a.concat([sig, message])
  return keypair.substring(0, 44) + b4a.toString(combined, 'base64')
}

/**
 * Verify and open an ANProto signature, returning the embedded message.
 * The returned string is "{timestamp}{hash}" — caller can split at offset 13.
 *
 * @param {string} message - wire-format signature from an.sign()
 * @returns {Promise<string>} the original signed message ("{timestamp}{hash}")
 * @throws {Error} if the signature is invalid
 */
an.open = async (message) => {
  if (typeof message !== 'string' || message.length < 44) {
    throw new Error('Invalid ANProto message (too short)')
  }
  const publicKey = b4a.from(message.substring(0, 44), 'base64')
  const combined = b4a.from(message.substring(44), 'base64')
  if (publicKey.length !== 32) throw new Error('Invalid ANProto pubkey length')
  if (combined.length < SIG_BYTES) throw new Error('Invalid ANProto combined-mode envelope')

  const sig = combined.subarray(0, SIG_BYTES)
  const body = combined.subarray(SIG_BYTES)

  if (!crypto.verify(body, sig, publicKey)) {
    throw new Error('Invalid ANProto signature')
  }
  return b4a.toString(body, 'utf8')
}

// ── Convenience helpers (not part of canonical spec, but commonly needed) ──

/**
 * Extract the public key from a keypair string.
 * @param {string} keypair - 132-char keypair string
 * @returns {string} 44-char base64 public key
 */
an.publicKey = (keypair) => {
  if (typeof keypair !== 'string' || keypair.length !== 132) {
    throw new Error('Invalid keypair (expected 132 chars)')
  }
  return keypair.substring(0, 44)
}

/**
 * Parse an opened message into its timestamp and hash components.
 * Canonical format: 13-digit timestamp followed by 44-char base64 hash.
 * @param {string} opened - the string returned by an.open()
 * @returns {{ timestamp: number, hash: string }}
 */
an.parse = (opened) => {
  if (typeof opened !== 'string' || opened.length < 13) {
    throw new Error('Invalid opened message')
  }
  const tsMatch = opened.match(/^(\d{13})(.+)$/)
  if (!tsMatch) throw new Error('Invalid opened message format')
  return {
    timestamp: parseInt(tsMatch[1], 10),
    hash: tsMatch[2]
  }
}