OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / hyper-social / hyper-social-web / web / js / identity.js

191 lines · javascript ref:
/**
 * Browser-side identity for hyper-social-web
 *
 * Generates ed25519 keypair in browser, stores in localStorage,
 * auto-signs all authenticated requests with challenge-response.
 *
 * Uses @noble/ed25519 (vendored) for browser-compatible signing.
 */

const Identity = {
  _ed25519: null,
  _keypair: null,      // 132-char base64
  _publicKey: null,    // 44-char base64
  _secretKey: null,    // raw bytes (Uint8Array)
  _challenge: null,    // cached challenge
  _challengeExpiry: 0,
  _username: null,
  _ready: false,

  async init (basePath) {
    // Load ed25519 library
    try {
      const [ed, hashes] = await Promise.all([
        import((basePath || '') + '/vendor/noble-ed25519.js'),
        import((basePath || '') + '/vendor/noble-sha512.js')
      ])
      ed.etc.sha512Sync = (...m) => hashes.sha512(ed.etc.concatBytes(...m))
      ed.etc.sha512Async = async (...m) => hashes.sha512(ed.etc.concatBytes(...m))
      this._ed25519 = ed
    } catch (err) {
      console.warn('[Identity] Failed to load ed25519:', err.message)
      return
    }

    // Load existing keypair — do NOT auto-generate for anonymous visitors
    const stored = localStorage.getItem('hs-keypair')
    if (stored && stored.length === 132) {
      this._keypair = stored
    } else {
      // No keypair — user is anonymous until they explicitly login
      this._ready = true
      return
    }

    this._publicKey = this._keypair.substring(0, 44)
    this._secretKey = base64ToBytes(this._keypair.substring(44))
    this._username = localStorage.getItem('hs-username') || null
    this._ready = true

    console.log('[Identity] Ready:', this._publicKey.substring(0, 8) + '...')
  },

  async _generateKeypair () {
    const privateKey = this._ed25519.utils.randomPrivateKey()
    const publicKey = await this._ed25519.getPublicKeyAsync(privateKey)

    // ed25519 private key is 32 bytes, but sodium format is 64 bytes (priv + pub)
    const secretKey64 = new Uint8Array(64)
    secretKey64.set(privateKey, 0)
    secretKey64.set(publicKey, 32)

    return bytesToBase64(publicKey) + bytesToBase64(secretKey64)
  },

  async sign (challenge) {
    if (!this._ed25519 || !this._secretKey) return null

    const challengeBytes = new TextEncoder().encode(challenge)
    // noble-ed25519 uses first 32 bytes of secretKey
    const privateKey = this._secretKey.slice(0, 32)
    const signature = await this._ed25519.signAsync(challengeBytes, privateKey)
    return bytesToBase64(signature)
  },

  async getChallenge (base) {
    // Return cached challenge if still valid (1 min before expiry)
    if (this._challenge && Date.now() < this._challengeExpiry - 60000) {
      return this._challenge
    }

    try {
      const res = await fetch((base || '') + '/api/auth/challenge', { method: 'POST' })
      if (!res.ok) return null

      const data = await res.json()
      this._challenge = data
      this._challengeExpiry = data.expiresAt || (Date.now() + 4 * 60 * 1000)
      return data
    } catch {
      return null
    }
  },

  /**
   * Wrap fetch with auth headers
   */
  async authFetch (url, opts = {}) {
    if (!this._ready || !this._publicKey) {
      return fetch(url, opts)
    }

    const headers = { ...(opts.headers || {}) }
    headers['X-Identity-PublicKey'] = this._publicKey

    // Get a fresh challenge and sign it (challenges are single-use)
    const base = API ? API.base : ''
    const challenge = await this.getChallenge(base)
    if (challenge) {
      const sig = await this.sign(challenge.challenge)
      if (sig) {
        headers['X-Identity-Signature'] = sig
        headers['X-Identity-Challenge'] = challenge.challengeId
      }
      // Clear cached challenge — it's consumed on use
      this._challenge = null
      this._challengeExpiry = 0
    }

    return fetch(url, { ...opts, headers })
  },

  /**
   * Register this keypair with the server (explicit login)
   */
  async register (base) {
    if (!this._publicKey) return null

    try {
      const res = await fetch((base || '') + '/api/auth/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ publicKey: this._publicKey })
      })
      return res.json()
    } catch {
      return null
    }
  },

  /**
   * Check if this keypair is registered (without creating)
   */
  get isRegistered () {
    return this._ready && this._publicKey && localStorage.getItem('hs-registered') === 'true'
  },

  markRegistered () {
    localStorage.setItem('hs-registered', 'true')
  },

  get publicKey () { return this._publicKey },
  get username () { return this._username },
  get isReady () { return this._ready },

  setUsername (username) {
    this._username = username
    localStorage.setItem('hs-username', username)
  },

  clear () {
    localStorage.removeItem('hs-keypair')
    localStorage.removeItem('hs-pubkey')
    localStorage.removeItem('hs-username')
    localStorage.removeItem('hs-registered')
    this._keypair = null
    this._publicKey = null
    this._secretKey = null
    this._username = null
    this._ready = false
  }
}

// --- Base64 helpers (browser-compatible, no Buffer) ---

function bytesToBase64 (bytes) {
  let binary = ''
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i])
  }
  return btoa(binary)
}

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
}