OverviewFilesCommitsIssuesDocsReleasesPeers

anproto commits / a49ebbb

Initial commit — anproto
Wire-compatible implementation of evbogue's ANProto (Authenticated Non-networked protocol). Ed25519-signed timestamp+hash messages verified byte-identical against the canonical reference using hypercore-crypto + sodium-native (no vendored TweetNaCl). Includes Challenges helper for replay-protected challenge-response flows (browser login, etc.) as a separate export — not part of the canonical wire spec, useful for pear-git-v3 and hyper-social consumers. 14 tests / all passing. Phase 1 of the pear-git-v3 bottom-up rebuild. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
a49ebbbcac6df3912b384a969a87b68d8a13f835
by plankatron <fleeky@gmail.com> · 2026-04-15T03:27:27.000Z

Changed files (9) +1830 -0

.gitignore +15 -0
……@@ -0,0 +1,15 @@
1# Dependencies
2node_modules/
3
4# Storage directories created by example CLIs
5.anproto-*/
6
7# Editor and OS cruft
8.DS_Store
9*.log
10*.swp
11.vscode/
12.idea/
13
14# Test artifacts
15coverage/
examples/cli.js +110 -0
……@@ -0,0 +1,110 @@
1#!/usr/bin/env node
2/**
3 * Mini CLI example for anproto
4 *
5 * Demonstrates the canonical 4 functions: gen → hash → sign → open
6 *
7 * Usage:
8 * node examples/cli.js gen Generate a new keypair
9 * node examples/cli.js hash <data> Hash some data
10 * node examples/cli.js sign <hash> <keypair> Sign a hash
11 * node examples/cli.js open <signature> Open a signature
12 * node examples/cli.js demo Run a full round-trip
13 * node examples/cli.js challenges Demo the Challenges helper
14 */
15
16import { an, Challenges } from '../index.js'
17
18const args = process.argv.slice(2)
19const cmd = args[0]
20
21async function main () {
22 if (cmd === 'gen') {
23 const k = await an.gen()
24 console.log('keypair: %s', k)
25 console.log('pubkey: %s', an.publicKey(k))
26 } else if (cmd === 'hash') {
27 const data = args[1]
28 if (!data) { console.error('Usage: cli.js hash <data>'); process.exit(1) }
29 const h = await an.hash(data)
30 console.log(h)
31 } else if (cmd === 'sign') {
32 const h = args[1]
33 const k = args[2]
34 if (!h || !k) { console.error('Usage: cli.js sign <hash> <keypair>'); process.exit(1) }
35 const sig = await an.sign(h, k)
36 console.log(sig)
37 } else if (cmd === 'open') {
38 const sig = args[1]
39 if (!sig) { console.error('Usage: cli.js open <signature>'); process.exit(1) }
40 try {
41 const opened = await an.open(sig)
42 const parsed = an.parse(opened)
43 console.log('opened: %s', opened)
44 console.log('timestamp: %d (%s)', parsed.timestamp, new Date(parsed.timestamp).toISOString())
45 console.log('hash: %s', parsed.hash)
46 } catch (err) {
47 console.error('Invalid signature: %s', err.message)
48 process.exit(1)
49 }
50 } else if (cmd === 'demo') {
51 console.log('--- ANProto round-trip demo ---\n')
52
53 const message = args[1] || 'Hello, ANProto!'
54 console.log('1. Generate keypair')
55 const k = await an.gen()
56 console.log(' keypair: %s\n', k)
57
58 console.log('2. Hash message: %j', message)
59 const h = await an.hash(message)
60 console.log(' hash: %s\n', h)
61
62 console.log('3. Sign hash')
63 const sig = await an.sign(h, k)
64 console.log(' sig: %s\n', sig)
65
66 console.log('4. Open signature')
67 const opened = await an.open(sig)
68 const parsed = an.parse(opened)
69 console.log(' opened: %s', opened)
70 console.log(' timestamp: %d (%s)', parsed.timestamp, new Date(parsed.timestamp).toISOString())
71 console.log(' hash: %s\n', parsed.hash)
72
73 console.log('5. Verify hash matches')
74 console.log(' match: %s', parsed.hash === h ? 'YES ✓' : 'NO ✗')
75 } else if (cmd === 'challenges') {
76 console.log('--- Challenges helper demo ---\n')
77 const c = new Challenges({ ttl: 5000 })
78
79 console.log('Generate 3 challenges:')
80 const c1 = c.generate()
81 const c2 = c.generate()
82 const c3 = c.generate()
83 console.log(' c1: %s', c1)
84 console.log(' c2: %s', c2)
85 console.log(' c3: %s', c3)
86 console.log(' active: %d\n', c.size)
87
88 console.log('Consume c2:')
89 console.log(' consume(c2): %s', c.consume(c2))
90 console.log(' consume(c2): %s (single-use)', c.consume(c2))
91 console.log(' active: %d\n', c.size)
92
93 console.log('Verify c1 still active:')
94 console.log(' has(c1): %s', c.has(c1))
95 console.log(' has(c2): %s', c.has(c2))
96 console.log(' has(c3): %s', c.has(c3))
97
98 c.destroy()
99 } else {
100 console.log('anproto example CLI')
101 console.log(' gen Generate a keypair')
102 console.log(' hash <data> Hash data')
103 console.log(' sign <hash> <keypair> Sign a hash')
104 console.log(' open <signature> Open a signature')
105 console.log(' demo [message] Full round-trip demo')
106 console.log(' challenges Replay-protection helper demo')
107 }
108}
109
110main().catch((err) => { console.error(err.message); process.exit(1) })
examples/web.js +356 -0
……@@ -0,0 +1,356 @@
1#!/usr/bin/env node
2/**
3 * Mini web example for anproto
4 *
5 * Demonstrates a complete SSR browser login flow:
6 * 1. Browser visits / and gets a login page with a fresh challenge
7 * 2. Browser generates an Ed25519 keypair (stored in localStorage)
8 * 3. Browser hashes the challenge and signs it (canonical anproto)
9 * 4. Browser POSTs the signature to /login
10 * 5. Server verifies the signature, consumes the challenge, sets a cookie
11 * 6. Subsequent requests use the cookie to identify the user
12 *
13 * Usage: node examples/web.js [--port 9093]
14 *
15 * Browser side uses noble-ed25519 (vendored from canonical anproto via
16 * lib/nacl-fast-es.js) — but for the demo we use a tiny inline shim that
17 * uses WebCrypto's Ed25519 (Chrome 137+, Firefox 130+, Safari 17+).
18 */
19
20import http from 'http'
21import { an, Challenges } from '../index.js'
22import b4a from 'b4a'
23
24const args = process.argv.slice(2)
25function getFlag (name) {
26 const i = args.indexOf('--' + name)
27 return i !== -1 && args[i + 1] ? args[i + 1] : null
28}
29
30const port = parseInt(getFlag('port')) || 9093
31
32// ── State ─────────────────────────────────────────────────────
33
34const challenges = new Challenges({ ttl: 5 * 60 * 1000 })
35// pubkey → { firstSeen, lastSeen, loginCount }
36const sessions = new Map()
37// Cookie value → pubkey
38const cookieToPubkey = new Map()
39
40function newCookieValue () {
41 return Math.random().toString(36).slice(2) + Date.now().toString(36)
42}
43
44function parseCookies (req) {
45 const out = {}
46 const header = req.headers.cookie
47 if (!header) return out
48 for (const part of header.split(';')) {
49 const [k, v] = part.trim().split('=')
50 out[k] = v
51 }
52 return out
53}
54
55function escapeHtml (s) {
56 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
57}
58
59// ── HTTP server ───────────────────────────────────────────────
60
61const httpServer = http.createServer(async (req, res) => {
62 const url = req.url.split('?')[0]
63 const cookies = parseCookies(req)
64 const sessionPubkey = cookies.session ? cookieToPubkey.get(cookies.session) : null
65
66 // ── GET / — landing page ─────────────────────────────────────
67
68 if (req.method === 'GET' && url === '/') {
69 const challenge = challenges.generate()
70 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
71 res.end(renderHomePage(challenge, sessionPubkey))
72 return
73 }
74
75 // ── POST /login — verify the signature ──────────────────────
76
77 if (req.method === 'POST' && url === '/login') {
78 const body = await collectBody(req)
79 const params = new URLSearchParams(body)
80 const challenge = params.get('challenge')
81 const signature = params.get('signature')
82
83 if (!challenge || !signature) {
84 res.writeHead(400, { 'Content-Type': 'text/plain' })
85 res.end('Missing challenge or signature')
86 return
87 }
88
89 // Verify the challenge is one we issued and not yet consumed
90 if (!challenges.consume(challenge)) {
91 res.writeHead(400, { 'Content-Type': 'text/plain' })
92 res.end('Unknown or expired challenge')
93 return
94 }
95
96 // Verify the signature is valid ANProto
97 let opened
98 try {
99 opened = await an.open(signature)
100 } catch (err) {
101 res.writeHead(400, { 'Content-Type': 'text/plain' })
102 res.end('Invalid signature: ' + err.message)
103 return
104 }
105
106 // Verify the signed hash matches the challenge we issued
107 const expectedHash = await an.hash(challenge)
108 const parsed = an.parse(opened)
109 if (parsed.hash !== expectedHash) {
110 res.writeHead(400, { 'Content-Type': 'text/plain' })
111 res.end('Signature does not match challenge hash')
112 return
113 }
114
115 // Verify the timestamp is recent (defence in depth — challenge consumption
116 // already prevents replay, but the timestamp catches stale signatures)
117 if (Math.abs(Date.now() - parsed.timestamp) > 5 * 60 * 1000) {
118 res.writeHead(400, { 'Content-Type': 'text/plain' })
119 res.end('Signature timestamp out of window')
120 return
121 }
122
123 // Extract the pubkey from the signature
124 const pubkey = signature.substring(0, 44)
125
126 // Update session info
127 const existing = sessions.get(pubkey)
128 sessions.set(pubkey, {
129 firstSeen: existing ? existing.firstSeen : Date.now(),
130 lastSeen: Date.now(),
131 loginCount: (existing?.loginCount || 0) + 1
132 })
133
134 // Issue a session cookie
135 const cookieValue = newCookieValue()
136 cookieToPubkey.set(cookieValue, pubkey)
137
138 res.writeHead(303, {
139 'Set-Cookie': `session=${cookieValue}; HttpOnly; Path=/; Max-Age=${60 * 60 * 24}`,
140 Location: '/'
141 })
142 res.end()
143 return
144 }
145
146 // ── POST /logout ─────────────────────────────────────────────
147
148 if (req.method === 'POST' && url === '/logout') {
149 if (cookies.session) cookieToPubkey.delete(cookies.session)
150 res.writeHead(303, {
151 'Set-Cookie': 'session=; HttpOnly; Path=/; Max-Age=0',
152 Location: '/'
153 })
154 res.end()
155 return
156 }
157
158 // ── 404 ──────────────────────────────────────────────────────
159
160 res.writeHead(404, { 'Content-Type': 'text/plain' })
161 res.end('Not found')
162})
163
164function collectBody (req) {
165 return new Promise((resolve, reject) => {
166 const parts = []
167 req.on('data', chunk => parts.push(chunk))
168 req.on('end', () => resolve(b4a.concat(parts).toString('utf8')))
169 req.on('error', reject)
170 })
171}
172
173// ── HTML pages (SSR — no JSON API) ────────────────────────────
174
175function renderHomePage (challenge, sessionPubkey) {
176 const session = sessionPubkey ? sessions.get(sessionPubkey) : null
177
178 return `<!DOCTYPE html>
179<html><head><title>anproto demo</title>
180<style>
181 body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 720px; margin: 0 auto; }
182 h1 { color: #58a6ff; }
183 .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
184 .card h2 { font-size: 0.75rem; text-transform: uppercase; color: #8b949e; margin: 0 0 0.5rem; }
185 .row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
186 .row:last-child { border-bottom: none; }
187 .label { color: #8b949e; }
188 .value { font-family: monospace; color: #79c0ff; word-break: break-all; max-width: 70%; text-align: right; }
189 .btn { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 0.4rem 0.8rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; font-family: inherit; }
190 .btn:hover { background: #30363d; }
191 .btn-primary { background: #1f6feb; border-color: #1f6feb; color: white; }
192 .btn-primary:hover { background: #1158c7; }
193 #log { font-size: 0.8rem; color: #8b949e; min-height: 4em; }
194 #log div { padding: 2px 0; }
195 .ok { color: #3fb950; }
196 .err { color: #f85149; }
197</style></head><body>
198<h1>anproto SSR login demo</h1>
199
200${sessionPubkey ? renderLoggedIn(sessionPubkey, session) : renderLoginForm(challenge)}
201
202<div class="card">
203 <h2>How this works</h2>
204 <ol style="line-height: 1.6; font-size: 0.85rem;">
205 <li>Server issues a random <strong>challenge</strong> in the page</li>
206 <li>Browser generates an Ed25519 keypair (stored in localStorage)</li>
207 <li>Browser hashes the challenge and signs it via canonical ANProto</li>
208 <li>Browser POSTs the signature to <code>/login</code></li>
209 <li>Server verifies the signature, consumes the challenge, sets a cookie</li>
210 </ol>
211</div>
212
213</body></html>`
214}
215
216function renderLoginForm (challenge) {
217 return `<div class="card">
218 <h2>Not logged in</h2>
219 <div class="row"><span class="label">Challenge</span><span class="value">${escapeHtml(challenge)}</span></div>
220 <div style="margin-top: 1rem">
221 <button class="btn btn-primary" id="login-btn">Sign in with Ed25519</button>
222 <button class="btn" id="reset-btn">Generate new keypair</button>
223 <span id="log"></span>
224 </div>
225</div>
226
227<script type="module">
228const log = (msg, cls = '') => {
229 const d = document.createElement('div')
230 d.textContent = msg
231 if (cls) d.className = cls
232 document.getElementById('log').appendChild(d)
233}
234
235// ── Browser ANProto using WebCrypto Ed25519 ──
236//
237// We re-implement the canonical ANProto wire format on the browser side.
238// Modern browsers (Chrome 137+, Firefox 130+, Safari 17+) have Ed25519 in
239// WebCrypto. For older browsers, swap in noble-ed25519 (~10 KB JS bundle).
240
241const enc = new TextEncoder()
242const dec = new TextDecoder()
243
244const b64encode = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
245const b64decode = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0))
246
247async function gen() {
248 const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
249 // Export raw pubkey (32 bytes)
250 const pubRaw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey))
251 // Export pkcs8 secret key, extract the seed
252 const secPkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', kp.privateKey))
253 // pkcs8 format: ASN.1 wrapping. The 32-byte seed is at the end (last 32 bytes of the OCTET STRING)
254 // Quick extraction: seed is the last 32 bytes of the pkcs8 buffer
255 const seed = secPkcs8.slice(-32)
256 // ANProto secret key = seed (32 bytes) || pubkey (32 bytes) — that's the libsodium 64-byte secret key format
257 const sec = new Uint8Array(64)
258 sec.set(seed, 0)
259 sec.set(pubRaw, 32)
260 return b64encode(pubRaw) + b64encode(sec)
261}
262
263async function hash(data) {
264 const buf = enc.encode(data)
265 const h = await crypto.subtle.digest('SHA-256', buf)
266 return b64encode(h)
267}
268
269async function sign(h, keypair) {
270 const ts = Date.now()
271 const message = enc.encode(String(ts) + h)
272 // Reconstruct the WebCrypto private key from the seed (first 32 of the 64-byte sec key)
273 const secBytes = b64decode(keypair.substring(44))
274 const seed = secBytes.slice(0, 32)
275 // Build a minimal pkcs8 wrapper for an Ed25519 seed
276 const pkcs8 = new Uint8Array([
277 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
278 ...seed
279 ])
280 const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign'])
281 const sigBytes = new Uint8Array(await crypto.subtle.sign('Ed25519', privateKey, message))
282 // Combined-mode envelope: sig (64) || message
283 const combined = new Uint8Array(sigBytes.length + message.length)
284 combined.set(sigBytes, 0)
285 combined.set(message, sigBytes.length)
286 return keypair.substring(0, 44) + b64encode(combined)
287}
288
289// ── Login flow ──
290
291document.getElementById('reset-btn').onclick = () => {
292 localStorage.removeItem('anproto-keypair')
293 log('Keypair cleared from localStorage')
294}
295
296document.getElementById('login-btn').onclick = async () => {
297 try {
298 let kp = localStorage.getItem('anproto-keypair')
299 if (!kp) {
300 log('No keypair in localStorage, generating new one...')
301 kp = await gen()
302 localStorage.setItem('anproto-keypair', kp)
303 log('Generated keypair (saved to localStorage)', 'ok')
304 log(' pubkey: ' + kp.substring(0, 44))
305 } else {
306 log('Loaded existing keypair from localStorage', 'ok')
307 log(' pubkey: ' + kp.substring(0, 44))
308 }
309
310 const challenge = ${JSON.stringify(challenge)}
311 log('Hashing challenge...')
312 const h = await hash(challenge)
313 log(' hash: ' + h)
314
315 log('Signing hash...')
316 const sig = await sign(h, kp)
317 log(' sig: ' + sig.substring(0, 60) + '...')
318
319 log('POSTing /login...')
320 const body = new URLSearchParams({ challenge, signature: sig })
321 const res = await fetch('/login', {
322 method: 'POST',
323 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
324 body
325 })
326 if (res.ok || res.redirected) {
327 log('Login successful — reloading', 'ok')
328 window.location.href = '/'
329 } else {
330 const text = await res.text()
331 log('Login failed: ' + text, 'err')
332 }
333 } catch (err) {
334 log('Error: ' + err.message, 'err')
335 }
336}
337</script>`
338}
339
340function renderLoggedIn (pubkey, session) {
341 return `<div class="card">
342 <h2>Logged in</h2>
343 <div class="row"><span class="label">Pubkey</span><span class="value">${escapeHtml(pubkey)}</span></div>
344 <div class="row"><span class="label">First seen</span><span class="value">${session ? new Date(session.firstSeen).toLocaleString() : '?'}</span></div>
345 <div class="row"><span class="label">Last seen</span><span class="value">${session ? new Date(session.lastSeen).toLocaleString() : '?'}</span></div>
346 <div class="row"><span class="label">Login count</span><span class="value">${session ? session.loginCount : 1}</span></div>
347 <form action="/logout" method="POST" style="margin-top: 1rem">
348 <button class="btn" type="submit">Log out</button>
349 </form>
350</div>`
351}
352
353httpServer.listen(port, () => {
354 console.log('anproto SSR demo running:')
355 console.log(' http://localhost:%d', port)
356})
index.js +12 -0
……@@ -0,0 +1,12 @@
1/**
2 * anproto — wire-compatible implementation of evbogue's ANProto
3 *
4 * https://anproto.com — the Authenticated Non-networked protocol
5 *
6 * Exports:
7 * an — the canonical 4 functions: gen, hash, sign, open (+ helpers)
8 * Challenges — replay-protection helper for HTTP login flows (not part of spec)
9 */
10
11export { an } from './lib/an.js'
12export { Challenges } from './lib/challenges.js'
lib/an.js +125 -0
……@@ -0,0 +1,125 @@
1/**
2 * an.js — wire-compatible implementation of evbogue's ANProto
3 *
4 * Spec: https://anproto.com (canonical at github.com/evbogue/ANProto)
5 *
6 * Same wire format as the canonical TweetNaCl-based implementation, but
7 * uses sodium-native (via hypercore-crypto) so we don't carry a second
8 * Ed25519 implementation. Both libraries implement the same Ed25519
9 * standard and produce byte-identical signatures for the same inputs.
10 *
11 * The four canonical functions:
12 *
13 * gen() → 132-char keypair string (44 pubkey b64 + 88 secret b64)
14 * hash(data) → 44-char base64 SHA-256
15 * sign(hash, kp) → pubkey(44) + base64(combined-mode-sign(timestamp + hash))
16 * open(message) → "{timestamp}{hash}" string (throws if invalid)
17 */
18
19import crypto from 'hypercore-crypto'
20import sodium from 'sodium-native'
21import b4a from 'b4a'
22
23const SIG_BYTES = 64 // Ed25519 detached signature size (crypto_sign_BYTES)
24
25export const an = {}
26
27/**
28 * Generate a new Ed25519 keypair.
29 * @returns {Promise<string>} 132-char string: base64(pubkey 32B) + base64(secret 64B)
30 */
31an.gen = async () => {
32 const { publicKey, secretKey } = crypto.keyPair()
33 return b4a.toString(publicKey, 'base64') + b4a.toString(secretKey, 'base64')
34}
35
36/**
37 * SHA-256 hash data.
38 * @param {string|Buffer} data
39 * @returns {Promise<string>} 44-char base64 SHA-256 hash
40 */
41an.hash = async (data) => {
42 const buf = typeof data === 'string' ? b4a.from(data, 'utf8') : data
43 const out = b4a.alloc(32)
44 sodium.crypto_hash_sha256(out, buf)
45 return b4a.toString(out, 'base64')
46}
47
48/**
49 * Sign a hash with a keypair, producing a wire-format ANProto signature.
50 * The signed message is "{Date.now()}{hash}" — timestamp prefixed.
51 *
52 * Wire format: pubkey(44 b64) + base64(combined_mode_sign(message))
53 * where combined_mode_sign(m) = signature(64B) || m
54 *
55 * @param {string} hash - 44-char base64 hash from an.hash()
56 * @param {string} keypair - 132-char keypair string from an.gen()
57 * @returns {Promise<string>} wire-format signature
58 */
59an.sign = async (hash, keypair) => {
60 const ts = Date.now()
61 const message = b4a.from(String(ts) + hash, 'utf8')
62 const secretKey = b4a.from(keypair.substring(44), 'base64')
63 const sig = crypto.sign(message, secretKey) // detached, 64 bytes
64 // Combined-mode envelope: signature || message
65 const combined = b4a.concat([sig, message])
66 return keypair.substring(0, 44) + b4a.toString(combined, 'base64')
67}
68
69/**
70 * Verify and open an ANProto signature, returning the embedded message.
71 * The returned string is "{timestamp}{hash}" — caller can split at offset 13.
72 *
73 * @param {string} message - wire-format signature from an.sign()
74 * @returns {Promise<string>} the original signed message ("{timestamp}{hash}")
75 * @throws {Error} if the signature is invalid
76 */
77an.open = async (message) => {
78 if (typeof message !== 'string' || message.length < 44) {
79 throw new Error('Invalid ANProto message (too short)')
80 }
81 const publicKey = b4a.from(message.substring(0, 44), 'base64')
82 const combined = b4a.from(message.substring(44), 'base64')
83 if (publicKey.length !== 32) throw new Error('Invalid ANProto pubkey length')
84 if (combined.length < SIG_BYTES) throw new Error('Invalid ANProto combined-mode envelope')
85
86 const sig = combined.subarray(0, SIG_BYTES)
87 const body = combined.subarray(SIG_BYTES)
88
89 if (!crypto.verify(body, sig, publicKey)) {
90 throw new Error('Invalid ANProto signature')
91 }
92 return b4a.toString(body, 'utf8')
93}
94
95// ── Convenience helpers (not part of canonical spec, but commonly needed) ──
96
97/**
98 * Extract the public key from a keypair string.
99 * @param {string} keypair - 132-char keypair string
100 * @returns {string} 44-char base64 public key
101 */
102an.publicKey = (keypair) => {
103 if (typeof keypair !== 'string' || keypair.length !== 132) {
104 throw new Error('Invalid keypair (expected 132 chars)')
105 }
106 return keypair.substring(0, 44)
107}
108
109/**
110 * Parse an opened message into its timestamp and hash components.
111 * Canonical format: 13-digit timestamp followed by 44-char base64 hash.
112 * @param {string} opened - the string returned by an.open()
113 * @returns {{ timestamp: number, hash: string }}
114 */
115an.parse = (opened) => {
116 if (typeof opened !== 'string' || opened.length < 13) {
117 throw new Error('Invalid opened message')
118 }
119 const tsMatch = opened.match(/^(\d{13})(.+)$/)
120 if (!tsMatch) throw new Error('Invalid opened message format')
121 return {
122 timestamp: parseInt(tsMatch[1], 10),
123 hash: tsMatch[2]
124 }
125}
lib/challenges.js +93 -0
……@@ -0,0 +1,93 @@
1/**
2 * Challenges — in-memory replay protection helper for ANProto login flows
3 *
4 * Not part of canonical ANProto. A small helper for HTTP login flows where
5 * the server issues a challenge string, the client signs hash(challenge),
6 * and the server consumes the challenge to prevent replay.
7 *
8 * Usage:
9 * const challenges = new Challenges({ ttl: 5 * 60 * 1000 })
10 * const challenge = challenges.generate() // server issues
11 * // ... browser signs hash(challenge), POSTs sig ...
12 * if (!challenges.consume(challenge)) reject() // server consumes (single-use)
13 */
14
15import crypto from 'hypercore-crypto'
16import b4a from 'b4a'
17
18const DEFAULT_TTL = 5 * 60 * 1000 // 5 minutes
19const DEFAULT_CLEANUP_INTERVAL = 60 * 1000 // 1 minute
20
21export class Challenges {
22 constructor (opts = {}) {
23 this.ttl = opts.ttl || DEFAULT_TTL
24 this._active = new Map() // challenge string → expiresAt
25 this._cleanupTimer = setInterval(() => this._cleanup(), opts.cleanupInterval || DEFAULT_CLEANUP_INTERVAL)
26 if (this._cleanupTimer.unref) this._cleanupTimer.unref()
27 }
28
29 /**
30 * Issue a new challenge string. The caller sends this to the client; the
31 * client signs hash(challenge) and returns the signature.
32 * @returns {string} 44-char base64 random challenge
33 */
34 generate () {
35 const challenge = b4a.toString(crypto.randomBytes(32), 'base64')
36 this._active.set(challenge, Date.now() + this.ttl)
37 return challenge
38 }
39
40 /**
41 * Consume a challenge. Returns true if the challenge was active and not
42 * yet consumed; false otherwise. Single-use — calling consume() removes
43 * the challenge from the active set.
44 * @param {string} challenge
45 * @returns {boolean}
46 */
47 consume (challenge) {
48 const expiresAt = this._active.get(challenge)
49 if (expiresAt === undefined) return false
50 if (expiresAt < Date.now()) {
51 this._active.delete(challenge)
52 return false
53 }
54 this._active.delete(challenge)
55 return true
56 }
57
58 /**
59 * Check if a challenge is active without consuming it.
60 * @param {string} challenge
61 * @returns {boolean}
62 */
63 has (challenge) {
64 const expiresAt = this._active.get(challenge)
65 if (expiresAt === undefined) return false
66 return expiresAt >= Date.now()
67 }
68
69 /**
70 * Number of active (unexpired) challenges.
71 */
72 get size () {
73 return this._active.size
74 }
75
76 _cleanup () {
77 const now = Date.now()
78 for (const [challenge, expiresAt] of this._active) {
79 if (expiresAt < now) this._active.delete(challenge)
80 }
81 }
82
83 /**
84 * Stop the cleanup timer. Call when shutting down.
85 */
86 destroy () {
87 if (this._cleanupTimer) {
88 clearInterval(this._cleanupTimer)
89 this._cleanupTimer = null
90 }
91 this._active.clear()
92 }
93}
package-lock.json +951 -0
……@@ -0,0 +1,951 @@
1{
2 "name": "anproto",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "anproto",
9 "version": "0.1.0",
10 "dependencies": {
11 "b4a": "^1.6.0",
12 "hypercore-crypto": "^3.4.0",
13 "sodium-native": "^4.0.0"
14 },
15 "devDependencies": {
16 "brittle": "^3.0.0"
17 }
18 },
19 "node_modules/@jridgewell/resolve-uri": {
20 "version": "3.1.2",
21 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
22 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
23 "dev": true,
24 "license": "MIT",
25 "engines": {
26 "node": ">=6.0.0"
27 }
28 },
29 "node_modules/@jridgewell/sourcemap-codec": {
30 "version": "1.5.5",
31 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
32 "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
33 "dev": true,
34 "license": "MIT"
35 },
36 "node_modules/@jridgewell/trace-mapping": {
37 "version": "0.3.31",
38 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
39 "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
40 "dev": true,
41 "license": "MIT",
42 "dependencies": {
43 "@jridgewell/resolve-uri": "^3.1.0",
44 "@jridgewell/sourcemap-codec": "^1.4.14"
45 }
46 },
47 "node_modules/@types/istanbul-lib-coverage": {
48 "version": "2.0.6",
49 "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
50 "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
51 "dev": true,
52 "license": "MIT"
53 },
54 "node_modules/b4a": {
55 "version": "1.8.0",
56 "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
57 "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
58 "license": "Apache-2.0",
59 "peerDependencies": {
60 "react-native-b4a": "*"
61 },
62 "peerDependenciesMeta": {
63 "react-native-b4a": {
64 "optional": true
65 }
66 }
67 },
68 "node_modules/bare-abort": {
69 "version": "2.0.13",
70 "resolved": "https://registry.npmjs.org/bare-abort/-/bare-abort-2.0.13.tgz",
71 "integrity": "sha512-zdc8l88eB11Jsz5rDd6sCAgv2kUFXgdrZWoMlgU6JMkfAi1/uuGFC3IEHswKbIRQTk5H3T5CMuechsXYxiaHlQ==",
72 "dev": true,
73 "license": "Apache-2.0"
74 },
75 "node_modules/bare-addon-resolve": {
76 "version": "1.10.0",
77 "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz",
78 "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==",
79 "license": "Apache-2.0",
80 "dependencies": {
81 "bare-module-resolve": "^1.10.0",
82 "bare-semver": "^1.0.0"
83 },
84 "peerDependencies": {
85 "bare-url": "*"
86 },
87 "peerDependenciesMeta": {
88 "bare-url": {
89 "optional": true
90 }
91 }
92 },
93 "node_modules/bare-ansi-escapes": {
94 "version": "2.2.3",
95 "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz",
96 "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==",
97 "license": "Apache-2.0",
98 "dependencies": {
99 "bare-stream": "^2.6.5"
100 },
101 "peerDependencies": {
102 "bare-buffer": "*"
103 },
104 "peerDependenciesMeta": {
105 "bare-buffer": {
106 "optional": true
107 }
108 }
109 },
110 "node_modules/bare-assert": {
111 "version": "1.2.0",
112 "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz",
113 "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==",
114 "license": "Apache-2.0",
115 "dependencies": {
116 "bare-inspect": "^3.1.2"
117 }
118 },
119 "node_modules/bare-bundle": {
120 "version": "1.10.0",
121 "resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz",
122 "integrity": "sha512-4LVlnJAHr00Hh6Vu6ZUJS38rcEtJT3b3vChXSsBsJ2mk1TN0lQ+gzd+Dw5L0aV7uqDZv84smuwW+O02X7PfDlw==",
123 "dev": true,
124 "license": "Apache-2.0",
125 "peerDependencies": {
126 "bare-buffer": "*",
127 "bare-url": "*"
128 },
129 "peerDependenciesMeta": {
130 "bare-buffer": {
131 "optional": true
132 },
133 "bare-url": {
134 "optional": true
135 }
136 }
137 },
138 "node_modules/bare-cov": {
139 "version": "1.2.1",
140 "resolved": "https://registry.npmjs.org/bare-cov/-/bare-cov-1.2.1.tgz",
141 "integrity": "sha512-SHNqb/WG9udUEj8m5qGSehoUQ9o0Xzv8nrf4HIdmG+KNU/XdhPLhTP5VPrHnSAMAXhAzEB4Q+2IYZ0x9qPzN1g==",
142 "dev": true,
143 "license": "Apache-2.0",
144 "dependencies": {
145 "bare-fs": "^4.1.2",
146 "bare-inspector": "^4.0.1",
147 "bare-path": "^3.0.0",
148 "bare-process": "^4.2.1",
149 "bare-url": "^2.1.5",
150 "bare-v8-to-istanbul": "^1.0.2",
151 "picomatch": "^4.0.2",
152 "which-runtime": "^1.2.1"
153 }
154 },
155 "node_modules/bare-crypto": {
156 "version": "1.13.4",
157 "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.13.4.tgz",
158 "integrity": "sha512-JiCZ5l2YOG1y8J7yy1BCAKTCZrPnHLb7pDRIdurBTOn5oIwBQDIv8iH5Pl2V85vzjl1NZXRfNY4HZLsE942jJA==",
159 "dev": true,
160 "license": "Apache-2.0",
161 "dependencies": {
162 "bare-assert": "^1.2.0",
163 "bare-stream": "^2.6.3"
164 },
165 "peerDependencies": {
166 "bare-buffer": "*"
167 },
168 "peerDependenciesMeta": {
169 "bare-buffer": {
170 "optional": true
171 }
172 }
173 },
174 "node_modules/bare-debug-log": {
175 "version": "2.0.0",
176 "resolved": "https://registry.npmjs.org/bare-debug-log/-/bare-debug-log-2.0.0.tgz",
177 "integrity": "sha512-Vi42PkMQsNV9PUpx2Gl1hikshx5O9FzMJ6o9Nnopseg7qLBBK7Nl31d0RHcfwLEAfmcPApytpc0ZFfq68u22FQ==",
178 "dev": true,
179 "license": "Apache-2.0",
180 "dependencies": {
181 "bare-os": "^3.0.1"
182 }
183 },
184 "node_modules/bare-dns": {
185 "version": "2.1.4",
186 "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.1.4.tgz",
187 "integrity": "sha512-abwjHmpWqSRNB7V5615QxPH92L71AVzFm/kKTs8VYiNTAi2xVdonpv0BjJ0hwXLwomoW+xsSOPjW6PZPO14asg==",
188 "dev": true,
189 "license": "Apache-2.0",
190 "engines": {
191 "bare": ">=1.7.0"
192 }
193 },
194 "node_modules/bare-encoding": {
195 "version": "1.0.3",
196 "resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz",
197 "integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==",
198 "dev": true,
199 "license": "Apache-2.0",
200 "peerDependencies": {
201 "bare-buffer": "*"
202 },
203 "peerDependenciesMeta": {
204 "bare-buffer": {
205 "optional": true
206 }
207 }
208 },
209 "node_modules/bare-env": {
210 "version": "3.0.0",
211 "resolved": "https://registry.npmjs.org/bare-env/-/bare-env-3.0.0.tgz",
212 "integrity": "sha512-0u964P5ZLAxTi+lW4Kjp7YRJQ5gZr9ycYOtjLxsSrupgMz3sn5Z9n4SH/JIifHwvadsf1brA2JAjP+9IOWwTiw==",
213 "dev": true,
214 "license": "Apache-2.0",
215 "dependencies": {
216 "bare-os": "^3.0.1"
217 }
218 },
219 "node_modules/bare-events": {
220 "version": "2.8.2",
221 "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
222 "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
223 "license": "Apache-2.0",
224 "peerDependencies": {
225 "bare-abort-controller": "*"
226 },
227 "peerDependenciesMeta": {
228 "bare-abort-controller": {
229 "optional": true
230 }
231 }
232 },
233 "node_modules/bare-format": {
234 "version": "1.0.2",
235 "resolved": "https://registry.npmjs.org/bare-format/-/bare-format-1.0.2.tgz",
236 "integrity": "sha512-GswdhnOnP9QtwRbrf4wLApw5widkaLMsLe2XOs35fQD2YfEN1ApoGka+cZ7PfvzxMgfYXmMhj/2OGlVn5/Dxgw==",
237 "dev": true,
238 "license": "Apache-2.0",
239 "dependencies": {
240 "bare-inspect": "^3.0.0"
241 }
242 },
243 "node_modules/bare-fs": {
244 "version": "4.7.0",
245 "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz",
246 "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==",
247 "dev": true,
248 "license": "Apache-2.0",
249 "dependencies": {
250 "bare-events": "^2.5.4",
251 "bare-path": "^3.0.0",
252 "bare-stream": "^2.6.4",
253 "bare-url": "^2.2.2",
254 "fast-fifo": "^1.3.2"
255 },
256 "engines": {
257 "bare": ">=1.16.0"
258 },
259 "peerDependencies": {
260 "bare-buffer": "*"
261 },
262 "peerDependenciesMeta": {
263 "bare-buffer": {
264 "optional": true
265 }
266 }
267 },
268 "node_modules/bare-hrtime": {
269 "version": "2.1.1",
270 "resolved": "https://registry.npmjs.org/bare-hrtime/-/bare-hrtime-2.1.1.tgz",
271 "integrity": "sha512-VMb3tHo05gsnbu3OXTmkDiwTjMlOsbQmKoysKqKEyR09m77TuDrYFbj3Q5GGk10dAKsUHrnXmwCaeJqzVpB5ZA==",
272 "dev": true,
273 "license": "Apache-2.0"
274 },
275 "node_modules/bare-http-parser": {
276 "version": "1.1.3",
277 "resolved": "https://registry.npmjs.org/bare-http-parser/-/bare-http-parser-1.1.3.tgz",
278 "integrity": "sha512-+dhVvQi6brHq14L/XHNRQ+TLuVE76VjRmMt61wVEtS+Od8xUslfMHWJN/ZjIIt3RtTG6vPuA+x9cOh7KrkBJsA==",
279 "dev": true,
280 "license": "Apache-2.0"
281 },
282 "node_modules/bare-http1": {
283 "version": "4.5.6",
284 "resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.6.tgz",
285 "integrity": "sha512-31OAwMkSU+z1VuUOCk65hx3aWQgzCfH/zQ6LGxbJtmiy2Czsw0+uvOBM9YkqaL6zUSTSYG2pLbL0v/TjME3Buw==",
286 "dev": true,
287 "license": "Apache-2.0",
288 "dependencies": {
289 "bare-events": "^2.6.0",
290 "bare-http-parser": "^1.1.1",
291 "bare-stream": "^2.10.0",
292 "bare-tcp": "^2.2.0"
293 },
294 "peerDependencies": {
295 "bare-buffer": "*",
296 "bare-url": "*"
297 },
298 "peerDependenciesMeta": {
299 "bare-buffer": {
300 "optional": true
301 },
302 "bare-url": {
303 "optional": true
304 }
305 }
306 },
307 "node_modules/bare-https": {
308 "version": "2.1.3",
309 "resolved": "https://registry.npmjs.org/bare-https/-/bare-https-2.1.3.tgz",
310 "integrity": "sha512-0TI/mJXQGYXmG7UUyWEG+KCJusayIAQLywUjFAskDoKuxfqVnGF+M/mTMrEV8J64DaIdU+5x761FvgmT7M68tA==",
311 "dev": true,
312 "license": "Apache-2.0",
313 "dependencies": {
314 "bare-http1": "^4.4.0",
315 "bare-tcp": "^2.2.0",
316 "bare-tls": "^2.0.0"
317 }
318 },
319 "node_modules/bare-inspect": {
320 "version": "3.1.4",
321 "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz",
322 "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==",
323 "license": "Apache-2.0",
324 "dependencies": {
325 "bare-ansi-escapes": "^2.1.0",
326 "bare-type": "^1.0.0"
327 },
328 "engines": {
329 "bare": ">=1.18.0"
330 }
331 },
332 "node_modules/bare-inspector": {
333 "version": "4.0.3",
334 "resolved": "https://registry.npmjs.org/bare-inspector/-/bare-inspector-4.0.3.tgz",
335 "integrity": "sha512-yYk6+7tTVKJKHOja5+QBn989ETv8oVsmwoH0nCM+QYC6w5MC03GC4aQNK+lipAZSz1EaAj42WZ8Wewjpy0L9TA==",
336 "dev": true,
337 "license": "Apache-2.0",
338 "dependencies": {
339 "bare-events": "^2.1.0",
340 "bare-http1": "^4.0.0",
341 "bare-stream": "^2.0.0",
342 "bare-url": "^2.0.0",
343 "bare-ws": "^2.0.0"
344 },
345 "engines": {
346 "bare": ">=1.2.0"
347 },
348 "peerDependencies": {
349 "bare-tcp": "*"
350 },
351 "peerDependenciesMeta": {
352 "bare-tcp": {
353 "optional": true
354 }
355 }
356 },
357 "node_modules/bare-module": {
358 "version": "6.1.3",
359 "resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.1.3.tgz",
360 "integrity": "sha512-5XWsVHsvtWMH4tK4DQWgpNTV0t/sg3ZrAaQLIxrwjrS5+u8Q9vEgc/zQ4QaDPWDse/y/5h+d+YG1Q0JfSMt0zA==",
361 "dev": true,
362 "license": "Apache-2.0",
363 "dependencies": {
364 "bare-bundle": "^1.3.0",
365 "bare-module-lexer": "^1.0.0",
366 "bare-module-resolve": "^1.8.0",
367 "bare-path": "^3.0.0",
368 "bare-url": "^2.0.1"
369 },
370 "engines": {
371 "bare": ">=1.23.0"
372 },
373 "peerDependencies": {
374 "bare-buffer": "*"
375 },
376 "peerDependenciesMeta": {
377 "bare-buffer": {
378 "optional": true
379 }
380 }
381 },
382 "node_modules/bare-module-lexer": {
383 "version": "1.4.7",
384 "resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.4.7.tgz",
385 "integrity": "sha512-0klU4eMsjh/wcxi8FdHmNom2j2F4kmkXOhyJFL9qTaSFp2lE3m6BtbKgMHY8R5miqC9r8/IfA8wzXnC5Os14WA==",
386 "dev": true,
387 "license": "Apache-2.0",
388 "dependencies": {
389 "require-addon": "^1.0.2"
390 },
391 "peerDependencies": {
392 "bare-buffer": "*"
393 },
394 "peerDependenciesMeta": {
395 "bare-buffer": {
396 "optional": true
397 }
398 }
399 },
400 "node_modules/bare-module-resolve": {
401 "version": "1.12.1",
402 "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz",
403 "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==",
404 "license": "Apache-2.0",
405 "dependencies": {
406 "bare-semver": "^1.0.0"
407 },
408 "peerDependencies": {
409 "bare-url": "*"
410 },
411 "peerDependenciesMeta": {
412 "bare-url": {
413 "optional": true
414 }
415 }
416 },
417 "node_modules/bare-net": {
418 "version": "2.3.1",
419 "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.1.tgz",
420 "integrity": "sha512-MypSqDKpDU2Xt7FIfazn5yGvRnV09gFcIPHGWstW0gxuzA4tucTcwJSZeos97C4F89vtU5oGwXDN/HrGN6Y4Jw==",
421 "dev": true,
422 "license": "Apache-2.0",
423 "dependencies": {
424 "bare-events": "^2.2.2",
425 "bare-pipe": "^4.0.0",
426 "bare-stream": "^2.0.0",
427 "bare-tcp": "^2.0.0"
428 }
429 },
430 "node_modules/bare-os": {
431 "version": "3.8.7",
432 "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz",
433 "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==",
434 "devOptional": true,
435 "license": "Apache-2.0",
436 "engines": {
437 "bare": ">=1.14.0"
438 }
439 },
440 "node_modules/bare-path": {
441 "version": "3.0.0",
442 "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
443 "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
444 "devOptional": true,
445 "license": "Apache-2.0",
446 "dependencies": {
447 "bare-os": "^3.0.1"
448 }
449 },
450 "node_modules/bare-pipe": {
451 "version": "4.1.5",
452 "resolved": "https://registry.npmjs.org/bare-pipe/-/bare-pipe-4.1.5.tgz",
453 "integrity": "sha512-6OfxaG8JSkRh3Gc4hzHRsxNt+yu2PpN7lrv1V+T78GdknWQkVGwiEvu4m+1nbfk8cMVQ0TGxRvQ90XA4rhnTuw==",
454 "dev": true,
455 "license": "Apache-2.0",
456 "dependencies": {
457 "bare-events": "^2.0.0",
458 "bare-stream": "^2.0.0"
459 },
460 "engines": {
461 "bare": ">=1.16.0"
462 }
463 },
464 "node_modules/bare-process": {
465 "version": "4.4.1",
466 "resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.4.1.tgz",
467 "integrity": "sha512-JfcTtymq6akqM2bdyTsP4A4GYJceBzb91k/ECmFps75uFf6uO33SM1bOZsRAqyRXExabsQJLn5S41NBl8HTM4A==",
468 "dev": true,
469 "license": "Apache-2.0",
470 "dependencies": {
471 "bare-abort": "^2.0.13",
472 "bare-env": "^3.0.0",
473 "bare-events": "^2.3.1",
474 "bare-hrtime": "^2.0.0",
475 "bare-os": "^3.7.1",
476 "bare-signals": "^4.0.0",
477 "bare-stdio": "^1.0.1"
478 }
479 },
480 "node_modules/bare-semver": {
481 "version": "1.0.3",
482 "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.3.tgz",
483 "integrity": "sha512-HS/A30bi2+PiRJfU6R4+Kp+6KeLSCSByjYM2iiobOKzLAvtu1CT+S8xWfiU7wz0erknjkUoC+yXy108tzIuP5Q==",
484 "license": "Apache-2.0"
485 },
486 "node_modules/bare-signals": {
487 "version": "4.2.0",
488 "resolved": "https://registry.npmjs.org/bare-signals/-/bare-signals-4.2.0.tgz",
489 "integrity": "sha512-fNHMOdQIlYuTvMB3Oh9Apk99hLKn351+Ir8vz+khiPTcOqIyGG4uWWjdLTzxWdYGsA0eT+We3y0K74hjj2nq7A==",
490 "dev": true,
491 "license": "Apache-2.0",
492 "dependencies": {
493 "bare-events": "^2.5.3",
494 "bare-os": "^3.3.1"
495 },
496 "engines": {
497 "bare": ">=1.7.0"
498 }
499 },
500 "node_modules/bare-stdio": {
501 "version": "1.0.2",
502 "resolved": "https://registry.npmjs.org/bare-stdio/-/bare-stdio-1.0.2.tgz",
503 "integrity": "sha512-3WJDqtvVGP4f+j68kyEC05umOYNwKJ1xG+YAXL8yZ605WgNqiRhVaFq+mVIhBt2eKNp7pa5vCQdhOt1pNh79SA==",
504 "dev": true,
505 "license": "Apache-2.0",
506 "dependencies": {
507 "bare-fs": "^4.5.2",
508 "bare-pipe": "^4.1.5",
509 "bare-tty": "^5.0.3"
510 }
511 },
512 "node_modules/bare-stream": {
513 "version": "2.13.0",
514 "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz",
515 "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==",
516 "license": "Apache-2.0",
517 "dependencies": {
518 "streamx": "^2.25.0",
519 "teex": "^1.0.1"
520 },
521 "peerDependencies": {
522 "bare-abort-controller": "*",
523 "bare-buffer": "*",
524 "bare-events": "*"
525 },
526 "peerDependenciesMeta": {
527 "bare-abort-controller": {
528 "optional": true
529 },
530 "bare-buffer": {
531 "optional": true
532 },
533 "bare-events": {
534 "optional": true
535 }
536 }
537 },
538 "node_modules/bare-stylize": {
539 "version": "0.0.1",
540 "resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz",
541 "integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==",
542 "dev": true,
543 "license": "Apache-2.0",
544 "dependencies": {
545 "bare-ansi-escapes": "^2.2.3",
546 "bare-process": "^4.2.1"
547 }
548 },
549 "node_modules/bare-subprocess": {
550 "version": "5.2.3",
551 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-5.2.3.tgz",
552 "integrity": "sha512-07wwswlV7M3sC9IykbZRZ/jHAkrXFWVLqdBWGv1y0ojCimtRD9hGwxdHmR5FUFmDUZLNsBmTYJNQqgio5+A85Q==",
553 "dev": true,
554 "license": "Apache-2.0",
555 "dependencies": {
556 "bare-env": "^3.0.0",
557 "bare-events": "^2.5.4",
558 "bare-os": "^3.0.1",
559 "bare-pipe": "^4.0.0",
560 "bare-url": "^2.2.2"
561 },
562 "engines": {
563 "bare": ">=1.7.0"
564 },
565 "peerDependencies": {
566 "bare-buffer": "*"
567 },
568 "peerDependenciesMeta": {
569 "bare-buffer": {
570 "optional": true
571 }
572 }
573 },
574 "node_modules/bare-tcp": {
575 "version": "2.2.7",
576 "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.2.7.tgz",
577 "integrity": "sha512-rjpqNQ2cOCkNo3NeYA/W4GTK3DRkl8sDHO3uos+AEswUjLC8XXMQF8WrJCSjlIowCbS6NUVxKE92X5RGXjyefg==",
578 "dev": true,
579 "license": "Apache-2.0",
580 "dependencies": {
581 "bare-dns": "^2.0.4",
582 "bare-events": "^2.5.4",
583 "bare-stream": "^2.6.4"
584 },
585 "engines": {
586 "bare": ">=1.16.0"
587 }
588 },
589 "node_modules/bare-tls": {
590 "version": "2.2.3",
591 "resolved": "https://registry.npmjs.org/bare-tls/-/bare-tls-2.2.3.tgz",
592 "integrity": "sha512-dPYBGEXtgLceRFMfGaF2/rqR86/xMxMyrM9ootO/gaRKL/z2uNHJs7aP7IOBtnJF8eUCt5qwMzbKfrKgDIxPLg==",
593 "dev": true,
594 "license": "Apache-2.0",
595 "dependencies": {
596 "bare-net": "^2.0.1",
597 "bare-stream": "^2.6.4"
598 },
599 "engines": {
600 "bare": ">=1.7.0"
601 }
602 },
603 "node_modules/bare-tty": {
604 "version": "5.1.0",
605 "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.1.0.tgz",
606 "integrity": "sha512-EZLvW4A+XiJgI3TW+e1pMME9PIJsfEXe/DA/WSKzIkq/v7Yarpv/rvG6Z5pGnpo4V/Bd+qopwnCLSR71hMMBYA==",
607 "dev": true,
608 "license": "Apache-2.0",
609 "dependencies": {
610 "bare-events": "^2.2.0",
611 "bare-signals": "^4.0.0",
612 "bare-stream": "^2.0.0"
613 },
614 "engines": {
615 "bare": ">=1.16.0"
616 }
617 },
618 "node_modules/bare-type": {
619 "version": "1.1.0",
620 "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz",
621 "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==",
622 "license": "Apache-2.0",
623 "engines": {
624 "bare": ">=1.2.0"
625 }
626 },
627 "node_modules/bare-url": {
628 "version": "2.4.0",
629 "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
630 "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
631 "devOptional": true,
632 "license": "Apache-2.0",
633 "peer": true,
634 "dependencies": {
635 "bare-path": "^3.0.0"
636 }
637 },
638 "node_modules/bare-utils": {
639 "version": "1.6.0",
640 "resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz",
641 "integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==",
642 "dev": true,
643 "license": "Apache-2.0",
644 "dependencies": {
645 "bare-debug-log": "^2.0.0",
646 "bare-encoding": "^1.0.0",
647 "bare-format": "^1.0.0",
648 "bare-inspect": "^3.0.0",
649 "bare-stylize": "^0.0.1",
650 "bare-type": "^1.0.6"
651 }
652 },
653 "node_modules/bare-v8-to-istanbul": {
654 "version": "1.0.2",
655 "resolved": "https://registry.npmjs.org/bare-v8-to-istanbul/-/bare-v8-to-istanbul-1.0.2.tgz",
656 "integrity": "sha512-CM0wPgyQtXTQ7h4q8NjbTZ/FH5he0Jkiwgpq0lFZZA5tkVZzmQ/9ul4R7E+cNlGZJIZlQZ9v4CwbgVaFG3zOAA==",
657 "dev": true,
658 "license": "Apache-2.0",
659 "dependencies": {
660 "bare-assert": "^1.0.2",
661 "bare-fs": "^4.1.2",
662 "bare-module": "^6.1.2",
663 "bare-path": "^3.0.0",
664 "bare-process": "^4.2.0",
665 "bare-url": "^2.1.5",
666 "bare-utils": "^1.2.0",
667 "v8-to-istanbul": "^9.3.0",
668 "which-runtime": "^1.2.1"
669 }
670 },
671 "node_modules/bare-ws": {
672 "version": "2.1.0",
673 "resolved": "https://registry.npmjs.org/bare-ws/-/bare-ws-2.1.0.tgz",
674 "integrity": "sha512-2gEWlPK9iyBchACdIY6oQXgmDz3KLrChdwrPgmU3IVXOFTnxTqSUT27WE/+izd4QojHj/SsqDkQiD2HDvuTdAA==",
675 "dev": true,
676 "license": "Apache-2.0",
677 "dependencies": {
678 "bare-crypto": "^1.2.0",
679 "bare-events": "^2.3.1",
680 "bare-http1": "^4.0.0",
681 "bare-https": "^2.0.0",
682 "bare-stream": "^2.1.2"
683 },
684 "peerDependencies": {
685 "bare-buffer": "*",
686 "bare-url": "*"
687 },
688 "peerDependenciesMeta": {
689 "bare-buffer": {
690 "optional": true
691 },
692 "bare-url": {
693 "optional": true
694 }
695 }
696 },
697 "node_modules/brittle": {
698 "version": "3.19.1",
699 "resolved": "https://registry.npmjs.org/brittle/-/brittle-3.19.1.tgz",
700 "integrity": "sha512-4Ted1Mt9o9B6oIA6ImJJCtB/Fv++ZO3IDNQgRcHOXWSYGRUidgxchaGrUBaRn+4mlneXrgInPkCPzi0jO8qKbA==",
701 "dev": true,
702 "license": "Apache-2.0",
703 "dependencies": {
704 "b4a": "^1.6.0",
705 "bare-assert": "^1.0.2",
706 "bare-cov": "^1.1.0",
707 "bare-fs": "^4.1.6",
708 "bare-os": "^3.6.1",
709 "bare-path": "^3.0.0",
710 "bare-process": "^4.2.1",
711 "bare-subprocess": "^5.0.0",
712 "bare-url": "^2.1.6",
713 "error-stack-parser": "^2.1.4",
714 "globbie": "^1.0.2",
715 "paparam": "^1.6.2",
716 "same-object": "^1.0.2",
717 "test-tmp": "^1.4.0",
718 "tmatch": "^5.0.0"
719 },
720 "bin": {
721 "brittle": "bin/node.js",
722 "brittle-bare": "bin/bare.js",
723 "brittle-node": "bin/node.js",
724 "brittle-pear": "bin/pear.js"
725 }
726 },
727 "node_modules/compact-encoding": {
728 "version": "2.19.2",
729 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.2.tgz",
730 "integrity": "sha512-/YjhHQE/5L4F7l5Bht69dRbP9RV6zoJPeowi8bMKQxNKe3Nh6hOY8pBGoVE9fz5GaWfEd8fWJ2aU9sB4KZuMYg==",
731 "license": "Apache-2.0",
732 "dependencies": {
733 "b4a": "^1.3.0"
734 }
735 },
736 "node_modules/convert-source-map": {
737 "version": "2.0.0",
738 "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
739 "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
740 "dev": true,
741 "license": "MIT"
742 },
743 "node_modules/error-stack-parser": {
744 "version": "2.1.4",
745 "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
746 "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
747 "dev": true,
748 "license": "MIT",
749 "dependencies": {
750 "stackframe": "^1.3.4"
751 }
752 },
753 "node_modules/events-universal": {
754 "version": "1.0.1",
755 "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
756 "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
757 "license": "Apache-2.0",
758 "dependencies": {
759 "bare-events": "^2.7.0"
760 }
761 },
762 "node_modules/fast-fifo": {
763 "version": "1.3.2",
764 "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
765 "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
766 "license": "MIT"
767 },
768 "node_modules/globbie": {
769 "version": "1.0.3",
770 "resolved": "https://registry.npmjs.org/globbie/-/globbie-1.0.3.tgz",
771 "integrity": "sha512-hcryJmKcftf82xfBWTWxuUITWIIoYO3ec104V17SMHGFl6VLbm1d1Ju9LX9L+jyJTwICpemX/dmPI/HGYoKr1A==",
772 "dev": true,
773 "license": "Apache-2.0",
774 "dependencies": {
775 "bare-fs": "^4.1.2",
776 "bare-path": "^3.0.0",
777 "bare-process": "^4.2.1",
778 "picomatch": "^4.0.2"
779 }
780 },
781 "node_modules/hypercore-crypto": {
782 "version": "3.6.1",
783 "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.6.1.tgz",
784 "integrity": "sha512-ltIz2uDwy9pO/ZGTvqcjzyBkvt6O4cVm4r/nNxh0GFs/RbQtqP/i4wCvLEdmU7ptgtnw7fI67WYD1aHPuv4OVA==",
785 "license": "MIT",
786 "dependencies": {
787 "b4a": "^1.6.6",
788 "compact-encoding": "^2.15.0",
789 "sodium-universal": "^5.0.0"
790 }
791 },
792 "node_modules/paparam": {
793 "version": "1.10.1",
794 "resolved": "https://registry.npmjs.org/paparam/-/paparam-1.10.1.tgz",
795 "integrity": "sha512-viyQI64VIja0Za3njIzhoEP8ZVkgPowhZPuG0E96NwBfYJ6ZIyrrlhWGtFPkdN7eYLl2L8CTWL+wla0evm1KKQ==",
796 "dev": true,
797 "license": "Apache-2.0"
798 },
799 "node_modules/picomatch": {
800 "version": "4.0.4",
801 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
802 "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
803 "dev": true,
804 "license": "MIT",
805 "engines": {
806 "node": ">=12"
807 },
808 "funding": {
809 "url": "https://github.com/sponsors/jonschlinkert"
810 }
811 },
812 "node_modules/require-addon": {
813 "version": "1.2.0",
814 "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz",
815 "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==",
816 "license": "Apache-2.0",
817 "dependencies": {
818 "bare-addon-resolve": "^1.3.0"
819 },
820 "engines": {
821 "bare": ">=1.10.0"
822 }
823 },
824 "node_modules/same-object": {
825 "version": "1.0.2",
826 "resolved": "https://registry.npmjs.org/same-object/-/same-object-1.0.2.tgz",
827 "integrity": "sha512-csHWhvUsLbIOHDM/nP+KHWM+BLPsIzWkFa8HbzaI0G7BqKXgx+7FJpKTGgLXyz5amfdY2OVBcmXTqYOMEk04og==",
828 "dev": true,
829 "license": "MIT"
830 },
831 "node_modules/sodium-native": {
832 "version": "4.3.3",
833 "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
834 "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==",
835 "license": "MIT",
836 "dependencies": {
837 "require-addon": "^1.1.0"
838 }
839 },
840 "node_modules/sodium-universal": {
841 "version": "5.0.1",
842 "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz",
843 "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==",
844 "license": "MIT",
845 "dependencies": {
846 "sodium-native": "^5.0.1"
847 },
848 "peerDependencies": {
849 "sodium-javascript": "~0.8.0"
850 },
851 "peerDependenciesMeta": {
852 "sodium-javascript": {
853 "optional": true
854 }
855 }
856 },
857 "node_modules/sodium-universal/node_modules/sodium-native": {
858 "version": "5.1.0",
859 "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz",
860 "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==",
861 "license": "MIT",
862 "dependencies": {
863 "bare-assert": "^1.2.0",
864 "require-addon": "^1.1.0",
865 "which-runtime": "^1.2.1"
866 },
867 "engines": {
868 "bare": ">=1.16.0"
869 }
870 },
871 "node_modules/stackframe": {
872 "version": "1.3.4",
873 "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
874 "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
875 "dev": true,
876 "license": "MIT"
877 },
878 "node_modules/streamx": {
879 "version": "2.25.0",
880 "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
881 "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
882 "license": "MIT",
883 "dependencies": {
884 "events-universal": "^1.0.0",
885 "fast-fifo": "^1.3.2",
886 "text-decoder": "^1.1.0"
887 }
888 },
889 "node_modules/teex": {
890 "version": "1.0.1",
891 "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
892 "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
893 "license": "MIT",
894 "dependencies": {
895 "streamx": "^2.12.5"
896 }
897 },
898 "node_modules/test-tmp": {
899 "version": "1.4.0",
900 "resolved": "https://registry.npmjs.org/test-tmp/-/test-tmp-1.4.0.tgz",
901 "integrity": "sha512-GVggxGg+jXqP2Wbju50JVLo+9E+nIOPPyWqgr63EbOnNItIKu1cEbJpTWAJeflnyGqXOtcMI7ijHRp88GUkfDA==",
902 "dev": true,
903 "license": "MIT",
904 "dependencies": {
905 "bare-fs": "^4.0.1",
906 "bare-os": "^3.3.0",
907 "bare-path": "^3.0.0"
908 }
909 },
910 "node_modules/text-decoder": {
911 "version": "1.2.7",
912 "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
913 "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
914 "license": "Apache-2.0",
915 "dependencies": {
916 "b4a": "^1.6.4"
917 }
918 },
919 "node_modules/tmatch": {
920 "version": "5.0.0",
921 "resolved": "https://registry.npmjs.org/tmatch/-/tmatch-5.0.0.tgz",
922 "integrity": "sha512-Ib9OtBkpHn07tXP04SlN1SYRxFgTk6wSM2EBmjjxug4u5RXPRVLkdFJSS1PmrQidaSB8Lru9nRtViQBsbxzE5Q==",
923 "dev": true,
924 "license": "ISC",
925 "engines": {
926 "node": ">=8"
927 }
928 },
929 "node_modules/v8-to-istanbul": {
930 "version": "9.3.0",
931 "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
932 "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
933 "dev": true,
934 "license": "ISC",
935 "dependencies": {
936 "@jridgewell/trace-mapping": "^0.3.12",
937 "@types/istanbul-lib-coverage": "^2.0.1",
938 "convert-source-map": "^2.0.0"
939 },
940 "engines": {
941 "node": ">=10.12.0"
942 }
943 },
944 "node_modules/which-runtime": {
945 "version": "1.3.2",
946 "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz",
947 "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==",
948 "license": "Apache-2.0"
949 }
950 }
951}
package.json +18 -0
……@@ -0,0 +1,18 @@
1{
2 "name": "anproto",
3 "version": "0.1.0",
4 "description": "Wire-compatible implementation of evbogue's ANProto (Authenticated Non-networked protocol) — Ed25519 signed timestamp+hash messages",
5 "type": "module",
6 "main": "index.js",
7 "scripts": {
8 "test": "brittle test/*.test.js"
9 },
10 "dependencies": {
11 "b4a": "^1.6.0",
12 "hypercore-crypto": "^3.4.0",
13 "sodium-native": "^4.0.0"
14 },
15 "devDependencies": {
16 "brittle": "^3.0.0"
17 }
18}
test/anproto.test.js +150 -0
……@@ -0,0 +1,150 @@
1import test from 'brittle'
2import { an, Challenges } from '../index.js'
3
4// ── Round-trip tests (functionality) ─────────────────────────────────
5
6test('an.gen() produces a 132-char keypair string', async (t) => {
7 const k = await an.gen()
8 t.is(typeof k, 'string')
9 t.is(k.length, 132, 'keypair is 132 chars (44 pubkey + 88 secret, base64)')
10})
11
12test('an.hash() produces deterministic 44-char base64 SHA-256', async (t) => {
13 const h1 = await an.hash('Hello World')
14 const h2 = await an.hash('Hello World')
15 t.is(h1, h2, 'same input produces same hash')
16 t.is(h1.length, 44, 'SHA-256 in base64 is 44 chars')
17
18 // Wire compat: this is the canonical hash for "Hello World"
19 // (verified against evbogue's reference implementation)
20 t.is(h1, 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=', 'matches canonical anproto hash')
21})
22
23test('an.sign() / an.open() round-trip', async (t) => {
24 const k = await an.gen()
25 const h = await an.hash('hello world')
26 const sig = await an.sign(h, k)
27 const opened = await an.open(sig)
28
29 t.ok(opened.endsWith(h), 'opened message ends with the hash')
30 t.is(opened.length, 13 + h.length, '13-digit timestamp followed by hash')
31})
32
33test('an.sign() output starts with the public key', async (t) => {
34 const k = await an.gen()
35 const h = await an.hash('test')
36 const sig = await an.sign(h, k)
37 t.is(sig.substring(0, 44), an.publicKey(k), 'first 44 chars of sig are pubkey')
38})
39
40test('an.open() rejects tampered signatures', async (t) => {
41 const k = await an.gen()
42 const h = await an.hash('test')
43 const sig = await an.sign(h, k)
44 // Flip a byte in the signature body
45 const tampered = sig.substring(0, 50) + 'X' + sig.substring(51)
46 await t.exception(an.open(tampered), /Invalid ANProto signature/, 'tampered sig rejected')
47})
48
49test('an.open() rejects truncated messages', async (t) => {
50 await t.exception(an.open('too-short'), /too short|invalid/i)
51})
52
53test('an.parse() splits opened message into timestamp + hash', async (t) => {
54 const k = await an.gen()
55 const h = await an.hash('test data')
56 const sig = await an.sign(h, k)
57 const opened = await an.open(sig)
58 const parsed = an.parse(opened)
59
60 t.is(typeof parsed.timestamp, 'number')
61 t.ok(parsed.timestamp > 1000000000000, 'timestamp is a valid millisecond epoch')
62 t.is(parsed.hash, h, 'hash round-trips')
63})
64
65// ── Wire compatibility with evbogue's canonical implementation ───────
66//
67// Test vector generated using the canonical evbogue/ANProto with a fixed
68// keypair (derived from sha256("anproto-test-seed")) and a fixed message.
69// Ed25519 is deterministic, so the same key + same message ALWAYS produces
70// the same signature bytes — regardless of which library generates it.
71//
72// If our implementation produces different bytes, we are NOT wire-compatible.
73
74const VECTORS = {
75 // Keypair derived from sha256("anproto-test-seed") via canonical impl
76 pubkey: 'M8HUU6LqOD/vKavtsNJfPyY4Z5cyhVjzjViAD+FdeZo=',
77 keypair: 'M8HUU6LqOD/vKavtsNJfPyY4Z5cyhVjzjViAD+FdeZo=Co+J2nXPQgNfn+S988mYWaEW7i3QBNdsvnpJtWOoCD4zwdRTouo4P+8pq+2w0l8/JjhnlzKFWPONWIAP4V15mg==',
78 helloWorldHash: 'pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=',
79 // Wire-format signature of the literal message "TEST_MESSAGE_FOR_WIRE_COMPAT"
80 // produced by canonical evbogue ANProto with the keypair above.
81 fixedMessage: 'TEST_MESSAGE_FOR_WIRE_COMPAT',
82 fixedSignature: 'M8HUU6LqOD/vKavtsNJfPyY4Z5cyhVjzjViAD+FdeZo=Z2+ybxNj4K0JqIrOyV6cS829hH/qJhbtCd5wMgVD1FYnq//Z8WWnMbu1SXOvtAQGY4/DGwAado+WKjzt+ihOAlRFU1RfTUVTU0FHRV9GT1JfV0lSRV9DT01QQVQ='
83}
84
85test('wire compat: hash("Hello World") matches canonical anproto', async (t) => {
86 const h = await an.hash('Hello World')
87 t.is(h, VECTORS.helloWorldHash)
88})
89
90test('wire compat: open() decodes a signature produced by canonical anproto', async (t) => {
91 // The fixedSignature was produced by evbogue's TweetNaCl-based impl.
92 // Our open() must decode it byte-for-byte to be wire-compatible.
93 const opened = await an.open(VECTORS.fixedSignature)
94 t.is(opened, VECTORS.fixedMessage, 'our open() reads canonical signatures')
95})
96
97test('wire compat: pubkey extraction matches canonical', async (t) => {
98 // The first 44 chars of the keypair are the pubkey, in both impls.
99 t.is(an.publicKey(VECTORS.keypair), VECTORS.pubkey)
100})
101
102// ── Challenges (replay protection) ───────────────────────────────────
103
104test('Challenges: generate + consume single-use', (t) => {
105 const c = new Challenges({ ttl: 60000 })
106 const challenge = c.generate()
107
108 t.is(typeof challenge, 'string')
109 t.is(challenge.length, 44, 'random 32 bytes base64 = 44 chars')
110 t.ok(c.has(challenge), 'challenge is active')
111 t.is(c.size, 1)
112
113 t.ok(c.consume(challenge), 'first consume succeeds')
114 t.absent(c.consume(challenge), 'second consume fails (single-use)')
115 t.absent(c.has(challenge), 'consumed challenge no longer active')
116 t.is(c.size, 0)
117
118 c.destroy()
119})
120
121test('Challenges: unknown challenge rejected', (t) => {
122 const c = new Challenges()
123 t.absent(c.consume('not-a-real-challenge'), 'unknown challenge fails')
124 t.absent(c.has('not-a-real-challenge'))
125 c.destroy()
126})
127
128test('Challenges: expired challenges rejected', async (t) => {
129 const c = new Challenges({ ttl: 50, cleanupInterval: 10 })
130 const challenge = c.generate()
131 await new Promise(r => setTimeout(r, 80))
132 t.absent(c.consume(challenge), 'expired challenge fails')
133 c.destroy()
134})
135
136test('Challenges: many challenges, only consumed ones removed', (t) => {
137 const c = new Challenges()
138 const ch1 = c.generate()
139 const ch2 = c.generate()
140 const ch3 = c.generate()
141
142 t.is(c.size, 3)
143 c.consume(ch2)
144 t.is(c.size, 2)
145 t.ok(c.has(ch1))
146 t.absent(c.has(ch2))
147 t.ok(c.has(ch3))
148
149 c.destroy()
150})