OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / anproto-client.js

166 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'
import { sha256 as sha256Pure } from './vendor/sha256-pure.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. Prefers Web Crypto's `crypto.subtle` when available
   * (HTTPS + localhost); falls back to the vendored pure-JS
   * implementation on plain HTTP where SubtleCrypto is undefined.
   *
   * @param {string} data
   * @returns {Promise<string>} 44-char base64 hash
   */
  async hash (data) {
    const bytes = new TextEncoder().encode(data)
    let digest
    if (typeof crypto !== 'undefined' && crypto.subtle) {
      const buf = await crypto.subtle.digest('SHA-256', bytes)
      digest = new Uint8Array(buf)
    } else {
      digest = sha256Pure(bytes)
    }
    return bytesToBase64(digest)
  },

  /**
   * Sign a challenge using canonical ANProto wire format.
   *
   * Produces: pubkey(44 base64) + base64(signature(64) || timestamp + hash)
   * Wire-compatible with server-side an.open() and ANProto.verify().
   *
   * @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 seed = base64ToBytes(keypair.substring(44, 132)).slice(0, 32)

    // Canonical: SHA-256(challenge) → timestamp + hash → sign → combined envelope
    const contentHash = await this.hash(challenge)
    const ts = Date.now().toString()
    const message = ts + contentHash
    const messageBytes = new TextEncoder().encode(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.
   * @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 }
      }
      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 }

      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 }

      const bodyStr = new TextDecoder().decode(body)
      const tsMatch = bodyStr.match(/^(\d+)/)
      return {
        valid: true,
        publicKey: publicKeyB64,
        timestamp: tsMatch ? parseInt(tsMatch[0], 10) : null,
        hash: tsMatch ? bodyStr.substring(tsMatch[0].length) : bodyStr
      }
    } catch {
      return { valid: false, publicKey: 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