/**
* 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
}
})()