/**
* ANProto Browser Client — Ed25519 identity for browser contexts
*
* Uses vendored @noble/ed25519 (pure JS, no native deps, works on HTTP).
* Same protocol as server-side ANProto — keypairs, challenge-response signing.
*
* Keypair format: 132-char base64 string (44 pubkey + 88 secretkey)
* Signature format: 88-char base64 (64 bytes)
*/
import * as ed from './noble-ed25519.js'
import { sha512 } from './noble-sha512.js'
import { sha256 } from './noble-sha256.js'
// Wire pure-JS SHA-512 into noble-ed25519 (replaces crypto.subtle dependency)
ed.etc.sha512Sync = (...msgs) => {
const h = sha512.create()
for (const m of msgs) h.update(m)
return h.digest()
}
ed.etc.sha512Async = async (...msgs) => ed.etc.sha512Sync(...msgs)
export const ANProtoClient = {
/**
* Generate a new Ed25519 keypair
* @returns {Promise<string>} 132-char base64 keypair (44 pub + 88 secret)
*/
async gen () {
const privateKey = ed.utils.randomPrivateKey()
const publicKey = await ed.getPublicKeyAsync(privateKey)
// Build 64-byte secret key (32 seed + 32 pubkey) to match sodium format
const secretKey = new Uint8Array(64)
secretKey.set(privateKey, 0)
secretKey.set(publicKey, 32)
return bytesToBase64(publicKey) + bytesToBase64(secretKey)
},
/**
* Get public key from keypair string
* @param {string} keypair — 132-char base64
* @returns {string} 44-char base64 public key
*/
getPublicKey (keypair) {
return keypair.substring(0, 44)
},
/**
* Get public key as hex string
* @param {string} keypair — 132-char base64
* @returns {string} 64-char hex public key
*/
getPublicKeyHex (keypair) {
const bytes = base64ToBytes(keypair.substring(0, 44))
return bytesToHex(bytes)
},
/**
* SHA-256 hash — pure JS, so it works on plain HTTP as well as localhost and HTTPS
* @param {string} data
* @returns {Promise<string>} 44-char base64 hash
*/
async hash (data) {
// ⛔ PURE JS, NOT crypto.subtle. `crypto.subtle` exists only in a SECURE CONTEXT — HTTPS or
// localhost — so an operator page served over plain HTTP at http://stortron.local:3401 loaded a
// client whose hashing was undefined, and sign-in failed with no way to tell why from the page.
// The same file already made this move for ed25519 ("replaces crypto.subtle dependency"); this
// finishes it, and the surface no longer needs TLS to be reachable on a LAN name.
//
// ⚠ Verified byte-identical to the server's `an.hash` for ascii, unicode and the empty string —
// it must be, or every signature verifies and then fails the hash check.
return bytesToBase64(sha256(new TextEncoder().encode(data)))
},
/**
* Sign a challenge using canonical ANProto wire format.
*
* Produces: pubkey(44 base64) + base64(signature(64) || timestamp + hash)
* Wire-compatible with server-side ANProto.verify() and an.open().
*
* @param {string} challenge — challenge string (from server)
* @param {string} keypair — 132-char base64 keypair
* @returns {Promise<string>} canonical wire-format signature
*/
async sign (challenge, keypair) {
const pubKeyB64 = keypair.substring(0, 44)
const secretKeyB64 = keypair.substring(44, 132)
const seed = base64ToBytes(secretKeyB64).slice(0, 32)
// Canonical: hash the challenge, prepend timestamp, sign the combined message
const contentHash = await this.hash(challenge)
const ts = Date.now().toString()
const message = ts + contentHash
const messageBytes = new TextEncoder().encode(message)
// Detached signature of the message
const sig = await ed.signAsync(messageBytes, seed)
// Combined-mode envelope: signature(64 bytes) || message
const combined = new Uint8Array(sig.length + messageBytes.length)
combined.set(sig, 0)
combined.set(messageBytes, sig.length)
return pubKeyB64 + bytesToBase64(combined)
},
/**
* Verify a canonical ANProto signature and extract the signed data.
* @param {string} message — wire-format signature
* @returns {Promise<{ valid: boolean, publicKey: string, timestamp: number, hash: string }>}
*/
async open (message) {
try {
if (typeof message !== 'string' || message.length < 44) {
return { valid: false, publicKey: null, timestamp: null, hash: null }
}
const publicKeyB64 = message.substring(0, 44)
const publicKey = base64ToBytes(publicKeyB64)
const combined = base64ToBytes(message.substring(44))
if (combined.length <= 64) {
return { valid: false, publicKey: publicKeyB64, timestamp: null, hash: null }
}
const sig = combined.slice(0, 64)
const body = combined.slice(64)
const valid = await ed.verifyAsync(sig, body, publicKey)
if (!valid) {
return { valid: false, publicKey: publicKeyB64, timestamp: null, hash: null }
}
const bodyStr = new TextDecoder().decode(body)
// ⛔ 13 digits exactly — see lib/anproto.js. A greedy match eats the hash's leading character
// whenever it is a digit, which is ~1 in 6 messages.
const tsMatch = bodyStr.match(/^(\d{13})/)
const timestamp = tsMatch ? parseInt(tsMatch[0], 10) : null
const hash = tsMatch ? bodyStr.substring(tsMatch[0].length) : bodyStr
return { valid: true, publicKey: publicKeyB64, timestamp, hash }
} catch {
return { valid: false, publicKey: null, timestamp: null, hash: null }
}
}
}
// ── Encoding helpers ─────────────────────────────────────────
function base64ToBytes (base64) {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
function bytesToBase64 (bytes) {
const binary = String.fromCharCode(...bytes)
return btoa(binary)
}
function bytesToHex (bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
export default ANProtoClient