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