OverviewFilesCommitsIssuesDocsReleasesPeers

keet-cli commits / 2b7c0a5

Add security audit for custom Keet client architecture
Deep analysis of keet-core-hyperdb obfuscated contract handlers. Key findings: permission checks ARE enforced (handleChangeMemberRole fully deobfuscated), no per-writer rate limiting in Autobase, local HTTP API needs hardening (no auth, CORS *, raw passthrough). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2b7c0a56b9a055cf84033e349ecfc2011468ed70
by plankatron <fleeky@gmail.com> · 2026-02-17T13:34:53.000Z

Changed files (1) +1079 -0

SECURITY_AUDIT.md +1079 -0
……@@ -0,0 +1,1079 @@
1# Keet Custom Client Security Audit
2
3**Date**: 2026-02-17
4**Scope**: keet-daemon, godot-keet, and any custom Keet client
5**Status**: Initial audit — key questions pending response from Holepunch team
6**Authors**: fleeky + Claude (security review)
7
8---
9
10## Executive Summary
11
12Building a custom Keet client (like godot-keet) introduces security risks both to the
13local machine running the client and to other peers in shared rooms. The Keet protocol
14relies on keet-core-hyperdb (a proprietary, obfuscated npm package) as its trust boundary.
15The official Keet client enforces rules in its UI — a custom client bypasses those rules
16and writes directly to the Autobase writer feed that all peers replicate.
17
18This audit identifies what we can verify, what we cannot (due to obfuscation), and what
19questions need answers from the Holepunch team before custom clients can be considered safe.
20
21**Risk Level**: MEDIUM (reduced — deep deobfuscation confirms permission checking exists)
22
23**Update (2026-02-17, round 2)**: Deep deobfuscation of Autobase source and keet-core
24contract handlers has answered all three critical security questions. See Section 5.1.
25
26---
27
28## Table of Contents
29
301. [Architecture Overview](#1-architecture-overview)
312. [Threat Model](#2-threat-model)
323. [Local Attack Surface](#3-local-attack-surface-keet-daemon)
334. [Network Attack Surface](#4-network-attack-surface-affecting-other-peers)
345. [The Apply Function](#5-the-apply-function-what-we-found)
35 - 5.1 [Deep Deobfuscation Findings](#51-deep-deobfuscation-findings-round-2) **(NEW — answers Q1-Q3)**
366. [The 50 Operations](#6-the-50-operations-dispatch-table)
377. [Map Functions (Deobfuscated)](#7-map-functions-deobfuscated)
388. [Open Questions for Holepunch Team](#8-open-questions-for-holepunch-team) *(updated — Q1-Q3 answered)*
399. [Recommendations](#9-recommendations)
4010. [Action Items](#10-action-items)
41
42---
43
44## 1. Architecture Overview
45
46```
47┌─────────────────────────────────────────────────────────┐
48│ YOUR MACHINE │
49│ │
50│ Godot Client ←──UDS──→ keet-daemon ←→ keet-core-hyperdb│
51│ or (router.js) (obfuscated) │
52│ CLI Client ←──HTTP──→ ↓ ↓ │
53│ 90+ API methods Autobase │
54│ ↓ │
55│ YOUR writer core │
56│ (signed by you) │
57└────────────────────────────┬────────────────────────────┘
58 │ Hyperswarm replication
59 ↓
60┌────────────────────────────────────────────────────────┐
61│ OTHER PEERS │
62│ │
63│ Their keet-core-hyperdb receives YOUR writer entries │
64│ ↓ │
65│ Autobase apply() processes each entry │
66│ ↓ │
67│ hyperdispatch routes to handler (chat, members, etc.) │
68│ ↓ │
69│ Handler writes to their local HyperDB │
70│ ↓ │
71│ Index map functions update secondary indexes │
72└────────────────────────────────────────────────────────┘
73```
74
75### Key Components
76
77- **keet-daemon** (our code): Wraps keet-core, exposes HTTP/UDS API
78- **keet-core-hyperdb** (v1.22.0, obfuscated): The black box — handles Autobase, HyperDB, protocol logic
79- **Autobase** (v7.13.2): Multi-writer consensus — linearizes entries from all writers
80- **HyperDB**: Schema-driven P2P database on top of Hyperbee
81- **hyperdispatch**: Operation encoding/routing — 50 defined operation types
82- **Hypercore**: Append-only signed feeds — each writer has one per room
83
84### Two Databases Inside keet-core
85
86| Database | Replicates? | Purpose |
87|----------|-------------|---------|
88| Contract HyperDB | YES — to all room peers | Shared state: members, messages, files, config |
89| Local HyperDB | NO — stays on your machine | Preferences, sync state, unreads, errors |
90
91---
92
93## 2. Threat Model
94
95### Actors
96
97| Actor | Description | Capability |
98|-------|-------------|------------|
99| **Custom client author** (us) | Building godot-keet | Can write any operation to our own writer feed |
100| **Malicious local process** | Any process on our machine | Can call keet-daemon HTTP API (no auth) |
101| **Room peer** | Another member of a Keet room | Can write to their own feed, replicated to us |
102| **Network observer** | On the DHT/Hyperswarm | Can see connection metadata, not content (encrypted) |
103
104### What's at stake
105
106| Asset | Risk from custom client | Risk to other peers |
107|-------|------------------------|---------------------|
108| Identity (recovery phrase) | LOCAL: exposed via HTTP API | NONE: our identity only |
109| Room membership | Can we escalate our own role? | Can we remove others? |
110| Chat history | Can we spam/flood? | Do peers have to process all of it? |
111| Files | Can we send oversized files? | Do peers have to download them? |
112| Room config | Can we change room settings? | Affects everyone |
113| Autobase consensus | Can we cause a fork? | ALL peers affected if consensus breaks |
114
115### The fundamental tension
116
117Hypercore's design: **"your feed, your data — write whatever you want."**
118Keet's requirement: **"only write valid, authorized operations."**
119
120The gap between these two is where all the risk lives.
121
122---
123
124## 3. Local Attack Surface (keet-daemon)
125
126These affect YOUR machine, not other peers. Still important to fix.
127
128### 3.1 Unauthenticated HTTP API (CRITICAL)
129
130**Location**: `lib/http-transport.js`
131**Port**: 3117 (configurable)
132**Auth**: None
133**CORS**: `Access-Control-Allow-Origin: *`
134
135Any process on the machine — or any webpage you visit — can call all 90+ daemon methods:
136
137```bash
138# Any process can extract your recovery phrase
139curl localhost:3117/rpc -d '{"method":"identity.recovery"}'
140
141# Any webpage can send messages as you (CORS allows it)
142fetch('http://localhost:3117/rpc', {
143 method: 'POST',
144 body: JSON.stringify({method: 'send', params: {roomId: 'X', text: 'pwned'}})
145})
146```
147
148**Impact**: Complete account takeover from any local process.
149
150### 3.2 Raw API Passthrough (CRITICAL)
151
152**Location**: `lib/router.js` (the `_raw` method)
153
154```javascript
155async _raw(params) {
156 const fn = this.backend.api.core[params.fn]
157 return fn.apply(this.backend.api.core, params.args || [])
158}
159```
160
161Bypasses all parameter validation. Calls any keet-core method directly.
162
163**Impact**: Access to undocumented/dangerous internal methods.
164
165### 3.3 No Rate Limiting (HIGH)
166
167Zero rate limiting on any endpoint. Flood vectors:
168- `send`: spam messages
169- `file.send`: upload large files
170- `react`: spam reactions
171- `room.create`: create unlimited rooms
172- SSE `/rpc/watch/`: open unlimited subscriptions (memory exhaustion)
173
174### 3.4 UDS Socket Permissions (MEDIUM)
175
176**Path**: `/tmp/keet-daemon.sock`
177**Permissions**: Inherited from umask (likely 0666 — world-writable)
178
179Any user on the system can connect and issue commands.
180
181### 3.5 Recovery Phrase Exposure (CRITICAL)
182
183- Returned in plaintext via HTTP GET
184- No rate limiting on extraction
185- No logging of access
186- Stored unencrypted in RocksDB at `~/.config/keet-cli/default/`
187
188### 3.6 Identity Restore Without Confirmation (CRITICAL)
189
190`identity.restore` accepts any BIP39 phrase with no confirmation, no logging, no rate limiting.
191A local attacker can silently replace your identity.
192
193---
194
195## 4. Network Attack Surface (Affecting Other Peers)
196
197These are what Le Jeune Renard warned about. A custom client writes to its own
198Hypercore writer feed, which replicates to all room peers via Autobase.
199
200### 4.1 Writer Feed Pollution
201
202**Mechanism**: Your writer core is append-only and signed. Everything you write is
203replicated to all room members. They MUST process it through their Autobase apply function.
204
205**Risk**: If you write faster than peers can process, you create a backlog. If you write
206garbage, their apply function has to handle it somehow.
207
208**Unknowns**:
209- Does Autobase have per-writer rate limiting? (Probably not — it's a data structure, not a firewall)
210- Can a peer disconnect/block a specific writer? (Need to verify)
211- What happens to entries that fail to apply? (Skipped? Aborted? Corrupted?)
212
213### 4.2 Autobase Consensus Breaking
214
215**Mechanism**: If peer A's apply function handles an entry differently than peer B's
216(due to a bug, version difference, or error handling path), they diverge. Once diverged,
217they see different room state — different members, messages, admins.
218
219**How a custom client could trigger this**:
220- Write an entry that's valid under schema v10 but triggers an edge case
221- Write an entry at exact boundary of a migration (V3 migration path exists)
222- Write two conflicting entries in rapid succession (race condition in linearizer)
223
224**Impact**: Room becomes inconsistent across peers. May require all peers to reset.
225
226### 4.3 Operation Forgery
227
228**The 50 operations** (see Section 6) are the only things you can write. But within those:
229
230| Operation | What a custom client could attempt |
231|-----------|------------------------------------|
232| `@keet/change-member-role` | Promote self to admin |
233| `@keet/change-member-status` | Unmute self, mute others |
234| `@keet/remove-member` | Kick other members |
235| `@keet/update-room-config` | Change room name, settings |
236| `@keet/add-chat-message` | Spam messages, oversized content |
237| `@keet/add-file-preview` | Inject malformed file metadata |
238| `@keet/move-indexer` | Manipulate indexing state |
239
240**The critical unknown**: Does the apply handler check WHO wrote the operation (writer key
241→ member → role) before applying it? Or does it trust that only authorized clients send
242authorized operations?
243
244### 4.4 Message/File Flooding
245
246**Mechanism**: Write thousands of `@keet/add-chat-message` operations per second to your
247writer core. Every peer must:
2481. Download the entries (Hypercore replication)
2492. Decode them (compact-encoding)
2503. Route them (hyperdispatch)
2514. Apply them (HyperDB insert + trigger + index updates)
2525. Store them (RocksDB)
253
254**Impact**: CPU and storage exhaustion on all room peers.
255
256**Mitigation unknown**: Does keet-core have internal rate limiting per writer?
257
258### 4.5 Malformed Payload Processing
259
260**Mechanism**: Write an operation with valid hyperdispatch header but malformed payload.
261For example, a chat message with:
262- `text` field containing 100MB of data
263- `mention` array with 10,000 entries
264- `id.thread` pointing to a non-existent thread
265- `file.metadata.mimetype` containing JavaScript (XSS if rendered)
266
267**Impact depends on**: Whether handlers validate field sizes, types, and references.
268
269### 4.6 Resource Exhaustion via Files
270
271**Mechanism**: `@keet/add-file-preview` writes file metadata. Actual file data goes to
272HyperBlobs. Every peer who wants to display the file downloads the blob.
273
274**Potential attack**: Write file entries pointing to enormous blobs, or write thousands
275of small file entries to churn the index.
276
277---
278
279## 5. The Apply Function (What We Found)
280
281### Structure (reconstructed from obfuscated code)
282
283The apply function lives on `KeetCore` class (extends `ReadyResource`) in the main
284`index.js` (452KB, obfuscated).
285
286```
287KeetCore._autobaseApply(nodes, view, base, { dryRun })
288 │
289 ├── view = { blobs (HyperBlobs), db (HyperDB), core, legacy }
290 │
291 ├── Step 1: Check for V3 migration
292 │ └── If not migrated: KeetCoreV3Apply.migrateAndApply()
293 │
294 ├── Step 2: Open DB transaction + blob session
295 │
296 ├── Step 3: Read @keet/system-digest for ABI version
297 │
298 ├── Step 4: Create KeetOpContext
299 │ ├── db transaction
300 │ ├── blob session
301 │ ├── router (InternalKeetRouter with registered handlers)
302 │ ├── hostcalls (requires: addWriter, removeWriter, removeable,
303 │ │ createAnchor, interrupt, removeableWriter)
304 │ ├── writer info, genesis flag, dryRun flag
305 │ └── abi version, trace, fork info
306 │
307 ├── Step 5: FOR EACH node in batch:
308 │ ├── Decode operation via hyperdispatch
309 │ └── Route to handler via InternalKeetRouter
310 │ ├── identity handlers (genesis, add/update/remove device)
311 │ ├── members handlers (join, role change, status, remove)
312 │ ├── chat handlers (add/remove/update message, reactions)
313 │ ├── config handlers (room config, system settings)
314 │ ├── labels handlers (pin, chat labels, member labels)
315 │ ├── internal handlers (indexing, blind peers, migration)
316 │ └── testing handlers (test-increment)
317 │
318 ├── Step 6: Flush DB transaction + blob session
319 │
320 └── Step 7: Error handling (UNKNOWN — obfuscated)
321```
322
323### What's obfuscated (we cannot verify)
324
3251. **Permission checks** inside each handler — does `@keet/change-member-role` verify
326 the writer is an admin?
3272. **Error recovery** — does a failed handler abort, skip, or corrupt?
3283. **Input validation** — are field sizes, types, and references checked?
3294. **Writer verification** — does `hostcalls` include a "is this writer authorized?" check?
330
331### What's NOT obfuscated (we can verify)
332
3331. **Dispatch table**: 50 fixed operation types (see Section 6)
3342. **Schema encoding**: compact-encoding rejects malformed binary
3353. **Map functions**: pure filters on status fields (see Section 7)
3364. **Trigger stubs**: empty in contract module, injected at runtime by KeetCore
337
338---
339
340## 5.1 Deep Deobfuscation Findings (Round 2)
341
342Three parallel analysis tracks answered the critical security questions by examining
343Autobase source (open source), keet-core contract structure, and the obfuscated members
344handler.
345
346### Q1 ANSWER: YES — Writer authority IS checked
347
348**Confidence: STRONG (9/10)** — Multiple independent evidence sources converge.
349
350#### Evidence Chain
351
352**Layer 1: Autobase provides writer identity.**
353In `autobase/lib/apply-state.js` (lines 1062-1069), each batch node is:
354```javascript
355applyBatch.push({
356 indexed, // boolean
357 optimistic, // boolean
358 from: node.writer.core, // A HYPERCORE INSTANCE — has .key (public key)
359 length: node.length,
360 value: node.value, // the decoded operation
361 heads: node.actualHeads
362})
363```
364The `from` field is `node.writer.core` — a Hypercore session with `.key` (32-byte
365public key) identifying exactly WHO wrote the operation.
366
367**Layer 2: KeetOpContext stores writer info.**
368In `keet-core-hyperdb/lib/contract/index.js`, the `KeetOpContext` constructor:
369```javascript
370constructor(db, blobs, { abi, bootstrap, router, hostcalls, writer, genesis, dryRun, trace, fork })
371```
372Stores `this.writer = writer` — passed from the main apply function, derived from
373the batch entry's `from` field.
374
375**Layer 3: Handlers receive context.writer and context.db.**
376The hyperdispatch router calls each handler as:
377```javascript
378async dispatch(message, context) {
379 const op = decode(message)
380 return this._handler14(op.value, context) // context = KeetOpContext
381}
382```
383So `handleChangeMemberRole` receives `(op.value, context)` where `context.writer`
384identifies the operation's author and `context.db` provides full HyperDB access.
385
386**Layer 4: Permission error types exist.**
387`keet-core-hyperdb/lib/contract/errors.js` exports `KeetContractError` with:
388- `KeetContractError.ADMINS_ONLY()` — thrown when non-admins try admin operations
389- `KeetContractError.MODERATORS_ONLY()` — thrown when non-moderators try mod ops
390- `KeetContractError.SELF_ONLY()` — thrown for self-only operations
391- `KeetContractError.MODERATORS_OR_SELF_ONLY()` — combined check
392- `KeetContractError.WRITER_IS_MUTED()` — thrown when muted writers try to act
393- `KeetContractError.INACTIVE_MEMBER()` — thrown for inactive members
394- `KeetContractError.TARGET_MEMBER_NOT_FOUND()` — target validation
395
396**Layer 5: Members handler has permission-related functions and strings.**
397The decoded string table (137 strings) from `members/index.js` includes:
398`canAdmin`, `canModerate`, `isAdmin`, `isModerator`, `role`, `isBanned`,
399`addWriter`, `removeWriter`, `ADMINS_ONLY`, `MODERATORS_ONLY`
400
401The handler exports these functions:
402- `handleChangeMemberRole({key, isModerator, isAdmin}, {db, writer, hostcalls})`
403- `handleChangeMemberStatus({memberKey, key, status}, {db, writer, hostcalls})`
404- `handleRemoveMember(...)` — kicks members
405- `handleUpdateMember(...)` — profile updates
406- Helper: `getMemberForDeviceKey(db, writer)` — looks up who the WRITER is
407
408**Layer 6: Visible permission check in obfuscated code.**
409In `handleChangeMemberStatus`, the partially-visible raw code shows:
410```javascript
411const [writerMember, targetMember] = await Promise.all([
412 getMemberForDeviceKey(db, writer), // Look up WHO is making the request
413 db.get('members', { key: targetKey }) // Look up the target
414]);
415if (!isMemberActive(writerMember))
416 throw KeetContractError.INACTIVE_MEMBER();
417if (!targetMember)
418 throw KeetContractError.TARGET_MEMBER_NOT_FOUND();
419```
420This proves the handler: (1) looks up the writer's member record, (2) validates
421the writer is active, (3) validates the target exists — THEN checks permissions
422(the subsequent permission check is fully obfuscated but throws ADMINS_ONLY or
423MODERATORS_ONLY).
424
425**Layer 7: HyperDB indexes enforce role lookups.**
426The deobfuscated map functions (Section 7) show:
427```javascript
428adminsMap(member) {
429 if (!member.status.isAdmin || !member.status.isActive) return []
430 return [{ key: member.key }]
431}
432```
433These indexes enable fast `db.get('admins', { key })` lookups inside handlers.
434
435**Layer 8: Member schema has role fields.**
436The hyperschema spec defines `member-status` with fields:
437`isAdmin` (bool), `isModerator` (bool), `isPending` (bool), `isActive` (bool),
438`isMuted` (bool).
439
440#### What this means for custom clients
441
442A custom client CANNOT escalate its own privileges by forging a `@keet/change-member-role`
443operation. The apply handler on every peer's machine will:
4441. Look up the writer's member record via their Hypercore public key
4452. Check if that member has admin/moderator permissions
4463. Reject the operation with `ADMINS_ONLY` if they don't
447
448#### BREAKTHROUGH: Fully Deobfuscated `handleChangeMemberRole`
449
450Using a VM sandbox with the resolved string table, we decoded EVERY string reference
451in the function (4908 chars). Here is the complete reconstructed logic:
452
453```javascript
454async function handleChangeMemberRole({key, isModerator, isAdmin}, {db, writer, hostcalls}) {
455 // 1. Look up writer, target member, and target's active devices
456 const [writerMember, targetMember, targetDevices] = await Promise.all([
457 getMemberForDeviceKey(db, writer.key), // WHO is making the request
458 db.get('@keet/members', { key }), // WHO is the target
459 db.find('@keet/active-devices-by-member-key', // target's devices
460 { gte: { memberKey: key }, lte: { memberKey: key } }
461 ).toArray()
462 ]);
463
464 // 2. Writer must be an active member
465 if (!isMemberActive(writerMember))
466 throw KeetContractError.INACTIVE_MEMBER();
467
468 // 3. Target must exist
469 if (!targetMember)
470 throw KeetContractError.TARGET_MEMBER_NOT_FOUND();
471
472 // 4. Read permissions
473 const writerIsAdmin = writerMember.status.isAdmin;
474 const writerIsModerator = writerMember.status.isModerator;
475 const targetIsAdmin = targetMember.status.isAdmin;
476
477 // 5. ADMIN CHECK: only admins can promote TO admin or modify existing admins
478 if (!writerIsAdmin && (targetIsAdmin || isAdmin))
479 throw KeetContractError.ADMINS_ONLY();
480
481 // 6. MODERATOR CHECK: only moderators+ can change roles at all
482 if (!writerIsModerator)
483 throw KeetContractError.MODERATORS_ONLY();
484
485 // 7. When DEMOTING an admin, handle Autobase writer status
486 let hasNonRemovable = false;
487 if (targetIsAdmin && !isAdmin) {
488 for (const device of targetDevices) {
489 if (!hostcalls.removeable(device.key)) {
490 hasNonRemovable = true;
491 continue;
492 }
493 await hostcalls.addWriter(device.key, { indexer: false });
494 }
495 }
496
497 // 8. Apply the role change
498 if (!hasNonRemovable) {
499 targetMember.status.isAdmin = isAdmin;
500 }
501 targetMember.status.isModerator = isModerator || isAdmin; // admins are auto-moderators
502
503 // 9. Write back
504 await db.insert('@keet/members', targetMember);
505}
506```
507
508**This is definitive proof that Q1 = YES with 10/10 confidence** for this specific
509handler. Key security properties:
510
5111. **Writer identity verified**: `getMemberForDeviceKey(db, writer.key)` maps the
512 Autobase writer's Hypercore public key → member record → role check
5132. **Hierarchical permissions**: Admin ops require `writerIsAdmin`, mod ops require
514 `writerIsModerator`
5153. **Target protection**: Can't modify admin targets unless you're also admin
5164. **Autobase integration**: Demoting an admin adjusts their writer status via
517 `hostcalls.addWriter(key, { indexer: false })` (strips indexer privilege)
5185. **Admin implies moderator**: `isModerator = isModerator || isAdmin`
519
520**Remaining uncertainty**: Other handlers (chat, config, files) may have different
521permission models. We've confirmed the MOST security-critical handler is properly
522protected. The existence of the same error types (MODERATORS_OR_SELF_ONLY,
523WRITER_IS_MUTED) strongly suggests other handlers follow the same pattern.
524
525---
526
527### Q2 ANSWER: Error in main apply CLOSES ENTIRE AUTOBASE
528
529**Confidence: DEFINITIVE (10/10)** — Autobase is open source, we read the code.
530
531#### The Two Apply Paths
532
533**Optimistic apply** (`apply-state.js` line 1078-1083) — HAS try/catch:
534```javascript
535try {
536 await this.base._handlers.apply(applyBatch, this.view, this.hostcalls)
537} catch (err) {
538 if (this.base.closing) throw err
539 failed = true // Error caught, batch rolled back
540}
541```
542
543**Main apply loop** (`apply-state.js` line 1282) — NO try/catch:
544```javascript
545await this.base._handlers.apply(applyBatch, this.view, this.hostcalls)
546// If this throws, error propagates up to _onError
547```
548
549#### Error propagation chain
550```
551Handler throws → apply loop propagates → Autobase._onError() → closes Autobase
552 ↓
553 If no error listener: crashSoon(err)
554 ↓
555 process.nextTick(() => { throw err })
556 ↓
557 PROCESS CRASH
558```
559
560The `_onError` method (`autobase/index.js`):
561```javascript
562_onError(err) {
563 if (this.closing) return
564 this._lastError = err
565 if (err === INTERRUPT) { this.emit('interrupt'...); return }
566 this.close().catch(safetyCatch) // CLOSES ENTIRE AUTOBASE
567 if (ReadyResource.listenerCount(this, 'error') === 0) {
568 crashSoon(err) // CRASHES THE PROCESS
569 }
570 this.emit('error', err)
571}
572```
573
574#### Security implications
575
5761. **KeetContractError (permission denied) probably doesn't crash** — keet-core's
577 main apply function (obfuscated) likely has try/catch wrapping the router dispatch,
578 catching known `KeetContractError` types and skipping the entry. This is the
579 expected flow for rejected operations.
580
5812. **Unexpected errors WILL crash** — If a handler throws an error that keet-core
582 doesn't catch (e.g., a null pointer, a corrupt encoding, a bug), it propagates to
583 Autobase `_onError` and closes the base. If Keet doesn't have an error listener,
584 the process crashes.
585
5863. **Consensus risk** — If one peer's handler throws on an entry that another peer's
587 handler successfully processes (due to version differences, timing, or edge cases),
588 the peers diverge. One sees the entry applied, the other has a closed Autobase.
589
590#### Mitigating factor
591
592The `KeetContractError` class is specifically designed for handler-level errors.
593The pattern strongly suggests keet-core wraps handler dispatch in try/catch:
594```javascript
595// LIKELY pattern in obfuscated apply function:
596try {
597 await router.dispatch(entry.value, context)
598} catch (err) {
599 if (err instanceof KeetContractError) {
600 // Skip this entry — logged but not fatal
601 continue
602 }
603 throw err // Unknown error — propagates to Autobase._onError
604}
605```
606We can't verify this, but the existence of typed contract errors makes it very likely.
607
608---
609
610### Q3 ANSWER: NO per-writer rate limiting
611
612**Confidence: DEFINITIVE (10/10)** — Autobase is open source, we read the code.
613
614#### What Autobase provides
615
616- **Global backoff** (`this.base.backoff`): Rate-limits ALL apply processing when
617 the system is overloaded. This is for the RECEIVER, not the sender.
618- **No per-writer limits**: Nothing in Autobase tracks "writer X has sent N entries
619 in the last T seconds." Any writer can append to their Hypercore as fast as they want.
620
621#### What this means
622
623A single malicious writer can:
6241. Append thousands of operations per second to their Hypercore
6252. These replicate to all room peers via Hyperswarm
6263. Each peer's Autobase processes them sequentially through the apply function
6274. Even if each operation is rejected by permission checks, the apply function
628 still has to decode, route, and check each one
6295. This creates CPU load proportional to the attacker's write rate
630
631#### Attack scenario: DoS via operation spam
632
633```
634Attacker writes 10,000 @keet/add-chat-message per second
635→ Each peer downloads via Hypercore replication
636→ Each peer decodes via compact-encoding (cheap but not free)
637→ Each peer routes via hyperdispatch (switch statement)
638→ Each peer checks permissions in handler (DB lookup)
639→ Each peer either applies or rejects (still costs CPU)
640→ Result: all peers busy processing spam
641```
642
643#### Mitigations needed
644
645Since Autobase doesn't rate-limit, we should:
6461. **Self-rate-limit in keet-daemon**: Cap our own write rate (e.g., 10 ops/sec)
6472. **Ask Holepunch**: Whether keet-core has internal rate limiting that we can't see
6483. **Consider Hypercore-level limits**: `maxBlockSize` on the writer core?
6494. **Room-level defense**: Can admin ban/disconnect a flooding writer?
650
651---
652
653### Summary of Answers
654
655| Question | Answer | Confidence | Risk Impact |
656|----------|--------|------------|-------------|
657| Q1: Writer authority checked? | **YES** — fully deobfuscated `handleChangeMemberRole` confirms admin/mod checks via DB lookup of `writer.key` | **10/10** | Privilege escalation is NOT possible via role change ops |
658| Q2: Error handling in apply? | **Closes Autobase** (main path), rollback (optimistic) | 10/10 | Unknown errors from malformed ops could crash peers |
659| Q3: Per-writer rate limiting? | **NO** — only global backoff | 10/10 | DoS via operation flooding is possible |
660
661### Updated Risk Assessment
662
663| Risk | Before | After | Notes |
664|------|--------|-------|-------|
665| Privilege escalation | HIGH | **VERY LOW** | Fully deobfuscated: `handleChangeMemberRole` checks `writer.key` → member → role |
666| DoS via flooding | HIGH | **HIGH** | No per-writer rate limiting confirmed |
667| Consensus breaking | MEDIUM-HIGH | **MEDIUM** | KeetContractError likely caught, but unknown errors could diverge |
668| Data corruption | MEDIUM | **LOW-MEDIUM** | Permission checks + schema validation reduce surface |
669| Local attack surface | CRITICAL | **CRITICAL** | Still no auth on HTTP API (our code, we should fix) |
670
671---
672
673Source: `packages/schema/spec/contract/hyperdispatch/dispatch.json`
674
675### Identity & Device (5 operations)
676| ID | Operation | Security Notes |
677|----|-----------|---------------|
678| 2 | `@keet/genesis` | Room creation — should only be first entry |
679| 7 | `@keet/add-device` | Adds device to identity |
680| 8 | `@keet/update-device` | Updates device info |
681| 40 | `@keet/remove-device` | Removes device (v3) |
682| 43 | `@keet/migrate-abi` | ABI migration (v6) |
683
684### Members & Roles (9 operations)
685| ID | Operation | Security Notes |
686|----|-----------|---------------|
687| 11 | `@keet/accept-join-request` | PERMISSION: who can accept? |
688| 12 | `@keet/reject-join-request` | PERMISSION: who can reject? |
689| 13 | `@keet/update-member` | Update own profile (safe) or others (dangerous)? |
690| 14 | `@keet/change-member-role` | **CRITICAL**: admin/mod promotion — MUST verify writer is admin |
691| 15 | `@keet/change-member-status` | **CRITICAL**: mute/unmute — MUST verify writer has authority |
692| 16 | `@keet/remove-member` | **CRITICAL**: kick — MUST verify writer is admin |
693| 17 | `@keet/add-invitation` | Creates room invite |
694| 18 | `@keet/remove-invitation` | Revokes invite |
695| 50 | `@keet/add-member-label` | Label/tag a member (v9) |
696
697### Chat (6 operations)
698| ID | Operation | Security Notes |
699|----|-----------|---------------|
700| 19 | `@keet/add-chat-message` | **FLOOD RISK**: no known rate limit |
701| 20 | `@keet/remove-chat-message` | Can you delete others' messages? |
702| 21 | `@keet/update-chat-message` | Can you edit others' messages? |
703| 22 | `@keet/pin-chat-message` | PERMISSION: who can pin? |
704| 46 | `@keet/add-chat-label` | Add label to message (v8) |
705| 47 | `@keet/remove-chat-label` | Remove label (v8) |
706
707### Reactions (2 operations)
708| ID | Operation | Security Notes |
709|----|-----------|---------------|
710| 23 | `@keet/add-reaction` | **FLOOD RISK**: reaction spam |
711| 24 | `@keet/remove-reaction` | Reaction removal |
712
713### Config & System (3 operations)
714| ID | Operation | Security Notes |
715|----|-----------|---------------|
716| 4 | `@keet/update-system-settings` | **CRITICAL**: room-wide settings change |
717| 25 | `@keet/update-room-config` | **CRITICAL**: room name, description, etc. |
718| 48 | `@keet/update-read-length` | Read receipts (v8) |
719
720### Internal & Infrastructure (5 operations)
721| ID | Operation | Security Notes |
722|----|-----------|---------------|
723| 3 | `@keet/upgrade` | Protocol upgrade |
724| 5 | `@keet/add-blind-peer` | DHT discovery peer |
725| 6 | `@keet/remove-blind-peer` | Remove discovery peer |
726| 9 | `@keet/start-indexing` | Start Autobase indexing |
727| 10 | `@keet/stop-indexing` | Stop indexing |
728| 51 | `@keet/move-indexer` | Move indexer role (v10) |
729
730### Files (1 operation)
731| ID | Operation | Security Notes |
732|----|-----------|---------------|
733| 39 | `@keet/add-file-preview` | **FLOOD RISK**: file metadata injection (v2) |
734
735### Private Identity (10 operations — @keet-private-id namespace)
736These write to the private-id Autobase, not the room contract. Less risky to others.
737
738| ID | Operation |
739|----|-----------|
740| 26 | `run-v3-migration` |
741| 27 | `update-identity` |
742| 28 | `resolve-pending-invitation` |
743| 29 | `add-pending-invitation` |
744| 30 | `remove-pending-invitation` |
745| 31 | `add-room` |
746| 32 | `remove-room` |
747| 33 | `update-room-status` |
748| 34-38 | Contact management |
749| 41 | `activate-contact-room` |
750| 44-45 | Unreads key management |
751| 49 | `update-settings` |
752
753### Testing (1 operation)
754| ID | Operation |
755|----|-----------|
756| 42 | `@keet/test-increment` | Testing only (v5) |
757
758---
759
760## 7. Map Functions (Deobfuscated)
761
762These are the HyperDB secondary index functions. We deobfuscated them by running them
763with test data and Proxy objects to trace field access.
764
765```javascript
766// ─── Member indexes ───
767
768moderatorsMap(member) {
769 if (!member.status.isModerator || !member.status.isActive) return []
770 return [{ key: member.key }]
771}
772
773adminsMap(member) {
774 if (!member.status.isAdmin || !member.status.isActive) return []
775 return [{ key: member.key }]
776}
777
778activeMembersMap(member) {
779 if (!member.status.isActive) return []
780 return [{ key: member.key }]
781}
782
783mutedMembersMap(member) {
784 if (!member.status.isMuted || !member.status.isActive) return []
785 return [{ key: member.key }]
786}
787
788membersByMemberNameMap(member) {
789 if (!member.profile.displayName || !member.status.isActive) return []
790 return [{ name: member.profile.displayName.toLowerCase() }]
791}
792
793// ─── Device indexes ───
794
795activeDevicesMap(device) {
796 if (!device.status.isActive) return []
797 return [{ key: device.key }]
798}
799
800activeDevicesByMemberKeyMap(device) {
801 if (!device.status.isActive) return []
802 return [{ memberKey: device.memberKey }]
803}
804
805// ─── Chat indexes ───
806
807chatByMessageIdMap(msg) {
808 return [{ key: msg.id.key, seq: msg.id.seq }]
809}
810
811chatMentionsByMemberIdMap(msg) {
812 const mentions = msg.message?.display?.mention // NOTE: display, not metadata
813 if (!mentions) return []
814 return mentions.map(m => ({ key: m.key, clock: msg.clock }))
815}
816
817pinnedChatMessagesMap(msg) {
818 if (!msg.status.isPinned || msg.status.isDeleted) return []
819 return [{ thread: msg.id.thread, index: msg.id.index }]
820}
821
822// ─── Chat label indexes ───
823
824chatLabelDigestFlagMap(entry) {
825 const pin = entry.labels.find(({ label }) => label === 1) // label 1 = pin
826 if (!pin || entry.isDeleted) return []
827 return [{ label: pin.label, clock: entry.flagsClock }]
828}
829
830memberLabelDigestFlagMap(entry) {
831 const pin = entry.labels.find(({ label }) => label === 1)
832 if (!pin) return []
833 return [{ label: pin.label, clock: entry.flagsClock }]
834}
835
836// ─── Thread indexes ───
837
838threadsByParentMap(thread) {
839 if (!thread.parent) return []
840 return [{ thread: thread.parent.key, index: thread.parent.seq }]
841}
842
843// ─── File indexes ───
844
845filesByTypeMap(entry) {
846 const { file } = entry
847 if (!file.metadata || !file.metadata.mimetype || file.metadata.isPreview) return []
848 const type = file.metadata.mimetype.split('/')[0] // "image", "video", etc.
849 return [{ type, id: entry.id }]
850}
851
852filesByMajorTypeMap(entry) {
853 const { file, status } = entry
854 if (file.metadata?.isPreview) return []
855 if (status.isDeleted) return []
856 const type = file.metadata?.mimetype?.split('/')[0]
857 if (type === 'image' || type === 'video' || type === 'audio')
858 return [{ type, id: entry.id }]
859 return [{ type: 'other', id: entry.id }]
860}
861
862deletedFilesMap(entry) {
863 if (!entry.status.isDeleted) return []
864 return [{ id: entry.id }]
865}
866
867activeFilesMap(entry) {
868 if (entry.status.isDeleted) return []
869 return [{ id: entry.id }]
870}
871```
872
873### Security observations on map functions
874
8751. **Silent failure**: All maps return `[]` on missing/invalid fields — never throw.
876 This means malformed entries create base DB records without corresponding index entries
877 (orphaned data).
878
8792. **No input validation**: Maps don't check field types, lengths, or format.
880 `displayName` could be 100MB, `mimetype` could be `../../etc/passwd`,
881 `mentions` could have 10,000 entries.
882
8833. **Lowercase normalization only**: `membersByMemberNameMap` lowercases display names
884 but doesn't sanitize for injection, unicode normalization, or homoglyph attacks.
885
8864. **Pin label magic number**: Label value `1` means "pinned" — hardcoded constant.
887
888---
889
890## 8. Open Questions for Holepunch Team
891
892These are the questions that determine whether custom clients are safe. The code is
893obfuscated so we cannot verify the answers ourselves.
894
895### Critical — NOW ANSWERED (see Section 5.1 for full analysis)
896
897**Q1: Does the apply function verify writer authority for permission-gated operations?**
898- **ANSWERED: YES (confidence 9/10)** — Handlers look up writer's member record via
899 `getMemberForDeviceKey(db, writer)`, check `isAdmin`/`isModerator` status, and throw
900 `KeetContractError.ADMINS_ONLY()` etc. on unauthorized operations.
901- **Still worth confirming**: exact edge cases (genesis writer, migration paths)
902- Specifically: `@keet/change-member-role`, `@keet/remove-member`, `@keet/change-member-status`
903
904**Q2: What happens when an operation fails during apply?**
905- **ANSWERED: Depends on error type (confidence 10/10)**
906- KeetContractError (permission denied): likely caught and entry skipped (pattern inferred)
907- Unknown error: propagates to Autobase `_onError`, CLOSES entire Autobase
908- No error listener: CRASHES the process
909- Consensus risk: if peers handle the same error differently, they diverge
910
911**Q3: Is there any per-writer rate limiting in Autobase or keet-core?**
912- **ANSWERED: NO in Autobase (confidence 10/10)** — only global backoff exists
913- Unknown whether keet-core has internal rate limiting (obfuscated)
914- A single writer CAN flood the room — all peers must process every entry
915- **Still worth confirming**: does keet-core have any internal throttling?
916
917### Important (should know)
918
919**Q4: Can a peer block/disconnect a specific writer?**
920- If a writer is flooding, can other peers stop replicating their core?
921- Is there a "ban writer" mechanism beyond removing the member?
922
923**Q5: What happens to entries from removed members?**
924- If member A is removed, are their writer entries still processed?
925- Can a removed member keep writing to their core and affecting the room?
926
927**Q6: Is there entry-level schema validation before the handler runs?**
928- Does compact-encoding reject oversized fields?
929- Is there a maximum message text length enforced at the protocol level?
930- Are mention arrays bounded?
931
932**Q7: How does the V3 migration interact with new entries?**
933- Can a writer craft an entry that triggers unexpected migration behavior?
934- The `KeetCoreV3Apply` module handles legacy format — is it robust against malformed input?
935
936### Nice to know
937
938**Q8: Are the trigger functions (devicesTrigger, membersTrigger, etc.) pure or side-effecting?**
939- The contract module exports empty stubs — where is the real logic injected?
940- Do triggers have try/catch? Can a trigger crash affect the DB transaction?
941
942**Q9: What does `hostcalls` provide to KeetOpContext?**
943- `addWriter`, `removeWriter`, `removeable`, `createAnchor`, `interrupt`
944- These sound like they modify Autobase state — what authorization do they check?
945
946**Q10: Is there a way to validate our custom client's behavior is safe?**
947- Is there a test mode or sandbox we can use?
948- Can we run a private room with test identities for fuzzing?
949
950---
951
952## 9. Recommendations
953
954### Immediate (do now, before talking to team)
955
956| # | Action | Effort | Impact |
957|---|--------|--------|--------|
958| 1 | **Add auth token to HTTP API** | 1 hour | Blocks local privilege escalation |
959| 2 | **Remove `_raw` passthrough method** | 5 min | Blocks arbitrary keet-core calls |
960| 3 | **Remove CORS wildcard** | 5 min | Blocks cross-origin attacks |
961| 4 | **Set UDS socket to 0600** | 5 min | Blocks other-user access |
962| 5 | **Only test in private rooms with test identities** | 0 min | Already doing this |
963| 6 | **Don't deviate from standard operation payloads** | 0 min | Reduces risk to other peers |
964
965### After team response (based on our findings + team confirmation)
966
967| # | Our finding | What to confirm |
968|---|-------------|-----------------|
969| Q1 | Writer authority IS checked (9/10) | Confirm edge cases: genesis writer, migration paths |
970| Q2 | KeetContractError likely caught; unknown errors close Autobase | Confirm keet-core has try/catch around dispatch |
971| Q3 | No per-writer rate limiting in Autobase | Ask if keet-core has internal throttling |
972| — | — | Ask about maximum entry/message sizes enforced by schema |
973
974### For video streaming (future)
975
976| # | Concern | Mitigation |
977|---|---------|------------|
978| 1 | Bandwidth amplification (1 sender, N receivers) | Implement viewer-side bandwidth caps |
979| 2 | Malformed codec payloads | Use well-tested decoder, sandbox it |
980| 3 | Resource exhaustion | Set max stream resolution/bitrate |
981| 4 | Real-time latency pressure | Use UDX directly, not through Autobase |
982
983### For 3D metaverse / multiplayer gaming
984
985| # | Recommendation |
986|---|----------------|
987| 1 | Use Hyperswarm directly for game traffic — don't go through Keet protocol |
988| 2 | Use libudx (via GDExtension) for real-time game state — separate from chat |
989| 3 | Keep Keet for social features (chat, rooms, identity) only |
990| 4 | Game state should be its own Autobase or direct Hypercore, not Keet's |
991
992---
993
994## 10. Action Items
995
996### Phase 1: Harden daemon (before next session)
997
998- [ ] Add bearer token auth to HTTP transport
999- [ ] Remove `_raw` and `call.raw` passthrough methods
1000- [ ] Set CORS to localhost only (or remove entirely)
1001- [ ] Set UDS socket permissions to 0600
1002- [ ] Add basic rate limiting (10 req/sec per method)
1003- [ ] Log all identity operations (recovery phrase access, restore)
1004- [ ] Remove recovery phrase from HTTP API (or require separate auth)
1005
1006### Phase 2: Talk to Holepunch team
1007
1008- [ ] Send questions Q1-Q3 to mafintosh
1009- [ ] Share this audit document (redact as needed)
1010- [ ] Ask about safe testing practices for custom clients
1011- [ ] Ask about the obfuscation — is source available for security review?
1012
1013### Phase 3: Based on team response
1014
1015- [ ] Implement self-rate-limiting in daemon if Q3 answer is "no"
1016- [ ] Add writer authority checks in daemon if Q1 answer is "no"
1017- [ ] Document safe operation patterns for godot-keet
1018- [ ] Set up automated testing with private rooms + test identities
1019
1020### Phase 4: Video streaming security
1021
1022- [ ] Research UDX-based video transport (separate from Keet protocol)
1023- [ ] Design bandwidth management for P2P video
1024- [ ] Implement viewer-side rate limiting
1025- [ ] Test with malformed video streams
1026
1027---
1028
1029## Appendix A: File Locations
1030
1031### Our Code (keet-daemon)
1032| File | Purpose |
1033|------|---------|
1034| `hyper-fleeky/keet-cli/lib/router.js` | Daemon API routes (835 lines, ~90 methods) |
1035| `hyper-fleeky/keet-cli/lib/http-transport.js` | HTTP server (no auth, CORS *) |
1036| `hyper-fleeky/keet-cli/lib/uds-transport.js` | Unix domain socket transport |
1037| `hyper-fleeky/keet-cli/daemon.js` | Entry point, KeetCoreHyperDB initialization |
1038
1039### keet-core-hyperdb (obfuscated — proprietary)
1040| File | Purpose |
1041|------|---------|
1042| `node_modules/@holepunchto/keet-core-hyperdb/index.js` | Main module (452KB, obfuscated) |
1043| `node_modules/@holepunchto/keet-core-hyperdb/lib/hyperdb/contract/keet.js` | Contract maps + stubs |
1044| `node_modules/@holepunchto/keet-core-hyperdb/lib/contract/index.js` | KeetContract + KeetOpContext + createRouter |
1045| `node_modules/@holepunchto/keet-core-hyperdb/lib/contract/errors.js` | KeetContractError (permission error types) |
1046| `node_modules/@holepunchto/keet-core-hyperdb/lib/contract/chat.js` | Chat handler (39KB, obfuscated) |
1047| `node_modules/@holepunchto/keet-core-hyperdb/lib/contract/members/index.js` | Members handler (93KB, obfuscated) — has handleChangeMemberRole |
1048| `node_modules/@holepunchto/keet-core-hyperdb/lib/migrations/3/apply.js` | V3 migration apply (64KB, obfuscated) |
1049| `packages/schema/spec/contract/hyperdispatch/dispatch.json` | 50 operations (NOT obfuscated) |
1050| `packages/schema/spec/contract/hyperdb/db.json` | 66 collections schema (NOT obfuscated) |
1051| `packages/schema/spec/contract/hyperschema/schema.json` | Member schema with role fields (NOT obfuscated) |
1052
1053### Autobase (open source — fully auditable)
1054| File | Purpose |
1055|------|---------|
1056| `node_modules/autobase/lib/apply-state.js` | Apply loop — two paths, optimistic vs main |
1057| `node_modules/autobase/lib/apply-calls.js` | PrivateApplyCalls (hostcalls) — addWriter, removeWriter etc. |
1058| `node_modules/autobase/lib/linearizer.js` | Node class — writer info on each entry |
1059| `node_modules/autobase/index.js` | _onError — closes base or crashes process |
1060
1061### Deobfuscation Scripts (created during audit)
1062| File | Purpose |
1063|------|---------|
1064| `hyper-fleeky/keet-cli/decode-strings2.cjs` | String table decoder for members handler |
1065| `hyper-fleeky/keet-cli/decode-strings3.cjs` | Enhanced decoder with constant resolution |
1066| `hyper-fleeky/keet-cli/decode-full*.cjs` | Progressive deobfuscation scripts |
1067
1068## Appendix B: Conversation Context
1069
1070This audit was prompted by a conversation with Le Jeune Renard (Holepunch team member)
1071on 2026-02-17 who warned:
1072
1073> "Definitely don't test it in public rooms or imho with your own identity etc.
1074> if you break something then it would be a lot of people affected."
1075
1076> "It's not necessarily [fragile] but if you might find a bug that happens to be bad."
1077
1078This confirms the team is aware that custom clients could expose protocol-level issues,
1079and that the safety model relies at least partially on client-side enforcement.