FEN — Design & Security Review Findings
Status: recorded · review pass over the design docs (
doc/*.md,ARCHITECTURE.md) and the implementation (packages/*,apps/fen,services/fen-provisioner,infra/*) as of5db2fed. Every finding is tracked as a GitHub issue (#15, #17–#35); resolutions land in their own PRs and are annotated per finding below. Scope: correctness, security, and topics left out. This document records problems; it does not change the design. Each finding has a proposed direction, not a final decision. Legend — severity: 🔴 high (blocks a core guarantee or ships a real vuln) · 🟠 medium (wrong or missing, exploitable/observable under realistic conditions) · 🟡 low (correctness/clarity, low blast radius).
Summary
The cryptographic core is sound (libsodium-only, XChaCha20-Poly1305, Ed25519, encrypt-then-sign,
canonical serialization with golden vectors) and the provisioner shows real security care (replay
cache on decoded bytes, constant error responses, no secret logging, X-Forwarded-For not
trusted). The serious problems are in the authorization layer the crypto cannot provide —
membership control, credential rotation, and the availability/griefing consequences of the
completeness gate — plus a set of spec-vs-implementation drifts where the code contradicts the
normative docs.
| # | Severity | Area | One-line | Tracked |
|---|---|---|---|---|
| 1 | 🔴 | Backend/rotation | S3-key rotation on removal locks out remaining members (no new-credential channel) | #15 · addressed by PR #40 |
| 2 | 🔴 | Invite/threat model | Leaked invite = permanent irrevocable compromise in v1 (rotation out of v1 scope) | #17 · addressed below |
| 3 | 🔴 | Membership | Acceptance underdesigned: no ack event, self-certifying joins, attacker-winnable tiebreak, invite_id missing from payload | #18 · addressed below |
| 4 | 🔴 | Event log | Event-suppression via author-chosen event_id collision | #19 |
| 5 | 🔴 | Availability | Completeness gate is a one-actor denial-of-settlement weapon | #20 · addressed below |
| 6 | 🟠 | Crypto | Ed25519→X25519 key reuse for wrapping used without justification | #21 |
| 7 | 🟠 | Crypto | Epoch-key wrap construction unspecified (static vs ephemeral ECDH → forward secrecy claim unbacked) | #22 |
| 8 | 🟠 | Sync | conditionalWrite:false makes probe().usable always false; multi-device read-modify-write PUT loses events | #23 |
| 9 | 🟠 | Impl vs spec | ExpenseUpdated spec'd as full snapshot, implemented as a patch | #24 |
| 10 | 🟠 | Impl vs spec | Split-sum invariant validated nowhere in the reducer | #25 |
| 11 | 🟠 | Impl vs spec | Signature verification absent from the ingest/reduce path (layer unimplemented) | #26 · addressed below |
| 12 | 🟠 | Infra/DR | Garage replication_factor=1 single node contradicts the "≥2 backends" durability mitigation | #27 |
| 13 | 🟠 | Privacy | Pubkey filenames leak the cross-group social graph (only an open question today) | #28 |
| 14 | 🟠 | Abuse | Provisioner group-creation abuse cheap to mount (free self-minted keys, rotatable IP) | #29 |
| 15 | 🟡 | Crypto hygiene | Canonical parser doesn't enforce id/hex/UUID shape or NFC (partial §9 hardening) | #30 |
| 16 | 🟡 | Impl vs spec | permission_rules.dart fully stubbed (UnimplementedError) | #31 |
| 17 | 🟡 | Impl vs spec | Participant.public_key still documented as X25519 (signatures are Ed25519) | #32 |
| 18 | 🟡 | Ops/legal | No at-rest encryption for the local SQLite event log; no GDPR/compliance surface | #33 |
| 19 | 🟡 | Deep links | AASA / assetlinks are TODO placeholders → https trust anchor degrades to hijackable custom scheme | #34 · addressed by documenting the launch gate requirement |
| 20 | 🟡 | Various | Topics left out (see §"Absent topics") | #35 |
1. 🔴 S3-key rotation on removal locks out remaining members
Tracked: #15 · Addressed: PR #40 implements the direction below as two-phase rotation, with one deviation: the new descriptor travels in a
StorageKeyRotatedevent encrypted under the post-removal epoch key (whichKeyEpochUpdatealready wraps per member) rather than being per-member-wrapped itself — same recipient set, no new wrapping machinery, and straggler recovery unifies withKeyRewrapRequest. Seedata-model.md§3.10,backend-architecture.md§6.1,security.md§4.8 (C-5, E-5).
Where: doc/security.md §4.8 (invariant E-1, E-2), doc/backend-architecture.md §6,
services/fen-provisioner/internal/api/server.go (handleRotateKey).
Problem. Invariant E-1 requires member removal to rotate the per-group S3 key.
handleRotateKey mints a new key and immediately DenyBucketKeys the old one. The per-group S3
key is shared by every member, so rotation invalidates the credential for all remaining members
at once. The only documented distribution channel for a StorageDescriptor is the invite package
(one-time, at join) and the bucket itself — which remaining members can no longer read once the old
key is denied. There is no specified mechanism to deliver the new credential to the members who
should keep access. As written, "rotate on removal" locks out the whole group.
Direction. Define a credential-hand-off protocol, e.g. a signed StorageKeyRotated event
carrying the new descriptor per-member-wrapped (X25519 to each remaining member), published
under the old key, with the provisioner deferring DenyBucketKey(old) until either all remaining
members ack or a bounded window elapses. Document the exact security window this opens (removed
member can still write during the overlap) and how the key-epoch rotation interleaves.
2. 🔴 A leaked invite is a permanent, irrevocable compromise in v1
Tracked: #17 · Resolved 2026-07-05: direction (b) — the provisioner mints a short-TTL (default 30 min), single-use invite credential distinct from the group's shared key (
POST /v1/groups/:id/invites), the joiner exchanges it for the shared credential once its ownMemberJoinedwrite succeeds (POST /v1/groups/:id/invites/:invite_id/complete), and the reaper denies+deletes invite credentials once used or expired. Key-epoch rotation and member removal remain explicitly out of v1 scope, unchanged. Seefen.md§2.4,security.md§4.3, andimplementation.md§5.5.
Where: doc/fen.md §2.4, doc/security.md §4.3, doc/implementation.md §5.5.
Problem. Three separately-acknowledged decisions compose badly:
- The invite package carries both
group_keyand the S3 bearer credential. expiry/revocation are client-enforced only; a malicious holder ignores them and uses the raw S3 creds +group_keydirectly.expiryis honest-client UX, not a security control.- Key-epoch rotation and member removal are explicitly out of v1 scope.
Net: shipping v1 has no recovery path from a leaked invite short of abandoning the group.
Invites travel via WhatsApp/iMessage/SMS — i.e. third-party servers, cloud message backups,
clipboard sync — so leakage is a realistic, not exotic, event. Also: s (secret) and p
(ciphertext) travel in the same URL, so the encryption adds nothing against anyone who sees the
whole link; it only defends against partial exposure of p without s.
Direction. Either (a) pull a minimal rotation path into v1, or (b) state the limitation
prominently in security.md §4.3 and the onboarding copy, and reduce blast radius (short default
expiry actually enforced server-side by the provisioner minting single-use, short-TTL credentials
rather than a long-lived bearer key in the link).
3. 🔴 Membership acceptance is underdesigned and partly wrong
Tracked: #18 · Resolved 2026-07-05:
MemberJoinedPayloadnow carriesinvite_id;MemberAdmittedis an explicit organiser-authored admission event bindingpublic_key -> invite_id; the reducer keeps self-signed joins pending and promotes only matching organiser admissions; the organiser asks the provisioner which pubkey won an invite viaGET /v1/groups/:id/invites/:invite_id, replacing the attacker-winnable(lamport, event_id)duplicate-join tiebreak. The organiser app invokes admission reconciliation when loading a group with pending joins. Real cryptographic enforcement that theMemberAdmittedsignature actually belongs to the organiser — deferred at the time to issue #26 — is now in place; see finding 11 below.
Where: doc/security.md §4.3–4.4, doc/fen.md Flow 3, doc/data-model.md §3.3/§3.5,
packages/fen_events/lib/src/payloads.dart.
Problems.
- §4.3 says "a member must acknowledge [a join] before Bob appears in the roster" — there is no acknowledgement event type in the schema. The mechanism doesn't exist.
MemberJoinedis self-certifying: peers verify its signature against the pubkey inside its own payload, so §4.4's "author_id is a known participant" is bootstrapped by the event being validated. Effective membership = possession of the invite link.fen.mdFlow 3 claims a duplicate joiner "cannot read any group content — E2EE." False — they holdgroup_key+ S3 key from the same package and read/write everything in the epoch.- The duplicate-join tiebreak ("first by
(lamport, event_id)wins") is attacker-winnable: both sort keys are author-chosen, so a malicious joiner stampslamport=1+ minimalevent_idand deterministically beats the honest joiner. - The rule keys on
invite_id, butMemberJoinedPayloadhas noinvite_idfield (neither indata-model.md§3.3 norpayloads.dart). As specified it is unimplementable.
Direction. Introduce an explicit organiser-signed admission event (MemberAdmitted) that binds
invited_pubkey → invite_id, make roster membership depend on it rather than on the self-signed
join, add invite_id to the join payload, and make single-use enforcement organiser-authoritative
(the organiser, not a client tiebreak, resolves duplicate joins).
4. 🔴 Event suppression via author-chosen event_id collision
Tracked: #19
Where: doc/data-model.md §3.9 (reducer invariant 5), doc/canonical-serialization.md §12
(open decision #3), packages/fen_ledger/lib/src/event_reducer.dart.
Problem. Dedup is "first (group_id, event_id) wins; later ones silently ignored." But
event_id is an author-assigned UUIDv4 inside the signed envelope, not a content hash. A member
can pre-publish an event reusing an event_id an honest event will later use (or flood ids), and
peers that saw the malicious one first silently drop the honest event as a "duplicate." author_seq
contiguity doesn't catch it — the slot is filled. Uniqueness isn't enforced by construction because
it's an author choice.
Direction. Resolve canonical-serialization open decision #3 in favour of a content-hash
event_id (SHA-256 of canonical bytes), or bind dedup to (author_id, author_seq) rather than
event_id so an author cannot suppress another author's event. Note the AAD dependency on
event_id existing pre-encryption (§7) when choosing.
5. 🔴 The completeness gate is a one-actor denial-of-settlement weapon
Tracked: #20 · Resolved 2026-07-08 (design): the gate was spec-only (no
author_frontiergate exists in code yet), and it was the design that was the weapon — so this fix is design-primary, adopting both halves of the Direction because each closes a different half of the attack. (1) Provableclaimed_seq(data-model.md§3.9 and new §3.12;fen.md§2.5;error-handling.mdError 7):claimed_seqnow advances only on receipt of a validly-signed event authored by that member, and the per-author high-water-mark advertisements peers exchange are demoted to routing hints that request ranges but never raise the frontier. So no member can inflate another member's frontier, and none can raise their own with a fabricated future seq — the zero-cost, no-key "advertise 999 and never upload" freeze is gone. (2) A bounded escape (new §3.12; Error 7 recovery option 4): a frontier stalled past a timeout is overridden by an organiser-signedSettlementSnapshotover the provable subset ("settle on what's provable") — the same organiser-signed settle-on-a-cut event already used before member removal (§3.10). It is organiser-only (enforced at ingest byVerifyingEventSink, issue #26) and checkable: itscovered_frontiernames the exact cut, so every member recomputes and rejects a snapshot whose balances don't match the provable log — the organiser picks the cut, never the numbers.SettlementSnapshotPayloadis brought in line with that spec (epoch,covered_frontier,stalled_members,as_of) and codec-wired with a round-trip test.Deliberately NOT covered: building the gate itself (
author_frontiertable, gate evaluation, timeoutT, snapshot-authoring UI) — a separate implementation milestone; and a malicious/absent organiser (a quorum/rotation override that removes the organiser as a single point of failure is out of v1 scope, riding with the never-returning-organiser open question in §20 Absent topics). v1's threat model — a single ordinary member freezing the whole group — is fully closed.
Where: doc/error-handling.md Error 7, doc/data-model.md §3.9, §3.12.
Problem. Settlement is hard-blocked until contiguous_seq == claimed_seq for every member.
claimed_seq rises when any peer advertises a high-water mark. So one member can advertise
author_seq: 999, never upload 1..999, and permanently block settlement for the whole group —
a single-actor denial of the app's core purpose. No timeout, quorum override, or "settle on what's
provable" escape hatch exists. Combined with the bearer key (any member can also delete others'
logs, §4.3), one bad actor can both corrupt and freeze the group.
Direction. Add a bounded escape: organiser-signed SettlementSnapshot can override the gate for
members whose frontier is stalled beyond a timeout; or require the high-water-mark claim to be
signed by that member over an event they actually published so a member can only block on their
own real events, not fabricated future ones.
6. 🟠 Ed25519→X25519 key reuse for wrapping used without justification
Tracked: #21
Where: packages/fen_crypto/lib/src/key_exchange.dart (x25519PublicFromEd25519,
x25519SecretFromEd25519), doc/security.md §4.1.
Problem. One identity keypair is reused for both signing (Ed25519) and ECDH key-wrapping
(X25519 via crypto_sign_ed25519_*_to_curve25519). Sharing a key across signing and KEM is a known
cross-protocol footgun (the two schemes' proofs don't compose for free; Ed25519→X25519 has
cofactor/malleability caveats). It's usually fine in practice, but here it reads as a convenience
("one key both signs and wraps") with no security argument.
Direction. Either derive a separate wrapping key, or keep the reuse and add an explicit justification citing the specific safety conditions (domain separation, no shared nonces, the libsodium conversion's guarantees).
7. 🟠 Epoch-key wrap construction is unspecified
Tracked: #22
Where: doc/data-model.md §3.3 (KeyEpochUpdatePayload.member_keys[].wrapped_key), §3.10.
Problem. wrapped_key is "group_key encrypted to this member's pubkey," but the construction is
undefined: static-static ECDH (no forward secrecy; identical wraps to the same member each epoch) or
ephemeral-static/ECIES (needs a per-recipient ephemeral pubkey field the payload lacks). This is
load-bearing for the "forward secrecy on removal" claim (§4.8) and is missing.
Direction. Specify an ECIES-style wrap (ephemeral X25519 pubkey per recipient + HKDF + AEAD), add the ephemeral-pubkey field to the payload, and state the forward-secrecy properties precisely.
8. 🟠 conditionalWrite:false breaks probe().usable; multi-device PUT loses events
Tracked: #23
Where: packages/fen_sync/lib/src/storage_backend.dart (BackendCaps.usable),
packages/fen_sync/lib/src/s3_backend.dart (probe), packages/fen_sync/lib/src/sync_manager.dart
(flush), doc/security.md §4.8 (invariant E-3), doc/key-management.md §4.7 multi-device.
Problem. BackendCaps.usable requires conditionalWrite == true; S3Backend.probe() hardcodes
false ("Garage If-Match support unconfirmed"). So probe().usable is always false and
invariant E-3 ("a backend must pass probe() before binding") can never be satisfied as coded.
flush() does a read-modify-write PUT of the whole log file with ifMatchEtag; if Garage ignores
If-Match, two devices of the same member (permitted by §4.7) silently lose-update each other's
events. The "single writer per object" guarantee holds only for one device, which §4.7's
key-copy multi-device path explicitly breaks.
Direction. Confirm Garage's conditional-write behaviour and either (a) rely on it and fix
probe(), or (b) redesign append to not require whole-file RMW (e.g. per-event object keys
logs/<pubkey>/<author_seq>.json, which also removes the collision surface). Reconcile the
multi-device section with the single-writer assumption now, since key-copy is possible in v1.
9. 🟠 ExpenseUpdated: full-snapshot spec vs patch implementation
Tracked: #24
Where: doc/data-model.md §3.8 (Projection Mechanics, "full snapshot… not a diff"),
packages/fen_events/lib/src/payloads.dart (ExpenseUpdatedPayload.patch),
packages/fen_ledger/lib/src/event_reducer.dart (_expenseUpdated).
Problem. The normative doc says ExpenseUpdated is a complete snapshot with partial-patch
semantics "explicitly excluded." The code models it as Map<String,dynamic> patch and applies
patch[x] ?? old.x — a diff. It's idempotent, but contradicts the spec, and a patch changing
settlementAmount while omitting splits silently violates the split-sum invariant.
Direction. Pick one and align docs+code. If snapshot: type the payload with all required fields and validate the split-sum on apply. If patch: update the normative doc and add cross-field validation.
10. 🟠 Split-sum invariant validated nowhere in the reducer
Tracked: #25
Where: doc/security.md §4.4, doc/canonical-serialization.md §8,
packages/fen_ledger/lib/src/event_reducer.dart, packages/fen_events/lib/src/payloads.dart.
Problem. sum(splits) == settlementAmount is mandated "on write and on receipt," but neither
reduce() nor the payload constructors check it. A crafted event violating it corrupts balances and
breaks the sum(net) == 0 cross-check that debt simplification relies on.
Direction. Enforce the invariant at ingest (before reduce) and reject violating events per the §9 "reject, don't sanitize" rule; add a golden test with a deliberately unbalanced event.
11. 🟠 Signature verification is absent from the ingest/reduce path
Tracked: #26 · Resolved:
VerifyingEventSink(apps/fen/lib/core/data/verifying_event_sink.dart) implements theEventSinkinterface and is now the only path a raw synced line can take before it reachesreduce(). For each line it: decodes the canonicalSignedEnvelope; verifiesauthor_signatureagainst the envelope's own claimedauthor_id(Ed25519, over the still-encrypted envelope, BEFORE decrypting); decrypts and decodes the payload; dedupes byevent_idagainst the existing log and the rest of the batch; and resolves roster authority by folding the samereduce()state the projector uses (organiser fromGroupCreated, active members viaMemberAdmitted) to check the claimed author is currently allowed to publish that event type. Only events passing every check are appended to the local log; everything else is dropped and reported via a typedEventRejection— part of theEventSink/SyncManagercontract itself (packages/fen_sync/lib/src/sync_manager.dart: bothingest()andpull()returnList<EventRejection>rather than discarding it), not an app-only bonus type reachable only from a test. Tests cover a tampered envelope, a signature valid under the wrong key, a non-roster author, a not-yet-admitted member attempting a privileged event, a forgedMemberAdmitted(right shape, wrong signer), and genuine cross-device round trips through the realJoinInviteService/AddExpenseServiceencoders.Production wiring (cross-review follow-up): an earlier draft of this fix left
VerifyingEventSinkregistered but never actually invoked by any production code path — the same class of defect PR #56 originally shipped.GroupSyncService(apps/fen/lib/core/data/group_sync_service.dart) is now the real, reachable call site: it builds a realS3Backendfrom the group'sgroup_storagebinding and push+pulls throughStorageBackendSyncManager, wired opportunistically intogroupBalanceProvider(the same group-load pointMemberAdmissionService.reconcilePendingAdmissionsuses). This is deliberately not a continuous background pull loop — that remains a separate, larger milestone — but it is a genuine, tested, non-test production call site, not just DI registration.Deliberately NOT covered by this fix (see
doc/security.md§4.4's "Honestly remaining" note for the full list): theEXPENSE EVENTScontent-validation rules (split-sum, currency, participant-is-member — issue #25);permission_rules.dart'screator_onlyedit-permission check (issue #31); organiser-only authorship ofGroupSettingsUpdated/GroupClosed/MemberRemoved(member-removal-style enforcement, issue #15-style); a continuous background pull loop (separate milestone, above); and the inherent bootstrap-trust question of a brand-new group's very firstGroupCreated. Implementing this issue also surfaced (and fixed, as a necessary prerequisite — confirmed via a repo-wide grep for everySignedEnvelope(/hkdf(/encrypt(construction site) a separate, pre-existing bug where BOTHjoin_invite_service.dart(MemberJoined) andadd_expense_service.dart(ExpenseLogged) sealed events with a different content-key derivation and AAD usage than the sharedsealEvent()helper the other two producers used — making those event types undecryptable by any single consistent receiver. Both now usesealEvent(); documented (but did not fix, as it is not independently exploitable given Ed25519 already covers the same fields) thatsealEvent()itself computesaadBytes()but never actually binds it into the AEAD call.
Where: packages/fen_ledger/lib/src/event_reducer.dart, packages/fen_sync/lib/src/sync_manager.dart
(EventSink is an interface only), doc/data-model.md §3.9 (reducer invariant 3).
Problem. reduce() trusts a GroupEvent and never verifies its Ed25519 signature; the
EventSink that must decrypt+verify+dedup before reducing is declared but not implemented in
the repo. So today nothing enforces the security-critical checks (invariants 3, 4, roster
membership). Correct layering, but the layer that carries all the security is the missing one.
Direction. Implement the ingest layer (verify signature against roster pubkey → check AAD/epoch → dedup → split-sum → reduce) and make it the only path into the reducer; add tests that a tampered or non-roster-signed event never reaches state.
12. 🟠 replication_factor = 1 contradicts the durability mitigation
Tracked: #27
Where: infra/managed-backend/garage.toml, doc/data-model.md §3.9 ("publish to ≥2 backends"),
doc/security.md §4.8 (invariant E-4: one backend per group).
Problem. §3.9 admits the residual data-loss risk is an event that exists on only one device before a peer copies it, and offers "publish to ≥2 backends immediately" as the mitigation. But a group is bound to exactly one backend (E-4), and that backend is a single-node, replication_factor=1 Garage on a NAS. The named mitigation is unavailable, so the SPOF is real until peers happen to sync.
Direction. Either raise replication / add a second replica target, or drop the "≥2 backends"
claim and rely explicitly on multi-device replicas + document the RPO. Add a tested restore
procedure (referenced in operations.md but not proven).
13. 🟠 Pubkey filenames leak the cross-group social graph
Tracked: #28
Where: doc/security.md §4.5, §4.8 (open question #4), doc/backend-architecture.md §1, §5.
Problem. logs/<member_pubkey>.jsonl exposes group membership, group sizes, and activity timing
to the operator; pubkeys are stable across groups, so the operator can correlate the same person
across groups. Client IP (logged/visible per §4.8 B) further de-pseudonymizes. Currently only an
open question.
Direction. Make the decision: derive an opaque per-group filename, e.g.
HKDF(group_key, "filename", member_pubkey) → hex, so filenames don't correlate across groups and
don't reveal raw identity pubkeys. Cheap; closes a standing leak.
14. 🟠 Provisioner group-creation abuse is cheap
Tracked: #29
Where: services/fen-provisioner/internal/api/server.go (handleCreateGroup, clientIP),
internal/ratelimit, doc/security.md §4.8 (open question), infra/managed-backend/docker-compose.yml.
Problem. POST /v1/groups is Ed25519-signed, but keys are self-minted and free, so per-pubkey
rate limiting is defeated by a fresh keypair per request. Per-IP limiting trusts only
CF-Connecting-IP; if the origin is reachable directly (compose doesn't firewall Garage/provisioner
to the tunnel), the header is absent and RemoteAddr is used — rotatable via proxies. A NAS-hosted
managed tier is exposed to storage-exhaustion abuse (each group = a bucket + quota). Attestation is
correctly deferred, but there's no interim cost.
Direction. Bind creation to something scarce (proof-of-work, a small deposit, or invite-tree
provenance), firewall the origin so only the tunnel can reach it (making CF-Connecting-IP
trustworthy), and cap total buckets/global create rate independent of identity.
15. 🟡 Canonical parser doesn't enforce id/hex/UUID shape or NFC
Tracked: #30
Where: packages/fen_events/lib/src/serialization.dart, doc/canonical-serialization.md
§3 (C4, C6), §9.
Problem. The "re-encode and compare" gate catches key-order/whitespace/dup-key/escaping issues
(nice), but: (a) _writeString re-emits surrogate code units without validation and NFC (C4) is
delegated elsewhere and not enforced here; (b) nothing checks event_id/group_id are lowercase
UUIDv4 or author_id is 64-char lowercase hex (C6). Malformed-but-round-trippable ids pass. The §9
hardening list is only partially implemented.
Direction. Add explicit shape checks for ids/hex/UUID and reject unpaired surrogates / non-NFC in the envelope; keep the round-trip gate as a backstop.
16. 🟡 permission_rules.dart is fully stubbed
Tracked: #31
Where: packages/fen_ledger/lib/src/permission_rules.dart.
Problem. canEdit/canDelete/canRemove all throw UnimplementedError. The edit/delete/remove
authorization surface that §4.4 depends on has no implementation yet. Acceptable for a POC, but
means no permission enforcement exists in code.
Direction. Implement against §4.4 rules; add unit tests for creator_only vs any_member and
organiser-only removal/close.
17. 🟡 Participant.public_key documented as X25519 (it's Ed25519)
Tracked: #32
Where: doc/data-model.md §3.2, §3.4 (participants.public_key "base64 X25519").
Problem. Comment says "X25519 public key; used to verify event signatures" — signatures are Ed25519. Leftover from the pre-Ed25519 rewrite; misleads implementers (and the wrap needs the Ed25519→X25519 conversion, not a stored X25519 key).
Direction. Correct to Ed25519 (hex, per the rest of the codebase) throughout the data model.
18. 🟡 No at-rest encryption for local data; no compliance surface
Tracked: #33
Where: doc/security.md §4.1 (key storage), doc/key-management.md §4.7, doc/operations.md.
Problem. Keys live in Keychain/Keystore (good), but the local SQLite event log — all plaintext
expense content, names, amounts — is on-device unencrypted. Device loss before remote-wipe exposes
everything; §4.7 covers key recovery, not local-data confidentiality. Separately, a managed service
holding EU users' IP-linked (pseudonymous) data has GDPR duties (processor role, breach
notification) that operations.md doesn't address — "no analytics" ≠ no obligation.
Direction. Consider SQLCipher / biometric app-lock for the local DB; add a short compliance section (data-processor stance, retention, breach process, IP-as-personal-data).
19. 🟡 AASA / assetlinks are placeholders → https trust anchor degrades
Tracked: #34 · Addressed: The
isInviteLinkparser now explicitly documents the custom-scheme fallback as an insecure downgrade and establishes real AASA/assetlinks as a launch gate.
Where: infra/well-known/apple-app-site-association, infra/well-known/assetlinks.json,
apps/fen/lib/features/onboarding/invite_link_parser.dart (isInviteLink).
Problem. Both files are TODO-REPLACE placeholders, so Universal/App Links won't verify. The
parser deliberately excludes plain http (good) and prefers the TLS-verified https form — but until
AASA/assetlinks are real, invites fall back to the fen:// custom scheme, which any app can
register and hijack (invite-secret interception).
Direction. Treat real AASA/assetlinks as a launch gate; document that the custom-scheme fallback is a downgrade and consider warning on it.
20. Absent topics (assorted)
Tracked: #35
- Epoch-wrap forward secrecy (see #7) and cross-protocol key reuse (#6) — both unbacked.
- FX-rate integrity: the pre-filled settlement suggestion comes from a plain
frankfurter.appfetch; a MITM could bias the number. Low severity (user confirms), unmentioned. - Backup KEK is underspecified: Method A KEK =
HKDF(device_secret, …)but Secure Enclave keys are non-extractable — what is the IKM concretely? Method C says Argon2id with no parameters. Not implementable as written. - Payload schema versioning: only the envelope
fmtis versioned. An older client silently ignoring a future membership-affecting event could keep trusting a removed member — forward-compat vs security tension, unaddressed. - Orphan/close deletion is loud-by-omission: the 6-month orphan sweep and 45-day close purge can destroy a group's canonical copy (financial record) with only client-side warnings; the never-returning organiser is only an open question.
KeyRewrapRequesthas no rate-limit/dedup rule — a griefer can force repeated organiser re-wraps.
What's genuinely good (keep)
- Encrypt-then-sign with canonical AAD binding
{group_id, epoch, event_id, author_id, event_type}— correct; prevents cross-epoch/cross-group replay. - Provisioner replay cache keyed on decoded pubkey/sig bytes (defeats hex-case malleability) and
refusal to trust
X-Forwarded-For. - Lamport + per-author
author_seqas a minimal sequencer substitute — sound and well-argued. - Money layer: integer minor units, decimal strings on the wire, deterministic remainder distribution — avoids the usual float disasters.
security.md§4.8 already names several trade-offs honestly; the gap is turning them into mechanisms in the spec and code.
Suggested triage order
- Unblock the protocol: #1 (rotation hand-off) and #3 (membership acceptance) — they gate the most.
- Close the log-integrity holes: #4 (
event_idsuppression), #10/#11 (invariant + verify at ingest), #8 (append model). - Availability & abuse: #5 (settlement gate escape), #14 (create abuse), #12 (durability).
- Privacy & clarity: #13 (filenames), #9/#15/#16/#17 (spec/impl drift), #2 (threat-model honesty).
- Left-out topics: #6/#7 (wrap crypto), #18/#19 and §20.