OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / static / anproto-client.js

169 lines · javascript ref:
/**
 * 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