Branch review remediation plan โ
Branch: claude/session-2026-06-14 (vs dev) Date: 2026-06-16 Source: multi-agent /code-review ultra (14 finder angles, adversarial verify, gap sweep). 56 findings survived verification; consolidated and re-grounded against the live code here. A few reviewer claims were corrected on close reading (see "Corrections to the review").
How to use this doc โ
Work items are ordered by priority (P0 first) and grouped into cohesive PRs. Each item is independently shippable. Check the box when landed on the branch; fold the matching SECURITY_REMEDIATION.md tracker edit into the SAME PR (per repo rule). Open PRs as drafts to dev. A handful of items carry a DECISION marker: pick the recommended option or override it before implementing.
Severity legend: P0 = security correctness, ship first. P1 = wrong behavior on a common path. P2 = hardening + cleanup.
Decisions taken (2026-06-16) โ
- PR structure: everything lands on the current branch
claude/session-2026-06-14as ONE PR (operator preference), not the 4 PRs the first draft suggested. The "PR A/B/C/D" labels below are now just batch ordering. - WI-3 (block behavior): blocking CLOSES the connection. The send path should refuse to encrypt to a blocked peer; no re-blacklist-on-send needed.
- WI-5 (taxonomy): chose option (b). The client still emits
lantern_scheduledon form submit, so reusing that name server-side would double-count. Registered the two new server names the routes already use instead.
Progress (2026-06-16) โ
Landed on-branch and test-green (not yet committed):
- [x] WI-4 wave_sent entityType
lantern_pintowave(lanterns.js:128). - [x] WI-5 registered
lantern_schedule_created+lantern_schedule_cancelledin the taxonomy (name list + EVENT_REGISTRY), option (b). - [x] WI-6
devEchoEnablednow reads GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT / GCP_PROJECT;trackEvent.test.jsupdated to mirror the real deploy var + parity and default-env cases. forge.getEnvironment broad fix left as a flagged follow-up. - [x] WI-6b new
analyticsContracts.test.jsasserts every lanterns-api server event passes forge validation (prevents the whole silent-drop class). - [x] WI-1 device-trust now fingerprints on the per-device ed25519 (baseline + verified records are device->key maps, v3/v2). A same-id key swap, ghost device, or removal flips trust to 'changed';
getConnectionSafetyInfoalso cross-checks the live vs stored safety number. Files: deviceTrust.js, firestoreTransport.js, roomCrypto.js, Chat.jsx. - [x] WI-1b rules comment corrected to admit the delete+recreate gap; added a rules test documenting it is allowed by rules and caught client-side.
- [x] WI-2
/to-devicenow returns{ failures, retryable }(via pureplanToDeviceWrites); the client throws on a retryable drop so the OlmMachine re-shares (fail-closed) instead of acking a lost room key. - [x] WI-3
sendMessagerefuses to encrypt/send to a blocked peer (blocking closes the connection), so a blocked peer's devices never get a fresh room key. - [x] WI-7 plaintext-downgrade closed at the root: the
userKeysPARENT doc is now server-only to delete (firestore.rules), so a participant can't delete their directory to flipconnectionIsE2eeCapableand re-permit plaintext. Chose the delete-lockdown over a connection latch (simpler, monotonic, no cooperative write). Added a deny test; comment corrected. - [x] WI-8 venue refresh lock is token-fenced:
acquireRefreshLockmints alockToken;releaseRefreshLock+ the primary-cell branch ofupdateRefreshMetadatacompare-and-clear inside a transaction and only clearinProgresswhen the token is still ours, so a stolen-and-overran holder can't clobber the new one. Token threaded through import.js; catch-path lat/lng re-check simplified (review #44). Lock tests rewritten. - [x] WI-9 durable, cross-instance rate limiter: new
checkKeyDurable(Firestore sliding window, one transaction per call) backs the M-E2EE claim-otk + to-device caps; IP/user soft limits stay in-memory. Needs a Firestore TTL policy on_rateLimits.expireAt(IaC follow-up) for storage hygiene. - [x] WI-10 (part 1) per-device flood accounting: the durable limiter takes a
cost; to-device charges one slot per target device, so a 20-device payload spends 20 of the per-recipient budget (doc-granular bound, not request-granular). - [x] WI-11 removed the unreachable trackEvent ':dropped' branch (forge.track resolves tracked:true or throws); parallelized the to-device mailbox writes; fixed the deploy-dev scheduler to pass
?limit=100(matches its "100/run" comment; route caps at 100); dropped the unused getPeerTrustState barrel export. - Tests green per workspace (full suites): apps/web 1137, packages/shared 156, lanterns 69, auth 83, venues 88; firestore.rules 118.
DEFERRED (documented, not silently dropped):
- WI-10 (part 2) mailbox relationship check: โ
DONE (2026-07-23), see
SECURITY_REMEDIATION.mdM-E2EE-6. The feared wave-accept timing edge does not exist: acceptWave only writes the connection doc (no encryption at accept), and the sole key-share origin is message-send, which requires a settled connectionId. Gate is status-blind doc existence, so archived-chat re-keys still deliver. - WI-11 low-value items left for later: claim-otk / fulfillKeysQuery sequential-IO parallelization (touches crypto paths, low payoff), areaPrefix constant dedup into @lantern/shared/venues, setup-analytics-alerts.sh shared helper, and the grandfathered em-dash lines (the write-time hook blocks new ones; the ratchet burns down old ones).
- forge.getEnvironment reads GCLOUD_PROJECT/GCP_PROJECT but Cloud Run sets GOOGLE_CLOUD_PROJECT, so forge may mis-tag environment on every service. Broad blast radius; flagged in WI-6, left as a standalone audit.
Validation (2026-06-16) โ
npm run validate -> 25 passed, 0 failed. npm run test:rules -> 118 passed.
Three PRE-EXISTING branch lint failures (not introduced by this work) were unblocked in passing:
- CTA baseline: the branch's E2EE verify UI added one new low-emphasis rose-outline "Remove verification" button;
cta-classes-baseline.jsonChat.jsx bumped 3->4 (the canonical DANGER_TINT_CLASSES is a filled red tint, visually different, so a reskin was deliberately avoided; migrate during the CTA consolidation project). - OpenAPI:
POST /auth/key-directory/to-devicewas implemented (M-E2EE-5) but undocumented; added a full spec entry to services/api/auth/openapi.json. - Em-dash baseline: comment rewrites in this work removed em dashes from 4 files; baseline re-snapshotted to lock in the reduction.
Round 2 (2026-06-16, same session) - cleared most deferrals โ
After "fix everything", these landed (validate still 25/25):
- Firestore TTL policy on
_rateLimits.expireAtCREATED + ACTIVE on lantern-app-dev (gcloud firestore fields ttls update ... --enable-ttl). Apply the same on prod when that rollout happens. - Lock heartbeat (#25):
heartbeatRefreshLock(token-fenced) + an unref'd interval in import.js bumpsupdatedAtat half the TTL, so a long import is not stolen mid-flight. Closes the concurrent-execution-on-overrun gap WI-8 left. - In-memory limiter eviction (#48): unref'd periodic sweep drops idle keys.
- forge.getEnvironment (#6): now reads GOOGLE_CLOUD_PROJECT, so deployed Cloud Run services are tagged with their real environment instead of 'development'. NOTE: broad data-semantics change - prod analytics events now stamp environment='production'; expected/correct, but flag for anyone querying that col.
- areaPrefix dedup (#42): GEOHASH_AREA_PRECISION is now a single source in @lantern/shared/venues, imported by both the venue-api and the web client.
Still deferred (deliberate, with reasons) โ
- To-device mailbox relationship check (#12): a connections-by-pair gate needs a composite index (dev-deploy friction) and risks dropping a legit room-key share if any to-device timing edge was missed; LOW value (the durable per-device rate limit already bounds flooding). Worth its own scoped change.
- Multi-device limitations (#13 safety-number asymmetry, #15/#36 to-device drain): require building real multi-device support, not a bug fix.
- Micro-opts skipped (crypto-path risk / low payoff): claim-otk and fulfillKeysQuery sequential-IO parallelization (#46/#47); setup-analytics-alerts.sh shared-helper extraction (#41); grandfathered em-dash lines (ratchet burns down).
- CTA primitive migration (baseline bumped, deferred to the CTA consolidation project).
FIXED (pre-existing, folded in on request): 5 forge realtime-destination tests were failing because forge.test.js mocked the legacy default firebase-admin export, but firestore.js migrated to the modular firebase-admin/firestore (getFirestore + FieldValue). Re-pointed the mock at the modular subpath; forge now 61/61. Test-only change; forge still isn't in the validate orchestrator.
Corrections to the review (read first) โ
- The "dev analytics echo runs in prod" alarm (review findings #6/#21) does not hold.
devEchoEnabled()readsGCLOUD_PROJECT || GOOGLE_CLOUD_PROJECT(trackEvent.js:21), and bothdeploy-dev.ymlanddeploy-prod.ymlsetGOOGLE_CLOUD_PROJECT=$PROJECT_ID. So on prod it readslantern-app-prod,.includes('prod')is true, and the echo is correctly OFF. The real, smaller issues are captured in WI-6. - The multi-device safety-number mismatch (review #13) is real but only bites accounts with more than one device, which Lantern does not ship yet. It is folded into WI-1 (same function) and otherwise low priority.
- The em-dash convention hits (review #52 to #55) are mostly pre-existing grandfathered lines (the ratchet baseline). Treat as lowest priority (WI-11) and only fix lines this branch actually added.
P0 - E2EE correctness (PR A: "E2EE hardening round 3") โ
WI-1: Make peer change-detection key-material-aware (the MITM hole) โ
- [ ] Problem. A verified peer can be silently re-keyed and the green "Verified" badge stays lit. The change detector only compares the peer's device-ID set, never the identity-key material.
- Evidence.
- deviceTrust.js:233:
const state = baseline && !sameIdSet(verified.deviceIds, baseline) ? 'changed' : 'verified' - firestoreTransport.js:200:
recordPeerDevices(localUid, uid, Object.keys(devices))passes device IDs only, discardingdevices[id] = data.deviceKeys. - Rules permit the swap: firestore.rules:914-918 write-once guard has
resource == nullas its first disjunct, andallow deleteis owner-allowed, so delete-then-recreate of the same device id installs any new key.
- deviceTrust.js:233:
- Root cause. Baseline/verified records store
deviceIds: string[]. A delete+recreate (or in-place swap that slips the rule) keeps the id set constant, sosameIdSetreturns true and trust staysverified.computeSafetyNumberwould differ, butgetPeerTrustStatenever compares the live number to the stored one. - Fix.
- Change the baseline + verified record to store a per-device key fingerprint map (
{ [deviceId]: sha256(ed25519) }or the ed25519 itself), not a bare id list. UpdaterecordPeerDevices,getPeerTrustState,markPeerVerified, andsameIdSetto asameKeyMapcomparison. fulfillKeysQuerypasses the full{ deviceId: deviceKeys }(or the extracted ed25519 per device) intorecordPeerDevices.- Belt-and-suspenders: in
getConnectionSafetyInfo(roomCrypto.js:134) compare the freshly computedsafetyNumberagainst the storedverifiedSafetyNumberand forcestate: 'changed'on mismatch even if the map check missed.
- Change the baseline + verified record to store a per-device key fingerprint map (
- Tests. deviceTrust unit cases: same-id key swap to
changed; ghost device added tochanged; identical re-publish (reinstall) staysverified; first-sight toverified/baseline set without alarm. - Risk. Storage-format change for the localStorage records; bump the
VERIFIED_PREFIX/BASELINE_PREFIXversion so old records migrate cleanly (a missing fingerprint reads asunverified, which is safe).
WI-1b: Rules + test for the device-key delete-recreate gap (DECISION) โ
- [ ] Add a deny/allow test in firestore.rules.test.js for delete-then-recreate with a different
deviceKeys(today only the in-place merge swap is tested; the actual hole is untested, review #33). - DECISION, how hard to close it in rules:
- (a) Recommended: keep warn-but-allow. Rely on WI-1 client detection (which now catches the swap) and document that the rule blocks only in-place swaps. This matches the stated Signal-style posture and is cheap. Update the rule comment at firestore.rules:911-913 to stop claiming the swap is fully blocked.
- (b) Route device deletion through a server endpoint that writes an identity-key tombstone and enforces key continuity on re-create. Stronger, but adds a write path and a tombstone collection. Track for later.
WI-2: To-device delivery must not ACK a dropped key share โ
- [ ] Problem. When the server drops a room-key payload (rate limit, content cap, or unrecognized recipient) it still returns
200 {}; the client treats that as success and acks the OlmMachine, which then never re-shares. The recipient is left permanently unable to decrypt that Megolm session. - Evidence.
- Server drops but returns 200: keyDirectory.js:253-259 (
continueon rate limit), :243 (unrecognized recipient), :264-267 (content/id caps), then :278return res.json({}). - Client acks on 200: firestoreTransport.js:252-253
if (!res.ok) throw ...; return {}thenmarkRequestAsSentinfulfillRequest.
- Server drops but returns 200: keyDirectory.js:253-259 (
- Fix.
- Server
/to-device: build afailuresmap (mirror/claim-otk's shape) keyed by recipient/device with an errcode that distinguishes retryable (rate-limited) from permanent (unrecognized / malformed). Return it in the body (and consider HTTP 429 when everything was rate-limited). - Client
fulfillToDevice: if the response carries any retryable failure, throw so the request is NOT marked-as-sent and the machine resurfaces it on the next drain. Permanent failures may be acked (and logged) since retry will not help.
- Server
- DECISION, should key shares be rate-limited at all? The per-(sender, recipient) cap (30 / 10 min, keyDirectory.js:58) can be hit by legitimate churn (rapid block/unblock, rotations). Recommended: keep the cap as anti-flood but return it as a retryable failure (never a silent drop), and raise/scope it so normal use does not hit it. Pair with WI-9 so the cap is actually enforced cross-instance.
- Risk. Infinite retry if a permanent drop is misclassified as retryable; the errcode split is load-bearing, so test both paths.
WI-3: Re-blacklist a blocked peer's NEW devices on send (DECISION) โ
- [ ] Problem. Blocking blacklists only the devices the peer had at block time; a device added afterward (or a server-injected ghost) is not blacklisted, so the next
shareRoomKey(allDevices strategy) hands it the room key. - Evidence.
- Block-time only: blockService.js:165 calls
setPeerDevicesBlacklisted(roomCrypto.js:301) which refreshes + blacklists current devices once. - Send path shares with all unblacklisted devices: roomCrypto.js:168-169 (
buildEncryptionSettingsusesCollectStrategy.allDevices()). ThediscardRoomSessiondoc comment (roomCrypto.js:259-266) already flags this exact gap.
- Block-time only: blockService.js:165 calls
- DECISION, product behavior. Can a user still message a peer they blocked (one-sided block) or is the connection closed?
- If closed: the send path should refuse to encrypt to a blocked peer (cleanest).
- If still messageable: in
encryptMessage, re-apply the blacklist for the peer (a fresh KeysQuery + blacklist) beforeshareRoomKey, so any new device is caught each send.
- Tests. Needs the live two-device verification the code comment calls out.
P1 - analytics silent drops (PR B: "analytics taxonomy fixes") โ
WI-4: wave_sent uses an invalid entityType โ
- [ ] Evidence. lanterns.js:125
entityType: 'lantern_pin'.ENTITY_TYPES(analytics/index.js:148) has nolantern_pin, andwave_sent's registeredentityTypesis['wave']. Soforge.validateEventrejects,forge.trackthrows, and every wave is dropped. - Fix. Set
entityType: 'wave'. ConfirmentityIdmatches the registered schema (it documents entityId = wave document ID; the route currently passesrecipientPinId, so set to the wave doc id or update the schema). Add a test thatwave_sentvalidates.
WI-5: scheduled-lantern event names are unregistered (DECISION) โ
- [ ] Evidence. schedule.js:67 (
lantern_schedule_created) and schedule.js:120 (lantern_schedule_cancelled). The taxonomy registers onlylantern_scheduled(analytics/index.js:53). Both throw, so both are dropped. - DECISION, taxonomy.
- (a) Reuse
lantern_scheduledfor the create route and register one newlantern_schedule_cancelledevent. Less taxonomy churn. - (b) Register both
lantern_schedule_created+lantern_schedule_cancelledas new events and retirelantern_scheduled. Clearer semantics. - Recommended: (a). Add tests that the chosen names validate.
- (a) Reuse
WI-6: forge / dev-echo / test env-var alignment โ
- [ ] Evidence.
forge.getEnvironmentreadsGCLOUD_PROJECT || GCP_PROJECT(forge/index.js:150); the deploys setGOOGLE_CLOUD_PROJECTonly;trackEvent.test.jssimulates prod viaGCLOUD_PROJECT(test:40). - Fix.
- Fix the test to set
GOOGLE_CLOUD_PROJECT(mirror the real deploy) and add cases for GCLOUD-only and neither. - Extract one shared env-detection helper so
devEchoEnabledandforge.getEnvironmentagree on the var list (reuse, review #8/#28). - Investigate separately (broad blast radius):
forge.getEnvironmentnot readingGOOGLE_CLOUD_PROJECTmeans forge may mis-detect environment on every deployed service. Confirm what each service actually sets before changing forge; do not bundle that change blind.
- Fix the test to set
WI-6b: Taxonomy validation test (altitude, prevents the whole #4/#5 class) โ
- [ ] Add a CI test that enumerates every
trackEvent/forge.trackcall site and asserts the(eventName, entityType)validates against the taxonomy. This turns silent runtime drops into a build failure and stops the next one shipping.
P1 - E2EE downgrade + venue lock (PR C) โ
WI-7: Plaintext downgrade latch (DECISION) โ
- [ ] Problem. A client can re-permit plaintext on an E2EE connection by deleting its own
userKeys/{uid}doc. - Evidence.
connectionIsE2eeCapableuses a liveexists(userKeys/...)check (firestore.rules:507-510); the message-create rule allows the plaintexttextshape when not capable (firestore.rules:585);userKeysdelete is owner-allowed (firestore.rules:883). - DECISION, enforce "once E2EE, always E2EE":
- (a) Recommended: a monotonic latch on the connection doc (e.g.
e2eeLocked) that can transition false to true but never back; gate the plaintext shape on!e2eeLockedinstead of the liveexists()check. - (b) Make
userKeysdelete server-only (drop the clientallow delete) and handle device/account deletion via a server endpoint.
- (a) Recommended: a monotonic latch on the connection doc (e.g.
- Tests. Deny test: once a connection is e2ee-capable, deleting
userKeysmust NOT re-permit a plaintext message.
WI-8: Fence the venue refresh lock against clobber โ
- [ ] Problem. A slow import that overran the TTL and had its lock break-glass stolen still clears
inProgresson completion/failure, clobbering the new holder and breaking serialization. No heartbeat means any import slower than 5 min is treated as dead while alive. - Evidence.
- Acquire is a proper transactional CAS + break-glass: venue.service.js:166-198.
- Failure release: plain non-transactional merge, no ownership check venue.service.js:210-219.
- Success release: unconditional
inProgress: falseon the primary cell venue.service.js:254-266. - Break-glass uses
Date.now()vs aserverTimestampvenue.service.js:176-177 (clock skew across instances, minor).
- Fix. Token-fence the lock:
acquireRefreshLockmints a uniquelockTokenper acquisition and writes it;releaseRefreshLockand the primary-cell branch ofupdateRefreshMetadatado a transactional compare-and-clear that only clearsinProgresswhen the stored token matches the token this import acquired. Thread the token from import.js through to both release paths. Add a heartbeat (periodicupdatedAtbump) OR raise the TTL above the realistic max import time; with fencing, a late clobber becomes a no-op even if the TTL is exceeded. - Tests. Extend refreshLock.test.js: overran-import release must not clear a stolen lock; two concurrent imports never both hold.
- Fold in cleanup: the catch-block lat/lng re-check (import.js, review #44) and the
GEOHASH_AREA_PRECISIONduplication (move to@lantern/shared/venuesnext toREFRESH_LOCK_TTL_MINUTES, review #42).
P2 - rate-limiter durability + cleanup (PR D) โ
WI-9: Back the security rate limits with a durable store (DECISION) โ
- [ ] Problem. The M-E2EE claim + to-device caps live in a per-process in-memory
Map(rateLimiter.js:14), so on multi-instance Cloud Run the real cap isinstances x limit, it resets on cold start/deploy, and the keyspace this PR introduces grows without eviction (cleanWindowfilters timestamps but leaves empty entries). - DECISION:
- (a) Recommended: move the security-critical limits (claim-otk per-target, to-device per-recipient) to a Firestore-backed window/counter (durable, cross-instance). These ops are rare, so the extra read/write cost is negligible. Keep IP/user limits in-memory.
- (b) Accept best-effort: evict empty Map entries, set
min-instances >= 1, document the limit as soft. Cheaper, weaker.
- At minimum (either option): evict empty entries to stop unbounded growth (review #48).
WI-10: Per-device flood count + mailbox relationship check โ
- [ ] After WI-2/WI-9: count the to-device limit per written doc (per device), not per request (review #40, today one request writes up to 20 docs under one counter tick). Add a connection/relationship check before depositing into a recipient's mailbox so only an established chat peer can write to-device (keyDirectory.js:241, review #12). Lower priority.
WI-11: Cleanup batch (low) โ
- [ ] Dead code:
trackEvent.js:droppedstatus is unreachable (forge.trackreturns{tracked:true}or throws). Drop the branch. - [ ] Efficiency: parallelize independent Firestore ops:
/to-devicecol.addloop and/claim-otkper-device transactions (keyDirectory.js), per-peergetDocsinfulfillKeysQuery(firestoreTransport.js:184-201). GivegetConnectionSafetyInfoa read-only path that doesupdateTrackedUsers+ KeysQuery without the OTK-claim round-trip (roomCrypto.js:119). - [ ] Reuse:
setup-analytics-alerts.shis a near-copy ofsetup-billing-alerts.sh(extract a shared helper). - [ ] Simplification: unused
getPeerTrustStatebarrel re-export (signal/index.js); over-spread return ingetConnectionSafetyInfo. - [ ] Doc drift:
deploy-dev.ymlcomment says venue refresh is "limit-capped to 100/run" but no?limitis passed (default 50). Fix the comment or pass the limit. - [ ] Em dashes: only fix lines THIS branch added (offerService.js:121, firestore.rules:604, venueService.js:476, offerService.test.js:137); the rest are grandfathered baseline. The write-time hook blocks new ones.
- [ ] Revisit
markPeerVerifiedre-baseline (review #22) after WI-1 makes the records key-aware.
Suggested sequencing โ
- PR A (P0 E2EE): WI-1, WI-1b, WI-2, WI-3. Update
SECURITY_REMEDIATION.md(new H-E2EE / M-E2EE follow-up rows) in the same PR. - PR B (analytics): WI-4, WI-5, WI-6, WI-6b.
- PR C (downgrade + lock): WI-7, WI-8.
- PR D (rate-limiter + cleanup): WI-9, WI-10, WI-11.
Bundle related work; open as drafts to dev; run npm run validate once before each PR. Decisions to confirm before implementing: WI-1b, WI-2 (cap policy), WI-3 (block messageability), WI-5 (taxonomy), WI-7 (latch vs server-only delete), WI-9 (durable vs best-effort).