| … | … | @@ -1,27 +1,28 @@ |
| 1 | 1 | /** |
| 2 | | * Hypernote Identity — Browser ed25519 auth |
| 2 | * hyper-booking Identity — Browser ed25519 auth |
| 3 | 3 | * |
| 4 | 4 | * Generates ed25519 keypair CLIENT-SIDE using @noble/ed25519. |
| 5 | 5 | * The secret key never leaves the browser. |
| 6 | 6 | * Uses challenge-response signing for every authenticated request. |
| 7 | 7 | * Intercepts fetch() to inject auth headers automatically. |
| 8 | * |
| 9 | * Adapted from hypernote/pear-git identity.js. |
| 10 | * Uses vendored noble libs (zero CDN). |
| 8 | 11 | */ |
| 9 | 12 | |
| 10 | | window.HnIdentity = (function () { |
| 11 | | var KEYPAIR_KEY = 'hn-keypair' |
| 12 | | var PUBKEY_KEY = 'hn-pubkey' |
| 13 | | var USERNAME_KEY = 'hn-username' |
| 13 | window.HyperBookingIdentity = (function () { |
| 14 | var KEYPAIR_KEY = 'hb-keypair' |
| 15 | var PUBKEY_KEY = 'hb-pubkey' |
| 16 | var USERNAME_KEY = 'hb-username' |
| 14 | 17 | |
| 15 | | // Challenge cache — reuse until 1 min before expiry |
| 16 | 18 | var cachedChallenge = null |
| 17 | 19 | |
| 18 | | // @noble/ed25519 loaded via CDN |
| 19 | 20 | var ed = null |
| 20 | 21 | var sha512 = null |
| 21 | 22 | var nobleLoading = null |
| 22 | 23 | |
| 23 | 24 | /** |
| 24 | | * Load @noble/ed25519 from CDN |
| 25 | * Load @noble/ed25519 from vendored files |
| 25 | 26 | */ |
| 26 | 27 | function loadNoble () { |
| 27 | 28 | if (ed) return Promise.resolve() |
| … | … | @@ -29,9 +30,9 @@ window.HnIdentity = (function () { |
| 29 | 30 | |
| 30 | 31 | nobleLoading = (async function () { |
| 31 | 32 | try { |
| 32 | | var module = await import('https://esm.sh/@noble/ed25519@2.0.0') |
| 33 | var module = await import('/web/vendor/noble-ed25519.js') |
| 33 | 34 | ed = module |
| 34 | | var hashModule = await import('https://esm.sh/@noble/hashes@1.3.3/sha512') |
| 35 | var hashModule = await import('/web/vendor/noble-sha512.js') |
| 35 | 36 | sha512 = hashModule.sha512 |
| 36 | 37 | ed.etc.sha512Sync = function () { |
| 37 | 38 | return sha512.apply(null, arguments) |
| … | … | @@ -46,30 +47,18 @@ window.HnIdentity = (function () { |
| 46 | 47 | return nobleLoading |
| 47 | 48 | } |
| 48 | 49 | |
| 49 | | /** |
| 50 | | * Get stored keypair or null |
| 51 | | */ |
| 52 | 50 | function getKeypair () { |
| 53 | 51 | return localStorage.getItem(KEYPAIR_KEY) || null |
| 54 | 52 | } |
| 55 | 53 | |
| 56 | | /** |
| 57 | | * Get stored public key |
| 58 | | */ |
| 59 | 54 | function getPublicKey () { |
| 60 | 55 | return localStorage.getItem(PUBKEY_KEY) || null |
| 61 | 56 | } |
| 62 | 57 | |
| 63 | | /** |
| 64 | | * Get stored username |
| 65 | | */ |
| 66 | 58 | function getUsername () { |
| 67 | 59 | return localStorage.getItem(USERNAME_KEY) || null |
| 68 | 60 | } |
| 69 | 61 | |
| 70 | | /** |
| 71 | | * Set stored username |
| 72 | | */ |
| 73 | 62 | function setUsername (u) { |
| 74 | 63 | if (u) { |
| 75 | 64 | localStorage.setItem(USERNAME_KEY, u) |
| … | … | @@ -78,28 +67,21 @@ window.HnIdentity = (function () { |
| 78 | 67 | } |
| 79 | 68 | } |
| 80 | 69 | |
| 81 | | /** |
| 82 | | * Generate new ed25519 keypair CLIENT-SIDE |
| 83 | | * Secret key never leaves the browser. |
| 84 | | */ |
| 85 | 70 | async function generate () { |
| 86 | 71 | await loadNoble() |
| 87 | 72 | |
| 88 | | // Generate 32-byte random seed using browser crypto |
| 89 | 73 | var seed = new Uint8Array(32) |
| 90 | 74 | crypto.getRandomValues(seed) |
| 91 | 75 | |
| 92 | | // Derive ed25519 public key from seed |
| 93 | 76 | var publicKeyBytes = await ed.getPublicKeyAsync(seed) |
| 94 | 77 | |
| 95 | | // Build 64-byte secret key (seed + public key, matching hypercore-crypto format) |
| 96 | 78 | var secretKeyBytes = new Uint8Array(64) |
| 97 | 79 | secretKeyBytes.set(seed, 0) |
| 98 | 80 | secretKeyBytes.set(publicKeyBytes, 32) |
| 99 | 81 | |
| 100 | | var publicKeyB64 = bytesToBase64(publicKeyBytes) // 44 chars |
| 101 | | var secretKeyB64 = bytesToBase64(secretKeyBytes) // 88 chars |
| 102 | | var keypair = publicKeyB64 + secretKeyB64 // 132 chars |
| 82 | var publicKeyB64 = bytesToBase64(publicKeyBytes) |
| 83 | var secretKeyB64 = bytesToBase64(secretKeyBytes) |
| 84 | var keypair = publicKeyB64 + secretKeyB64 |
| 103 | 85 | |
| 104 | 86 | localStorage.setItem(KEYPAIR_KEY, keypair) |
| 105 | 87 | localStorage.setItem(PUBKEY_KEY, publicKeyB64) |
| … | … | @@ -107,28 +89,18 @@ window.HnIdentity = (function () { |
| 107 | 89 | return { publicKey: publicKeyB64, keypair: keypair } |
| 108 | 90 | } |
| 109 | 91 | |
| 110 | | /** |
| 111 | | * Get a fresh or cached challenge |
| 112 | | */ |
| 113 | 92 | async function getChallenge () { |
| 114 | 93 | if (cachedChallenge) { |
| 115 | 94 | var remaining = cachedChallenge.expiresAt - Date.now() |
| 116 | 95 | if (remaining > 60000) return cachedChallenge |
| 117 | 96 | } |
| 118 | 97 | |
| 119 | | var base = (window.HnApi && window.HnApi.basePath) || '' |
| 120 | | var res = await originalFetch(base + '/api/auth/challenge', { |
| 121 | | method: 'POST', |
| 122 | | headers: { 'Content-Type': 'application/json' } |
| 123 | | }) |
| 98 | var res = await originalFetch('/api/auth/challenge') |
| 124 | 99 | if (!res.ok) throw new Error('Failed to get challenge') |
| 125 | 100 | cachedChallenge = await res.json() |
| 126 | 101 | return cachedChallenge |
| 127 | 102 | } |
| 128 | 103 | |
| 129 | | /** |
| 130 | | * Sign a challenge string with the stored keypair |
| 131 | | */ |
| 132 | 104 | async function signChallenge (challenge) { |
| 133 | 105 | await loadNoble() |
| 134 | 106 | var keypair = getKeypair() |
| … | … | @@ -137,7 +109,6 @@ window.HnIdentity = (function () { |
| 137 | 109 | var secretKeyB64 = keypair.substring(44, 132) |
| 138 | 110 | var secretKey = base64ToBytes(secretKeyB64) |
| 139 | 111 | |
| 140 | | // noble expects the 32-byte seed (first 32 bytes of 64-byte secret key) |
| 141 | 112 | var seed = secretKey.slice(0, 32) |
| 142 | 113 | var challengeBytes = new TextEncoder().encode(challenge) |
| 143 | 114 | var signature = ed.sign(challengeBytes, seed) |
| … | … | @@ -158,20 +129,13 @@ window.HnIdentity = (function () { |
| 158 | 129 | return btoa(bin) |
| 159 | 130 | } |
| 160 | 131 | |
| 161 | | /** |
| 162 | | * Clear identity (logout) |
| 163 | | */ |
| 164 | 132 | function clear () { |
| 165 | 133 | localStorage.removeItem(KEYPAIR_KEY) |
| 166 | 134 | localStorage.removeItem(PUBKEY_KEY) |
| 167 | 135 | localStorage.removeItem(USERNAME_KEY) |
| 168 | | localStorage.removeItem('hn-token') |
| 169 | 136 | cachedChallenge = null |
| 170 | 137 | } |
| 171 | 138 | |
| 172 | | /** |
| 173 | | * Import keypair from 132-char string |
| 174 | | */ |
| 175 | 139 | function importKeypair (keypairStr) { |
| 176 | 140 | if (!keypairStr || keypairStr.length !== 132) { |
| 177 | 141 | throw new Error('Invalid keypair (must be 132 chars)') |
| … | … | @@ -183,9 +147,6 @@ window.HnIdentity = (function () { |
| 183 | 147 | return pubKey |
| 184 | 148 | } |
| 185 | 149 | |
| 186 | | /** |
| 187 | | * Export keypair as JSON object (for saving to file or copying) |
| 188 | | */ |
| 189 | 150 | function exportKeypair () { |
| 190 | 151 | var keypair = getKeypair() |
| 191 | 152 | var pubKey = getPublicKey() |
| … | … | @@ -201,17 +162,9 @@ window.HnIdentity = (function () { |
| 201 | 162 | |
| 202 | 163 | // ==================== FETCH INTERCEPTION ==================== |
| 203 | 164 | |
| 204 | | /** |
| 205 | | * Skip auth when user has no identity (anonymous browsing — reads are open). |
| 206 | | * When user HAS an identity, always send auth headers (even for GETs like profile). |
| 207 | | */ |
| 208 | | function shouldSkipAuth (url, method) { |
| 165 | function shouldSkipAuth (url) { |
| 209 | 166 | if (url.indexOf('/api/') === -1) return true |
| 210 | | // Challenge endpoint must be unauthenticated |
| 211 | 167 | if (url.indexOf('/api/auth/challenge') !== -1) return true |
| 212 | | // Username availability check is public |
| 213 | | if (url.indexOf('/available') !== -1) return true |
| 214 | | // If no identity, skip auth (anonymous browsing — reads are open) |
| 215 | 168 | if (!getPublicKey()) return true |
| 216 | 169 | return false |
| 217 | 170 | } |
| … | … | @@ -220,9 +173,8 @@ window.HnIdentity = (function () { |
| 220 | 173 | |
| 221 | 174 | async function authenticatedFetch (input, init) { |
| 222 | 175 | var url = typeof input === 'string' ? input : input.url || '' |
| 223 | | var method = (init && init.method) ? init.method.toUpperCase() : 'GET' |
| 224 | 176 | |
| 225 | | if (shouldSkipAuth(url, method)) { |
| 177 | if (shouldSkipAuth(url)) { |
| 226 | 178 | return originalFetch(input, init) |
| 227 | 179 | } |
| 228 | 180 | |
| … | … | @@ -231,15 +183,6 @@ window.HnIdentity = (function () { |
| 231 | 183 | return originalFetch(input, init) |
| 232 | 184 | } |
| 233 | 185 | |
| 234 | | // Legacy bearer token support |
| 235 | | var legacyToken = localStorage.getItem('hn-token') |
| 236 | | if (legacyToken) { |
| 237 | | init = init || {} |
| 238 | | init.headers = init.headers || {} |
| 239 | | init.headers['Authorization'] = 'Bearer ' + legacyToken |
| 240 | | return originalFetch(input, init) |
| 241 | | } |
| 242 | | |
| 243 | 186 | try { |
| 244 | 187 | var ch = await getChallenge() |
| 245 | 188 | var signature = await signChallenge(ch.challenge) |
| … | … | @@ -254,7 +197,6 @@ window.HnIdentity = (function () { |
| 254 | 197 | |
| 255 | 198 | var res = await originalFetch(input, init) |
| 256 | 199 | |
| 257 | | // On 401, retry once with fresh challenge |
| 258 | 200 | if (res.status === 401) { |
| 259 | 201 | cachedChallenge = null |
| 260 | 202 | ch = await getChallenge() |
| … | … | @@ -287,7 +229,7 @@ window.HnIdentity = (function () { |
| 287 | 229 | generate: generate, |
| 288 | 230 | getChallenge: getChallenge, |
| 289 | 231 | signChallenge: signChallenge, |
| 290 | | hasIdentity: function () { return !!getKeypair() }, |
| 232 | hasKeypair: function () { return !!getKeypair() }, |
| 291 | 233 | loadNoble: loadNoble, |
| 292 | 234 | clear: clear, |
| 293 | 235 | importKeypair: importKeypair, |