OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / web / js / identity.js

468 lines · javascript ref:
/**
 * Hypernote 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.
 */

window.HnIdentity = (function () {
  // Security note: Private key is stored in localStorage, which is inherently
  // accessible to same-origin scripts. CSP headers mitigate XSS risk.
  // This is the standard trade-off for browser-only ed25519 identity.
  var KEYPAIR_KEY = 'hn-keypair'
  var PUBKEY_KEY = 'hn-pubkey'
  var USERNAME_KEY = 'hn-username'

  // Challenge cache — reuse until 1 min before expiry
  var cachedChallenge = null

  // @noble/ed25519 loaded via CDN
  var ed = null
  var sha512 = null
  var nobleLoading = null

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

    // In hyper:// mode, load from the HTTP server (stored in localStorage)
    // In normal mode, derive from page URL (works with --prefix)
    var base
    if (window.location.protocol === 'hyper:') {
      base = localStorage.getItem('hn-api-base') || 'http://localhost:4040'
    } else {
      base = window.location.origin + window.location.pathname.replace(/\/+$/, '')
    }

    nobleLoading = (async function () {
      try {
        // Load from local node_modules via /vendor/ route
        var module = await import(base + '/vendor/@noble/ed25519/index.js')
        ed = module

        // crypto.subtle is only available in secure contexts (HTTPS/localhost).
        // For LAN access over HTTP, provide a pure JS sha512 fallback.
        if (typeof crypto === 'undefined' || !crypto.subtle) {
          ed.etc.sha512Async = async function () {
            var merged = new Uint8Array(Array.from(arguments).reduce(function (a, b) { return a + b.length }, 0))
            var offset = 0
            for (var i = 0; i < arguments.length; i++) {
              merged.set(arguments[i], offset)
              offset += arguments[i].length
            }
            return _jsSha512(merged)
          }
        }
      } catch (err) {
        nobleLoading = null
        console.error('[Identity] Failed to load noble ed25519:', err)
        throw err
      }
    })()

    return nobleLoading
  }

  /**
   * Get stored keypair or null
   */
  function getKeypair () {
    return localStorage.getItem(KEYPAIR_KEY) || null
  }

  /**
   * Get stored public key
   */
  function getPublicKey () {
    return localStorage.getItem(PUBKEY_KEY) || null
  }

  /**
   * Get stored username
   */
  function getUsername () {
    return localStorage.getItem(USERNAME_KEY) || null
  }

  /**
   * Set stored username
   */
  function setUsername (u) {
    if (u) {
      localStorage.setItem(USERNAME_KEY, u)
    } else {
      localStorage.removeItem(USERNAME_KEY)
    }
  }

  /**
   * Generate new ed25519 keypair CLIENT-SIDE
   * Secret key never leaves the browser.
   */
  async function generate () {
    await loadNoble()

    // Generate 32-byte random seed using browser crypto
    var seed = new Uint8Array(32)
    crypto.getRandomValues(seed)

    // Derive ed25519 public key from seed
    var publicKeyBytes = await ed.getPublicKeyAsync(seed)

    // Build 64-byte secret key (seed + public key, matching hypercore-crypto format)
    var secretKeyBytes = new Uint8Array(64)
    secretKeyBytes.set(seed, 0)
    secretKeyBytes.set(publicKeyBytes, 32)

    var publicKeyB64 = bytesToBase64(publicKeyBytes) // 44 chars
    var secretKeyB64 = bytesToBase64(secretKeyBytes) // 88 chars
    var keypair = publicKeyB64 + secretKeyB64         // 132 chars

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

    return { publicKey: publicKeyB64, keypair: keypair }
  }

  /**
   * Get a fresh or cached challenge
   */
  async function getChallenge () {
    if (cachedChallenge) {
      var remaining = cachedChallenge.expiresAt - Date.now()
      if (remaining > 60000) return cachedChallenge
    }

    return fetchNewChallenge()
  }

  /**
   * Always fetch a new challenge (bypasses cache).
   * Use for XHR uploads to avoid race with fetch() interceptor sharing single-use challenges.
   */
  async function getFreshChallenge () {
    cachedChallenge = null
    return fetchNewChallenge()
  }

  async function fetchNewChallenge () {
    var base = (window.HnApi && window.HnApi.getApiBase) ? window.HnApi.getApiBase() : (localStorage.getItem('hn-api-base') || '')
    var res = await originalFetch(base + '/api/auth/challenge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' }
    })
    if (!res.ok) throw new Error('Failed to get challenge')
    cachedChallenge = await res.json()
    return cachedChallenge
  }

  /**
   * Sign a challenge string with the stored keypair
   */
  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)

    // noble expects the 32-byte seed (first 32 bytes of 64-byte secret key)
    var seed = secretKey.slice(0, 32)
    var challengeBytes = new TextEncoder().encode(challenge)
    var signature = await ed.signAsync(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)
  }

  /**
   * Clear identity (logout)
   */
  function clear () {
    localStorage.removeItem(KEYPAIR_KEY)
    localStorage.removeItem(PUBKEY_KEY)
    localStorage.removeItem(USERNAME_KEY)
    localStorage.removeItem('hn-token')
    cachedChallenge = null
  }

  /**
   * Import keypair from 132-char string
   */
  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
  }

  /**
   * Export keypair as JSON object (for saving to file or copying)
   */
  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 ====================

  /**
   * Skip auth when user has no identity (anonymous browsing — reads are open).
   * When user HAS an identity, always send auth headers (even for GETs like profile).
   */
  function shouldSkipAuth (url, method) {
    if (url.indexOf('/api/') === -1) return true
    // Challenge endpoint must be unauthenticated
    if (url.indexOf('/api/auth/challenge') !== -1) return true
    // Username availability check is public
    if (url.indexOf('/available') !== -1) return true
    // If no identity, skip auth (anonymous browsing — reads are open)
    if (!getPublicKey()) return true
    return false
  }

  var originalFetch = window.fetch

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

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

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

    // Legacy bearer token support
    var legacyToken = localStorage.getItem('hn-token')
    if (legacyToken) {
      init = init || {}
      init.headers = init.headers || {}
      init.headers['Authorization'] = 'Bearer ' + legacyToken
      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)

      // On 401, retry once with fresh challenge
      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

  // ==================== PURE JS SHA-512 (for non-secure contexts) ====================

  // Based on FIPS 180-4. Used only when crypto.subtle is unavailable (HTTP LAN access).
  var _K = null
  function _jsSha512 (msg) {
    if (!_K) {
      // First 80 primes' cube-root fractional parts as two 32-bit ints
      _K = [
        0x428a2f98,0xd728ae22,0x71374491,0x23ef65cd,0xb5c0fbcf,0xec4d3b2f,0xe9b5dba5,0x8189dbbc,
        0x3956c25b,0xf348b538,0x59f111f1,0xb605d019,0x923f82a4,0xaf194f9b,0xab1c5ed5,0xda6d8118,
        0xd807aa98,0xa3030242,0x12835b01,0x45706fbe,0x243185be,0x4ee4b28c,0x550c7dc3,0xd5ffb4e2,
        0x72be5d74,0xf27b896f,0x80deb1fe,0x3b1696b1,0x9bdc06a7,0x25c71235,0xc19bf174,0xcf692694,
        0xe49b69c1,0x9ef14ad2,0xefbe4786,0x384f25e3,0x0fc19dc6,0x8b8cd5b5,0x240ca1cc,0x77ac9c65,
        0x2de92c6f,0x592b0275,0x4a7484aa,0x6ea6e483,0x5cb0a9dc,0xbd41fbd4,0x76f988da,0x831153b5,
        0x983e5152,0xee66dfab,0xa831c66d,0x2db43210,0xb00327c8,0x98fb213f,0xbf597fc7,0xbeef0ee4,
        0xc6e00bf3,0x3da88fc2,0xd5a79147,0x930aa725,0x06ca6351,0xe003826f,0x14292967,0x0a0e6e70,
        0x27b70a85,0x46d22ffc,0x2e1b2138,0x5c26c926,0x4d2c6dfc,0x5ac42aed,0x53380d13,0x9d95b3df,
        0x650a7354,0x8baf63de,0x766a0abb,0x3c77b2a8,0x81c2c92e,0x47edaee6,0x92722c85,0x1482353b,
        0xa2bfe8a1,0x4cf10364,0xa81a664b,0xbc423001,0xc24b8b70,0xd0f89791,0xc76c51a3,0x0654be30,
        0xd192e819,0xd6ef5218,0xd6990624,0x5565a910,0xf40e3585,0x5771202a,0x106aa070,0x32bbd1b8,
        0x19a4c116,0xb8d2d0c8,0x1e376c08,0x5141ab53,0x2748774c,0xdf8eeb99,0x34b0bcb5,0xe19b48a8,
        0x391c0cb3,0xc5c95a63,0x4ed8aa4a,0xe3418acb,0x5b9cca4f,0x7763e373,0x682e6ff3,0xd6b2b8a3,
        0x748f82ee,0x5defb2fc,0x78a5636f,0x43172f60,0x84c87814,0xa1f0ab72,0x8cc70208,0x1a6439ec,
        0x90befffa,0x23631e28,0xa4506ceb,0xde82bde9,0xbef9a3f7,0xb2c67915,0xc67178f2,0xe372532b,
        0xca273ece,0xea26619c,0xd186b8c7,0x21c0c207,0xeada7dd6,0xcde0eb1e,0xf57d4f7f,0xee6ed178,
        0x06f067aa,0x72176fba,0x0a637dc5,0xa2c898a6,0x113f9804,0xbef90dae,0x1b710b35,0x131c471b,
        0x28db77f5,0x23047d84,0x32caab7b,0x40c72493,0x3c9ebe0a,0x15c9bebc,0x431d67c4,0x9c100d4c,
        0x4cc5d4be,0xcb3e42b6,0x597f299c,0xfc657e2a,0x5fcb6fab,0x3ad6faec,0x6c44198c,0x4a475817
      ]
    }
    // Pre-processing: pad message
    var len = msg.length
    var bitLen = len * 8
    // pad to 896 mod 1024 bits (112 mod 128 bytes)
    var padLen = (112 - (len + 1) % 128 + 128) % 128 + 1
    var padded = new Uint8Array(len + padLen + 16)
    padded.set(msg)
    padded[len] = 0x80
    // Length as 128-bit big-endian (we only use lower 64 bits)
    var dv = new DataView(padded.buffer)
    dv.setUint32(padded.length - 4, bitLen >>> 0)
    dv.setUint32(padded.length - 8, (bitLen / 0x100000000) >>> 0)

    // Initial hash values
    var h0h = 0x6a09e667, h0l = 0xf3bcc908
    var h1h = 0xbb67ae85, h1l = 0x84caa73b
    var h2h = 0x3c6ef372, h2l = 0xfe94f82b
    var h3h = 0xa54ff53a, h3l = 0x5f1d36f1
    var h4h = 0x510e527f, h4l = 0xade682d1
    var h5h = 0x9b05688c, h5l = 0x2b3e6c1f
    var h6h = 0x1f83d9ab, h6l = 0xfb41bd6b
    var h7h = 0x5be0cd19, h7l = 0x137e2179

    var W = new Int32Array(160)

    for (var off = 0; off < padded.length; off += 128) {
      for (var i = 0; i < 32; i++) W[i] = dv.getInt32(off + i * 4)
      for (var i = 32; i < 160; i += 2) {
        // sigma1(W[i-4])
        var x1h = W[i-4], x1l = W[i-3]
        var s1h = ((x1h >>> 19) | (x1l << 13)) ^ ((x1l >>> 29) | (x1h << 3)) ^ (x1h >>> 6)
        var s1l = ((x1l >>> 19) | (x1h << 13)) ^ ((x1h >>> 29) | (x1l << 3)) ^ ((x1l >>> 6) | (x1h << 26))
        // sigma0(W[i-30])
        var x0h = W[i-30], x0l = W[i-29]
        var s0h = ((x0h >>> 1) | (x0l << 31)) ^ ((x0h >>> 8) | (x0l << 24)) ^ (x0h >>> 7)
        var s0l = ((x0l >>> 1) | (x0h << 31)) ^ ((x0l >>> 8) | (x0h << 24)) ^ ((x0l >>> 7) | (x0h << 25))
        var wl = (W[i-13] + s1l) | 0
        var wh = (W[i-14] + s1h + ((wl >>> 0) < (W[i-13] >>> 0) ? 1 : 0)) | 0
        wl = (wl + W[i-31]) | 0
        wh = (wh + W[i-32] + ((wl >>> 0) < (W[i-31] >>> 0) ? 1 : 0)) | 0  // +W[i-16] intentionally using i-32 for the pair
        wl = (wl + s0l) | 0
        wh = (wh + s0h + ((wl >>> 0) < (s0l >>> 0) ? 1 : 0)) | 0
        W[i] = wh; W[i+1] = wl
      }

      var ah = h0h, al = h0l, bh = h1h, bl = h1l, ch = h2h, cl = h2l, ddh = h3h, dl = h3l
      var eh = h4h, el = h4l, fh = h5h, fl = h5l, gh = h6h, gl = h6l, hh = h7h, hl = h7l

      for (var i = 0; i < 80; i++) {
        // Sigma1(e)
        var S1h = ((eh >>> 14) | (el << 18)) ^ ((eh >>> 18) | (el << 14)) ^ ((el >>> 9) | (eh << 23))
        var S1l = ((el >>> 14) | (eh << 18)) ^ ((el >>> 18) | (eh << 14)) ^ ((eh >>> 9) | (el << 23))
        // Ch(e,f,g)
        var chh = (eh & fh) ^ (~eh & gh)
        var chl = (el & fl) ^ (~el & gl)
        // Sigma0(a)
        var S0h = ((ah >>> 28) | (al << 4)) ^ ((al >>> 2) | (ah << 30)) ^ ((al >>> 7) | (ah << 25))
        var S0l = ((al >>> 28) | (ah << 4)) ^ ((ah >>> 2) | (al << 30)) ^ ((ah >>> 7) | (al << 25))
        // Maj(a,b,c)
        var majh = (ah & bh) ^ (ah & ch) ^ (bh & ch)
        var majl = (al & bl) ^ (al & cl) ^ (bl & cl)

        var ki = i * 2
        // T1 = h + Sigma1 + Ch + K[i] + W[i]
        var t1l = (hl + S1l) | 0
        var t1h = (hh + S1h + ((t1l >>> 0) < (hl >>> 0) ? 1 : 0)) | 0
        t1l = (t1l + chl) | 0
        t1h = (t1h + chh + ((t1l >>> 0) < (chl >>> 0) ? 1 : 0)) | 0
        t1l = (t1l + _K[ki+1]) | 0
        t1h = (t1h + _K[ki] + ((t1l >>> 0) < (_K[ki+1] >>> 0) ? 1 : 0)) | 0
        t1l = (t1l + W[ki+1]) | 0
        t1h = (t1h + W[ki] + ((t1l >>> 0) < (W[ki+1] >>> 0) ? 1 : 0)) | 0
        // T2 = Sigma0 + Maj
        var t2l = (S0l + majl) | 0
        var t2h = (S0h + majh + ((t2l >>> 0) < (S0l >>> 0) ? 1 : 0)) | 0

        hh = gh; hl = gl; gh = fh; gl = fl; fh = eh; fl = el
        el = (dl + t1l) | 0; eh = (ddh + t1h + ((el >>> 0) < (dl >>> 0) ? 1 : 0)) | 0
        ddh = ch; dl = cl; ch = bh; cl = bl; bh = ah; bl = al
        al = (t1l + t2l) | 0; ah = (t1h + t2h + ((al >>> 0) < (t1l >>> 0) ? 1 : 0)) | 0
      }

      h0l = (h0l + al) | 0; h0h = (h0h + ah + ((h0l >>> 0) < (al >>> 0) ? 1 : 0)) | 0
      h1l = (h1l + bl) | 0; h1h = (h1h + bh + ((h1l >>> 0) < (bl >>> 0) ? 1 : 0)) | 0
      h2l = (h2l + cl) | 0; h2h = (h2h + ch + ((h2l >>> 0) < (cl >>> 0) ? 1 : 0)) | 0
      h3l = (h3l + dl) | 0; h3h = (h3h + ddh + ((h3l >>> 0) < (dl >>> 0) ? 1 : 0)) | 0
      h4l = (h4l + el) | 0; h4h = (h4h + eh + ((h4l >>> 0) < (el >>> 0) ? 1 : 0)) | 0
      h5l = (h5l + fl) | 0; h5h = (h5h + fh + ((h5l >>> 0) < (fl >>> 0) ? 1 : 0)) | 0
      h6l = (h6l + gl) | 0; h6h = (h6h + gh + ((h6l >>> 0) < (gl >>> 0) ? 1 : 0)) | 0
      h7l = (h7l + hl) | 0; h7h = (h7h + hh + ((h7l >>> 0) < (hl >>> 0) ? 1 : 0)) | 0
    }

    var out = new Uint8Array(64)
    var ov = new DataView(out.buffer)
    ov.setUint32(0, h0h); ov.setUint32(4, h0l)
    ov.setUint32(8, h1h); ov.setUint32(12, h1l)
    ov.setUint32(16, h2h); ov.setUint32(20, h2l)
    ov.setUint32(24, h3h); ov.setUint32(28, h3l)
    ov.setUint32(32, h4h); ov.setUint32(36, h4l)
    ov.setUint32(40, h5h); ov.setUint32(44, h5l)
    ov.setUint32(48, h6h); ov.setUint32(52, h6l)
    ov.setUint32(56, h7h); ov.setUint32(60, h7l)
    return out
  }

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

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