OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity / lib / anproto-client.js

162 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 './vendor/noble-ed25519.js'
import { sha512 } from './vendor/noble-sha512.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 (uses crypto.subtle, works on localhost + HTTPS)
   * @param {string} data
   * @returns {Promise<string>} 44-char base64 hash
   */
  async hash (data) {
    const bytes = new TextEncoder().encode(data)
    const hashBuf = await crypto.subtle.digest('SHA-256', bytes)
    return bytesToBase64(new Uint8Array(hashBuf))
  },

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