OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore / public / apds-compat.js

226 lines · javascript ref:
/**
 * APDS-Compatible Identity Layer for Wirecore
 *
 * Matches Wiredove's apds API for browser-side identity management
 * Uses IndexedDB for persistent storage
 * Compatible with ANProto ed25519 keypair format (88 chars)
 * Uses TweetNaCl for Ed25519 signing + Web Crypto API for SHA-256
 *
 * Note: Requires TweetNaCl to be loaded via script tag in HTML
 */

const DB_NAME = 'wirecore'
const DB_VERSION = 1
const STORE_NAME = 'identity'

let db = null

/**
 * Initialize IndexedDB
 */
async function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION)

    request.onerror = () => reject(request.error)
    request.onsuccess = () => resolve(request.result)

    request.onupgradeneeded = (event) => {
      const db = event.target.result
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME)
      }
    }
  })
}

/**
 * Base64 encode (standard base64 - matches server b4a format)
 */
function base64Encode(bytes) {
  return btoa(String.fromCharCode(...bytes))
}

/**
 * Base64 decode
 */
function base64Decode(str) {
  const binary = atob(str)
  const bytes = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i)
  }
  return bytes
}

/**
 * Generate ANProto-compatible ed25519 keypair (132 chars base64)
 * Format: publicKey (44 chars) + secretKey (88 chars) = 132 total
 */
async function generateKeypair() {
  // Generate keypair using TweetNaCl
  const keypair = nacl.sign.keyPair()

  // ANProto format: base64(publicKey) + base64(secretKey)
  // publicKey: 32 bytes -> 44 chars
  // secretKey: 64 bytes -> 88 chars (seed + public concatenated)
  const publicKeyB64 = base64Encode(keypair.publicKey)
  const secretKeyB64 = base64Encode(keypair.secretKey)

  return publicKeyB64 + secretKeyB64
}

/**
 * Deterministic JSON stringify (sorts keys) - matches ANProto
 */
function deterministicStringify(obj) {
  if (obj === null) return 'null'
  if (typeof obj !== 'object') return JSON.stringify(obj)
  if (Array.isArray(obj)) return '[' + obj.map(item => deterministicStringify(item)).join(',') + ']'

  const keys = Object.keys(obj).sort()
  const pairs = keys.map(key => JSON.stringify(key) + ':' + deterministicStringify(obj[key]))
  return '{' + pairs.join(',') + '}'
}

/**
 * Hash data using SHA-256 (Web Crypto API)
 */
async function hashData(data) {
  const encoder = new TextEncoder()
  const dataBuffer = typeof data === 'string' ? encoder.encode(data) : data
  const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
  return base64Encode(new Uint8Array(hashBuffer))
}

/**
 * Sign data with keypair (TweetNaCl - ANProto compatible)
 */
async function signData(data, keypair) {
  const publicKeyB64 = keypair.substring(0, 44)
  const secretKeyB64 = keypair.substring(44, 132)

  // Convert from base64
  const secretKey = base64Decode(secretKeyB64)

  // Sign the message (TweetNaCl returns signature+message concatenated)
  const encoder = new TextEncoder()
  const messageBuffer = encoder.encode(data)
  const signedMessage = nacl.sign(messageBuffer, secretKey)

  // Convert to base64
  const signedB64 = base64Encode(signedMessage)

  // ANProto format: publicKey + signedMessage
  return publicKeyB64 + signedB64
}

/**
 * APDS-compatible API
 */
export const apds = {
  /**
   * Start APDS (initialize IndexedDB)
   */
  async start() {
    // Check TweetNaCl is loaded
    if (!window.nacl) {
      throw new Error('TweetNaCl not loaded. Add script tag to HTML.')
    }
    db = await initDB()
    console.log('Wirecore identity layer initialized (TweetNaCl ready)')
  },

  /**
   * Generate new keypair
   */
  async generate() {
    return await generateKeypair()
  },

  /**
   * Get stored keypair
   */
  async keypair() {
    if (!db) throw new Error('APDS not started')

    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE_NAME, 'readonly')
      const store = tx.objectStore(STORE_NAME)
      const request = store.get('keypair')

      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  },

  /**
   * Get public key only (first 44 chars of 132-char keypair)
   */
  async pubkey() {
    const keypair = await this.keypair()
    return keypair ? keypair.substring(0, 44) : null
  },

  /**
   * Store a value
   */
  async put(key, value) {
    if (!db) throw new Error('APDS not started')

    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE_NAME, 'readwrite')
      const store = tx.objectStore(STORE_NAME)
      store.put(value, key)

      tx.oncomplete = () => resolve()
      tx.onerror = () => reject(tx.error)
    })
  },

  /**
   * Get a value
   */
  async get(key) {
    if (!db) throw new Error('APDS not started')

    return new Promise((resolve, reject) => {
      const tx = db.transaction(STORE_NAME, 'readonly')
      const store = tx.objectStore(STORE_NAME)
      const request = store.get(key)

      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  },

  /**
   * Hash content
   */
  async hash(data) {
    return await hashData(data)
  },

  /**
   * Sign content with stored keypair
   * If data is an object, it will be stringified deterministically (matching ANProto)
   */
  async sign(data) {
    const keypair = await this.keypair()
    if (!keypair) {
      throw new Error('No keypair - generate one first')
    }

    // If data is an object, use deterministic stringify to match server
    const dataString = typeof data === 'object' ? deterministicStringify(data) : data

    const hash = await this.hash(dataString)

    // ANProto format: sign(timestamp + hash)
    const timestamp = Date.now().toString()
    const message = timestamp + hash

    return await signData(message, keypair)
  }
}