OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / web / js / identity.js

255 lines · javascript ref:
/**
 * hyper-booking Identity — Browser ed25519 auth
 *
 * Generates ed25519 keypair CLIENT-SIDE using @noble/ed25519.
 * The secret key never leaves the browser.
 * Uses challenge-response signing for every authenticated request.
 * Intercepts fetch() to inject auth headers automatically.
 *
 * Adapted from hypernote/pear-git identity.js.
 * Uses vendored noble libs (zero CDN).
 */

window.HyperBookingIdentity = (function () {
  var KEYPAIR_KEY = 'hb-keypair'
  var PUBKEY_KEY = 'hb-pubkey'
  var USERNAME_KEY = 'hb-username'

  var cachedChallenge = null

  var ed = null
  var sha512 = null
  var nobleLoading = null

  /**
   * Load @noble/ed25519 from vendored files
   */
  function loadNoble () {
    if (ed) return Promise.resolve()
    if (nobleLoading) return nobleLoading

    nobleLoading = (async function () {
      try {
        // Resolve vendor path from this script's src attribute
        var scriptEl = document.querySelector('script[src*="identity.js"]')
        var scriptSrc = scriptEl ? scriptEl.src : location.href
        var vendorBase = scriptSrc.replace(/\/js\/identity\.js.*$/, '/vendor/')
        var module = await import(vendorBase + 'noble-ed25519.js')
        ed = module
        var hashModule = await import(vendorBase + 'noble-sha512.js')
        sha512 = hashModule.sha512
        ed.etc.sha512Sync = function () {
          return sha512.apply(null, arguments)
        }
      } catch (err) {
        nobleLoading = null
        console.error('[Identity] Failed to load noble ed25519:', err)
        throw err
      }
    })()

    return nobleLoading
  }

  function getKeypair () {
    return localStorage.getItem(KEYPAIR_KEY) || null
  }

  function getPublicKey () {
    return localStorage.getItem(PUBKEY_KEY) || null
  }

  function getUsername () {
    return localStorage.getItem(USERNAME_KEY) || null
  }

  function setUsername (u) {
    if (u) {
      localStorage.setItem(USERNAME_KEY, u)
    } else {
      localStorage.removeItem(USERNAME_KEY)
    }
  }

  async function generate () {
    await loadNoble()

    var seed = new Uint8Array(32)
    crypto.getRandomValues(seed)

    var publicKeyBytes = ed.getPublicKey(seed)

    var secretKeyBytes = new Uint8Array(64)
    secretKeyBytes.set(seed, 0)
    secretKeyBytes.set(publicKeyBytes, 32)

    var publicKeyB64 = bytesToBase64(publicKeyBytes)
    var secretKeyB64 = bytesToBase64(secretKeyBytes)
    var keypair = publicKeyB64 + secretKeyB64

    localStorage.setItem(KEYPAIR_KEY, keypair)
    localStorage.setItem(PUBKEY_KEY, publicKeyB64)

    return { publicKey: publicKeyB64, keypair: keypair }
  }

  function getPathPrefix () {
    var path = window.location.pathname
    var idx = path.indexOf('/web/')
    if (idx > 0) return path.substring(0, idx)
    var dashIdx = path.indexOf('/dashboard')
    if (dashIdx > 0) return path.substring(0, dashIdx)
    var match = path.match(/^(\/[^/]+)\//)
    if (match && match[1] !== '/api' && match[1] !== '/web') return match[1]
    return ''
  }

  async function getChallenge () {
    if (cachedChallenge) {
      var remaining = cachedChallenge.expiresAt - Date.now()
      if (remaining > 60000) return cachedChallenge
    }

    var prefix = getPathPrefix()
    var res = await originalFetch(prefix + '/api/auth/challenge')
    if (!res.ok) throw new Error('Failed to get challenge')
    cachedChallenge = await res.json()
    return cachedChallenge
  }

  async function signChallenge (challenge) {
    await loadNoble()
    var keypair = getKeypair()
    if (!keypair) throw new Error('No keypair stored')

    var secretKeyB64 = keypair.substring(44, 132)
    var secretKey = base64ToBytes(secretKeyB64)

    var seed = secretKey.slice(0, 32)
    var challengeBytes = new TextEncoder().encode(challenge)
    var signature = ed.sign(challengeBytes, seed)

    return bytesToBase64(signature)
  }

  function base64ToBytes (b64) {
    var bin = atob(b64)
    var bytes = new Uint8Array(bin.length)
    for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
    return bytes
  }

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

  function clear () {
    localStorage.removeItem(KEYPAIR_KEY)
    localStorage.removeItem(PUBKEY_KEY)
    localStorage.removeItem(USERNAME_KEY)
    cachedChallenge = null
  }

  function importKeypair (keypairStr) {
    if (!keypairStr || keypairStr.length !== 132) {
      throw new Error('Invalid keypair (must be 132 chars)')
    }
    var pubKey = keypairStr.substring(0, 44)
    localStorage.setItem(KEYPAIR_KEY, keypairStr)
    localStorage.setItem(PUBKEY_KEY, pubKey)
    cachedChallenge = null
    return pubKey
  }

  function exportKeypair () {
    var keypair = getKeypair()
    var pubKey = getPublicKey()
    var username = getUsername()
    if (!keypair) return null
    return {
      publicKey: pubKey,
      keypair: keypair,
      username: username,
      exportedAt: new Date().toISOString()
    }
  }

  // ==================== FETCH INTERCEPTION ====================

  function shouldSkipAuth (url) {
    if (url.indexOf('/api/') === -1) return true
    if (url.indexOf('/api/auth/challenge') !== -1) return true
    if (!getPublicKey()) return true
    return false
  }

  var originalFetch = window.fetch

  async function authenticatedFetch (input, init) {
    var url = typeof input === 'string' ? input : input.url || ''

    if (shouldSkipAuth(url)) {
      return originalFetch(input, init)
    }

    var pubKey = getPublicKey()
    if (!pubKey) {
      return originalFetch(input, init)
    }

    try {
      var ch = await getChallenge()
      var signature = await signChallenge(ch.challenge)

      init = init || {}
      init.headers = init.headers || {}
      init.headers['X-Wirecore-PublicKey'] = pubKey
      init.headers['X-Wirecore-Signature'] = signature
      init.headers['X-Wirecore-Challenge'] = ch.challengeId

      cachedChallenge = null

      var res = await originalFetch(input, init)

      if (res.status === 401) {
        cachedChallenge = null
        ch = await getChallenge()
        signature = await signChallenge(ch.challenge)

        init.headers['X-Wirecore-PublicKey'] = pubKey
        init.headers['X-Wirecore-Signature'] = signature
        init.headers['X-Wirecore-Challenge'] = ch.challengeId
        cachedChallenge = null

        res = await originalFetch(input, init)
      }

      return res
    } catch (err) {
      console.error('[Identity] Auth interception failed:', err)
      return originalFetch(input, init)
    }
  }

  window.fetch = authenticatedFetch

  // ==================== PUBLIC API ====================

  return {
    getKeypair: getKeypair,
    getPublicKey: getPublicKey,
    getUsername: getUsername,
    setUsername: setUsername,
    generate: generate,
    getChallenge: getChallenge,
    signChallenge: signChallenge,
    hasKeypair: function () { return !!getKeypair() },
    loadNoble: loadNoble,
    clear: clear,
    importKeypair: importKeypair,
    exportKeypair: exportKeypair
  }
})()