/**
* APDS-Compatible Identity Layer for Wirecore
*
* Matches Wiredove's apds API for browser-side identity management
* Uses IndexedDB for persistent storage
* Compatible with ANProto ed25519 keypair format (88 chars)
* Uses TweetNaCl for Ed25519 signing + Web Crypto API for SHA-256
*
* Note: Requires TweetNaCl to be loaded via script tag in HTML
*/
const DB_NAME = 'wirecore'
const DB_VERSION = 1
const STORE_NAME = 'identity'
let db = null
/**
* Initialize IndexedDB
*/
async function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
})
}
/**
* Base64 encode (standard base64 - matches server b4a format)
*/
function base64Encode(bytes) {
return btoa(String.fromCharCode(...bytes))
}
/**
* Base64 decode
*/
function base64Decode(str) {
const binary = atob(str)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
/**
* Generate ANProto-compatible ed25519 keypair (132 chars base64)
* Format: publicKey (44 chars) + secretKey (88 chars) = 132 total
*/
async function generateKeypair() {
// Generate keypair using TweetNaCl
const keypair = nacl.sign.keyPair()
// ANProto format: base64(publicKey) + base64(secretKey)
// publicKey: 32 bytes -> 44 chars
// secretKey: 64 bytes -> 88 chars (seed + public concatenated)
const publicKeyB64 = base64Encode(keypair.publicKey)
const secretKeyB64 = base64Encode(keypair.secretKey)
return publicKeyB64 + secretKeyB64
}
/**
* Deterministic JSON stringify (sorts keys) - matches ANProto
*/
function deterministicStringify(obj) {
if (obj === null) return 'null'
if (typeof obj !== 'object') return JSON.stringify(obj)
if (Array.isArray(obj)) return '[' + obj.map(item => deterministicStringify(item)).join(',') + ']'
const keys = Object.keys(obj).sort()
const pairs = keys.map(key => JSON.stringify(key) + ':' + deterministicStringify(obj[key]))
return '{' + pairs.join(',') + '}'
}
/**
* Hash data using SHA-256 (Web Crypto API)
*/
async function hashData(data) {
const encoder = new TextEncoder()
const dataBuffer = typeof data === 'string' ? encoder.encode(data) : data
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
return base64Encode(new Uint8Array(hashBuffer))
}
/**
* Sign data with keypair (TweetNaCl - ANProto compatible)
*/
async function signData(data, keypair) {
const publicKeyB64 = keypair.substring(0, 44)
const secretKeyB64 = keypair.substring(44, 132)
// Convert from base64
const secretKey = base64Decode(secretKeyB64)
// Sign the message (TweetNaCl returns signature+message concatenated)
const encoder = new TextEncoder()
const messageBuffer = encoder.encode(data)
const signedMessage = nacl.sign(messageBuffer, secretKey)
// Convert to base64
const signedB64 = base64Encode(signedMessage)
// ANProto format: publicKey + signedMessage
return publicKeyB64 + signedB64
}
/**
* APDS-compatible API
*/
export const apds = {
/**
* Start APDS (initialize IndexedDB)
*/
async start() {
// Check TweetNaCl is loaded
if (!window.nacl) {
throw new Error('TweetNaCl not loaded. Add script tag to HTML.')
}
db = await initDB()
console.log('Wirecore identity layer initialized (TweetNaCl ready)')
},
/**
* Generate new keypair
*/
async generate() {
return await generateKeypair()
},
/**
* Get stored keypair
*/
async keypair() {
if (!db) throw new Error('APDS not started')
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get('keypair')
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
},
/**
* Get public key only (first 44 chars of 132-char keypair)
*/
async pubkey() {
const keypair = await this.keypair()
return keypair ? keypair.substring(0, 44) : null
},
/**
* Store a value
*/
async put(key, value) {
if (!db) throw new Error('APDS not started')
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.put(value, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
},
/**
* Get a value
*/
async get(key) {
if (!db) throw new Error('APDS not started')
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const request = store.get(key)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
},
/**
* Hash content
*/
async hash(data) {
return await hashData(data)
},
/**
* Sign content with stored keypair
* If data is an object, it will be stringified deterministically (matching ANProto)
*/
async sign(data) {
const keypair = await this.keypair()
if (!keypair) {
throw new Error('No keypair - generate one first')
}
// If data is an object, use deterministic stringify to match server
const dataString = typeof data === 'object' ? deterministicStringify(data) : data
const hash = await this.hash(dataString)
// ANProto format: sign(timestamp + hash)
const timestamp = Date.now().toString()
const message = timestamp + hash
return await signData(message, keypair)
}
}