| … | … | @@ -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 | |
| 12 | Building a custom Keet client (like godot-keet) introduces security risks both to the |
| 13 | local machine running the client and to other peers in shared rooms. The Keet protocol |
| 14 | relies on keet-core-hyperdb (a proprietary, obfuscated npm package) as its trust boundary. |
| 15 | The official Keet client enforces rules in its UI — a custom client bypasses those rules |
| 16 | and writes directly to the Autobase writer feed that all peers replicate. |
| 17 | |
| 18 | This audit identifies what we can verify, what we cannot (due to obfuscation), and what |
| 19 | questions 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 |
| 24 | contract handlers has answered all three critical security questions. See Section 5.1. |
| 25 | |
| 26 | --- |
| 27 | |
| 28 | ## Table of Contents |
| 29 | |
| 30 | 1. [Architecture Overview](#1-architecture-overview) |
| 31 | 2. [Threat Model](#2-threat-model) |
| 32 | 3. [Local Attack Surface](#3-local-attack-surface-keet-daemon) |
| 33 | 4. [Network Attack Surface](#4-network-attack-surface-affecting-other-peers) |
| 34 | 5. [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)** |
| 36 | 6. [The 50 Operations](#6-the-50-operations-dispatch-table) |
| 37 | 7. [Map Functions (Deobfuscated)](#7-map-functions-deobfuscated) |
| 38 | 8. [Open Questions for Holepunch Team](#8-open-questions-for-holepunch-team) *(updated — Q1-Q3 answered)* |
| 39 | 9. [Recommendations](#9-recommendations) |
| 40 | 10. [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 | |
| 117 | Hypercore's design: **"your feed, your data — write whatever you want."** |
| 118 | Keet's requirement: **"only write valid, authorized operations."** |
| 119 | |
| 120 | The gap between these two is where all the risk lives. |
| 121 | |
| 122 | --- |
| 123 | |
| 124 | ## 3. Local Attack Surface (keet-daemon) |
| 125 | |
| 126 | These 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 | |
| 135 | Any 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 |
| 139 | curl localhost:3117/rpc -d '{"method":"identity.recovery"}' |
| 140 | |
| 141 | # Any webpage can send messages as you (CORS allows it) |
| 142 | fetch('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 |
| 155 | async _raw(params) { |
| 156 | const fn = this.backend.api.core[params.fn] |
| 157 | return fn.apply(this.backend.api.core, params.args || []) |
| 158 | } |
| 159 | ``` |
| 160 | |
| 161 | Bypasses 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 | |
| 167 | Zero 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 | |
| 179 | Any 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. |
| 191 | A local attacker can silently replace your identity. |
| 192 | |
| 193 | --- |
| 194 | |
| 195 | ## 4. Network Attack Surface (Affecting Other Peers) |
| 196 | |
| 197 | These are what Le Jeune Renard warned about. A custom client writes to its own |
| 198 | Hypercore 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 |
| 203 | replicated 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 |
| 206 | garbage, 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, |
| 217 | they 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 |
| 242 | authorized operations? |
| 243 | |
| 244 | ### 4.4 Message/File Flooding |
| 245 | |
| 246 | **Mechanism**: Write thousands of `@keet/add-chat-message` operations per second to your |
| 247 | writer core. Every peer must: |
| 248 | 1. Download the entries (Hypercore replication) |
| 249 | 2. Decode them (compact-encoding) |
| 250 | 3. Route them (hyperdispatch) |
| 251 | 4. Apply them (HyperDB insert + trigger + index updates) |
| 252 | 5. 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. |
| 261 | For 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 |
| 272 | HyperBlobs. 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 |
| 275 | of 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 | |
| 283 | The apply function lives on `KeetCore` class (extends `ReadyResource`) in the main |
| 284 | `index.js` (452KB, obfuscated). |
| 285 | |
| 286 | ``` |
| 287 | KeetCore._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 | |
| 325 | 1. **Permission checks** inside each handler — does `@keet/change-member-role` verify |
| 326 | the writer is an admin? |
| 327 | 2. **Error recovery** — does a failed handler abort, skip, or corrupt? |
| 328 | 3. **Input validation** — are field sizes, types, and references checked? |
| 329 | 4. **Writer verification** — does `hostcalls` include a "is this writer authorized?" check? |
| 330 | |
| 331 | ### What's NOT obfuscated (we can verify) |
| 332 | |
| 333 | 1. **Dispatch table**: 50 fixed operation types (see Section 6) |
| 334 | 2. **Schema encoding**: compact-encoding rejects malformed binary |
| 335 | 3. **Map functions**: pure filters on status fields (see Section 7) |
| 336 | 4. **Trigger stubs**: empty in contract module, injected at runtime by KeetCore |
| 337 | |
| 338 | --- |
| 339 | |
| 340 | ## 5.1 Deep Deobfuscation Findings (Round 2) |
| 341 | |
| 342 | Three parallel analysis tracks answered the critical security questions by examining |
| 343 | Autobase source (open source), keet-core contract structure, and the obfuscated members |
| 344 | handler. |
| 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.** |
| 353 | In `autobase/lib/apply-state.js` (lines 1062-1069), each batch node is: |
| 354 | ```javascript |
| 355 | applyBatch.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 | ``` |
| 364 | The `from` field is `node.writer.core` — a Hypercore session with `.key` (32-byte |
| 365 | public key) identifying exactly WHO wrote the operation. |
| 366 | |
| 367 | **Layer 2: KeetOpContext stores writer info.** |
| 368 | In `keet-core-hyperdb/lib/contract/index.js`, the `KeetOpContext` constructor: |
| 369 | ```javascript |
| 370 | constructor(db, blobs, { abi, bootstrap, router, hostcalls, writer, genesis, dryRun, trace, fork }) |
| 371 | ``` |
| 372 | Stores `this.writer = writer` — passed from the main apply function, derived from |
| 373 | the batch entry's `from` field. |
| 374 | |
| 375 | **Layer 3: Handlers receive context.writer and context.db.** |
| 376 | The hyperdispatch router calls each handler as: |
| 377 | ```javascript |
| 378 | async dispatch(message, context) { |
| 379 | const op = decode(message) |
| 380 | return this._handler14(op.value, context) // context = KeetOpContext |
| 381 | } |
| 382 | ``` |
| 383 | So `handleChangeMemberRole` receives `(op.value, context)` where `context.writer` |
| 384 | identifies 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.** |
| 397 | The 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 | |
| 401 | The 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.** |
| 409 | In `handleChangeMemberStatus`, the partially-visible raw code shows: |
| 410 | ```javascript |
| 411 | const [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 | ]); |
| 415 | if (!isMemberActive(writerMember)) |
| 416 | throw KeetContractError.INACTIVE_MEMBER(); |
| 417 | if (!targetMember) |
| 418 | throw KeetContractError.TARGET_MEMBER_NOT_FOUND(); |
| 419 | ``` |
| 420 | This proves the handler: (1) looks up the writer's member record, (2) validates |
| 421 | the writer is active, (3) validates the target exists — THEN checks permissions |
| 422 | (the subsequent permission check is fully obfuscated but throws ADMINS_ONLY or |
| 423 | MODERATORS_ONLY). |
| 424 | |
| 425 | **Layer 7: HyperDB indexes enforce role lookups.** |
| 426 | The deobfuscated map functions (Section 7) show: |
| 427 | ```javascript |
| 428 | adminsMap(member) { |
| 429 | if (!member.status.isAdmin || !member.status.isActive) return [] |
| 430 | return [{ key: member.key }] |
| 431 | } |
| 432 | ``` |
| 433 | These indexes enable fast `db.get('admins', { key })` lookups inside handlers. |
| 434 | |
| 435 | **Layer 8: Member schema has role fields.** |
| 436 | The 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 | |
| 442 | A custom client CANNOT escalate its own privileges by forging a `@keet/change-member-role` |
| 443 | operation. The apply handler on every peer's machine will: |
| 444 | 1. Look up the writer's member record via their Hypercore public key |
| 445 | 2. Check if that member has admin/moderator permissions |
| 446 | 3. Reject the operation with `ADMINS_ONLY` if they don't |
| 447 | |
| 448 | #### BREAKTHROUGH: Fully Deobfuscated `handleChangeMemberRole` |
| 449 | |
| 450 | Using a VM sandbox with the resolved string table, we decoded EVERY string reference |
| 451 | in the function (4908 chars). Here is the complete reconstructed logic: |
| 452 | |
| 453 | ```javascript |
| 454 | async 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 |
| 509 | handler. Key security properties: |
| 510 | |
| 511 | 1. **Writer identity verified**: `getMemberForDeviceKey(db, writer.key)` maps the |
| 512 | Autobase writer's Hypercore public key → member record → role check |
| 513 | 2. **Hierarchical permissions**: Admin ops require `writerIsAdmin`, mod ops require |
| 514 | `writerIsModerator` |
| 515 | 3. **Target protection**: Can't modify admin targets unless you're also admin |
| 516 | 4. **Autobase integration**: Demoting an admin adjusts their writer status via |
| 517 | `hostcalls.addWriter(key, { indexer: false })` (strips indexer privilege) |
| 518 | 5. **Admin implies moderator**: `isModerator = isModerator || isAdmin` |
| 519 | |
| 520 | **Remaining uncertainty**: Other handlers (chat, config, files) may have different |
| 521 | permission models. We've confirmed the MOST security-critical handler is properly |
| 522 | protected. The existence of the same error types (MODERATORS_OR_SELF_ONLY, |
| 523 | WRITER_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 |
| 535 | try { |
| 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 |
| 545 | await 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 | ``` |
| 551 | Handler 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 | |
| 560 | The `_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 | |
| 576 | 1. **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 | |
| 581 | 2. **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 | |
| 586 | 3. **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 | |
| 592 | The `KeetContractError` class is specifically designed for handler-level errors. |
| 593 | The pattern strongly suggests keet-core wraps handler dispatch in try/catch: |
| 594 | ```javascript |
| 595 | // LIKELY pattern in obfuscated apply function: |
| 596 | try { |
| 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 | ``` |
| 606 | We 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 | |
| 623 | A single malicious writer can: |
| 624 | 1. Append thousands of operations per second to their Hypercore |
| 625 | 2. These replicate to all room peers via Hyperswarm |
| 626 | 3. Each peer's Autobase processes them sequentially through the apply function |
| 627 | 4. Even if each operation is rejected by permission checks, the apply function |
| 628 | still has to decode, route, and check each one |
| 629 | 5. This creates CPU load proportional to the attacker's write rate |
| 630 | |
| 631 | #### Attack scenario: DoS via operation spam |
| 632 | |
| 633 | ``` |
| 634 | Attacker 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 | |
| 645 | Since Autobase doesn't rate-limit, we should: |
| 646 | 1. **Self-rate-limit in keet-daemon**: Cap our own write rate (e.g., 10 ops/sec) |
| 647 | 2. **Ask Holepunch**: Whether keet-core has internal rate limiting that we can't see |
| 648 | 3. **Consider Hypercore-level limits**: `maxBlockSize` on the writer core? |
| 649 | 4. **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 | |
| 673 | Source: `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) |
| 736 | These 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 | |
| 762 | These are the HyperDB secondary index functions. We deobfuscated them by running them |
| 763 | with test data and Proxy objects to trace field access. |
| 764 | |
| 765 | ```javascript |
| 766 | // ─── Member indexes ─── |
| 767 | |
| 768 | moderatorsMap(member) { |
| 769 | if (!member.status.isModerator || !member.status.isActive) return [] |
| 770 | return [{ key: member.key }] |
| 771 | } |
| 772 | |
| 773 | adminsMap(member) { |
| 774 | if (!member.status.isAdmin || !member.status.isActive) return [] |
| 775 | return [{ key: member.key }] |
| 776 | } |
| 777 | |
| 778 | activeMembersMap(member) { |
| 779 | if (!member.status.isActive) return [] |
| 780 | return [{ key: member.key }] |
| 781 | } |
| 782 | |
| 783 | mutedMembersMap(member) { |
| 784 | if (!member.status.isMuted || !member.status.isActive) return [] |
| 785 | return [{ key: member.key }] |
| 786 | } |
| 787 | |
| 788 | membersByMemberNameMap(member) { |
| 789 | if (!member.profile.displayName || !member.status.isActive) return [] |
| 790 | return [{ name: member.profile.displayName.toLowerCase() }] |
| 791 | } |
| 792 | |
| 793 | // ─── Device indexes ─── |
| 794 | |
| 795 | activeDevicesMap(device) { |
| 796 | if (!device.status.isActive) return [] |
| 797 | return [{ key: device.key }] |
| 798 | } |
| 799 | |
| 800 | activeDevicesByMemberKeyMap(device) { |
| 801 | if (!device.status.isActive) return [] |
| 802 | return [{ memberKey: device.memberKey }] |
| 803 | } |
| 804 | |
| 805 | // ─── Chat indexes ─── |
| 806 | |
| 807 | chatByMessageIdMap(msg) { |
| 808 | return [{ key: msg.id.key, seq: msg.id.seq }] |
| 809 | } |
| 810 | |
| 811 | chatMentionsByMemberIdMap(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 | |
| 817 | pinnedChatMessagesMap(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 | |
| 824 | chatLabelDigestFlagMap(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 | |
| 830 | memberLabelDigestFlagMap(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 | |
| 838 | threadsByParentMap(thread) { |
| 839 | if (!thread.parent) return [] |
| 840 | return [{ thread: thread.parent.key, index: thread.parent.seq }] |
| 841 | } |
| 842 | |
| 843 | // ─── File indexes ─── |
| 844 | |
| 845 | filesByTypeMap(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 | |
| 852 | filesByMajorTypeMap(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 | |
| 862 | deletedFilesMap(entry) { |
| 863 | if (!entry.status.isDeleted) return [] |
| 864 | return [{ id: entry.id }] |
| 865 | } |
| 866 | |
| 867 | activeFilesMap(entry) { |
| 868 | if (entry.status.isDeleted) return [] |
| 869 | return [{ id: entry.id }] |
| 870 | } |
| 871 | ``` |
| 872 | |
| 873 | ### Security observations on map functions |
| 874 | |
| 875 | 1. **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 | |
| 879 | 2. **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 | |
| 883 | 3. **Lowercase normalization only**: `membersByMemberNameMap` lowercases display names |
| 884 | but doesn't sanitize for injection, unicode normalization, or homoglyph attacks. |
| 885 | |
| 886 | 4. **Pin label magic number**: Label value `1` means "pinned" — hardcoded constant. |
| 887 | |
| 888 | --- |
| 889 | |
| 890 | ## 8. Open Questions for Holepunch Team |
| 891 | |
| 892 | These are the questions that determine whether custom clients are safe. The code is |
| 893 | obfuscated 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 | |
| 1070 | This audit was prompted by a conversation with Le Jeune Renard (Holepunch team member) |
| 1071 | on 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 | |
| 1078 | This confirms the team is aware that custom clients could expose protocol-level issues, |
| 1079 | and that the safety model relies at least partially on client-side enforcement. |