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