/**
* 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]
}
}