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