Skip to main content

FEN — Security & Compliance (§4)

Part of the FEN design set. Overview, index, and the legacy-terminology map: fen.md.

4. Security & Compliance

4.1 Cryptographic Primitives

ConcernAlgorithmNotes
Group content encryptionXChaCha20-Poly1305256-bit key, 192-bit random nonce per event. Key derived via HKDF from group_key. Random nonce generation is safe at all scales; no nonce-management problem across offline multi-device writes.
Receipt / blob encryptionXChaCha20-Poly1305attachment_key derived from group_key via HKDF. Blob uploaded as ciphertext; sha256(ciphertext) is the content address.
Invite package encryptionXChaCha20-Poly1305Encrypted with 32-byte random invite_secret. Package travels in deep link #fragment.
Sub-key derivationHKDF-SHA256HKDF(group_key, salt="", info="fen-v1-<purpose>")content_key, attachment_key, roster_key
Event signingEd25519 (RFC 8032)Each member signs with their own member_privkey; the signature covers the canonical envelope bytes (RFC 8785 JCS; encrypt-then-sign, so the signed envelope carries the ciphertext and authorship verifies without decrypting — see canonical-serialization.md). Ed25519 hashes internally, so bytes are signed directly (no pre-hash). event_id is an assigned UUIDv4, not a content hash. Decision (§4.8): Ed25519 replaces the former secp256k1/Schnorr, whose only rationale was legacy compatibility — now removed. Provided by libsodium; the bip340 dependency is dropped.
Storage write accessBackend-native (NON-cryptographic)Managed (Garage, v1): per-group S3 key. Planned v2 BYO self-hosted S3 uses the same mechanism. ⚠️ A bearer capability scoped to one group — trade-offs analysed in §4.8.
Attachment write accessBackend-nativeEncrypted receipt blobs are written as files in the group folder under the same backend credential as events. attachment storage removed.
Random generationCSPRNGPlatform secure random (SecRandomCopyBytes on iOS, SecureRandom on Android) for all key material and nonces.
Key storagePlatform secure enclavemember_privkey and group_key stored in iOS Keychain / Android Keystore. Never in SharedPreferences or local DB plaintext.

Algorithm rationale:

  • XChaCha20-Poly1305 over AES-256-GCM: libsodium's AES-256-GCM implementation (crypto_aead_aes256gcm) requires hardware AES acceleration and provides no software fallbackcrypto_aead_aes256gcm_is_available() returns 0 on many Android devices (low-end MediaTek / older Qualcomm SoCs). XChaCha20-Poly1305 is libsodium's primary AEAD cipher, runs in constant-time software on every target, and its 192-bit random nonce eliminates nonce-management risk across multi-device offline writes (birthday bound is negligible for the lifetime of the universe).
  • Ed25519 (RFC 8032) for signing — decided. secp256k1/Schnorr existed only for legacy compatibility; with the backend removed, that constraint is gone. Ed25519 is standard, fast to verify, and already provided by sodium_libs, so the bip340 dependency is dropped and the entire crypto stack lives in one audited library (libsodium). Epoch-key wrapping (§3.10) uses X25519 ECDH; an Ed25519 identity key converts to X25519 via crypto_sign_ed25519_pk_to_curve25519, so a single identity key both signs and wraps.
  • HKDF over direct group_key use: defence-in-depth — a compromise of one sub-key (e.g. attachment_key) does not expose event content encrypted under content_key.

4.2 Key Hierarchy

graph TD
GK["group_key
(32 random bytes)
One per group — the content root secret"]
IK["invite_secret
(32 random bytes)
One per invite — discarded after join"]
MK["member_privkey
(Ed25519)
One per member per group
Generated on-device — NEVER shared or distributed"]

GK -->|"HKDF-SHA256
info=fen-v1-content"| CK["content_key
XChaCha20-Poly1305
Encrypts all event payloads"]
GK -->|"HKDF-SHA256
info=fen-v1-attachment"| AK["attachment_key
XChaCha20-Poly1305
Encrypts receipt photo blobs"]
GK -->|"HKDF-SHA256
info=fen-v1-roster"| RK["roster_key
XChaCha20-Poly1305
Encrypts member roster events"]

GK -->|"included in"| InvitePkg["Invite package
{group_id, group_key,
storage_descriptor, member_pubkeys, expiry}"]
IK -->|"XChaCha20-Poly1305 encrypts"| InvitePkg
IK -->|"travels in"| Fragment["URL #fragment
(never sent to any server)"]
InvitePkg -->|"also travels in"| Fragment

MK -->|"signs (author attribution)"| Events["Signed events
(per-author log files)"]
MK -->|"member_pubkey published in"| Roster["Encrypted group roster
(MemberJoined event)"]
SA["Storage access
NON-cryptographic
Per-group S3 key"] -->|"scoped write (bearer)"| Store["Storage backend
(Garage; v2 planned: self-hosted S3)"]

What was eliminated vs the custom-backend design:

Old credentialPurposeReplacement
Shared bearer write credentialBackend write accessThe per-group S3 key (Managed today; same mechanism for the planned v2 BYO self-hosted tier) — a bearer capability scoped to one group; rotated on member removal. See §4.8.
device_keypair (X25519/Ed25519)Event signingmember_privkey (Ed25519) — signing only; storage write access is a separate backend credential (§4.8)
Stored invite record on backendPackage retrievalSelf-contained #fragment — no server round-trip, no backend persistence

Trust boundary summary:

  • The storage backend sees: encrypted event/blob bytes (random-looking), file names (logs/<member_pubkey>.jsonl — so per-member pubkeys and group structure are visible), file sizes, and timestamps. It cannot read content.
  • No backend ever sees group_key, invite_secret, any sub-key, or any member private key.
  • Net: content confidentiality is identical on any backend; the Managed tier (Garage) exposes only pseudonymous metadata (Ed25519 pubkeys), never real identities (see §4.5, §4.8).

4.3 Threat Model

ThreatMitigation
Storage provider reads expense dataAll events encrypted with content_key (HKDF from group_key). The backend sees only ciphertext + file names.
Storage provider reads receipt photosPhotos encrypted with attachment_key (HKDF from group_key) before upload. The backend receives random-looking bytes; sha256(ciphertext) is the file name and reveals nothing about plaintext.
Forged expense eventsEvery event carries a signature over its canonical bytes. Peers verify against the active roster, where non-founder members become active only after the organiser publishes MemberAdmitted; unrecognised or merely pending pubkeys are rejected. Resolved (issue #26): VerifyingEventSink (apps/fen/lib/core/data/verifying_event_sink.dart) is now the sole path raw synced lines take into the reducer — it verifies author_signature against the envelope's own claimed author_id before decrypting, then decrypts and checks the claimed author is currently the organiser or an active roster member (folding the same reduce() state the projector uses) before the event is ever appended to the local log. A tampered envelope, a signature valid under the wrong key, or a genuinely-valid signature from a non-roster/not-yet-admitted author are all rejected pre-reduce, never silently — surfaced via EventRejection, part of the EventSink/SyncManager contract itself (packages/fen_sync), not an app-only bonus type. Driven by a real production call site, GroupSyncService, wired opportunistically into groupBalanceProvider (§4.4 has the full account, including the "not yet a continuous pull loop" caveat). Transport-independent — unchanged by the migration.
Unauthorised storage write accessThe per-group S3 key (Managed today; same for the planned v2 BYO self-hosted tier) is a bearer capability scoped to one group: any holder can create/overwrite/delete files in that group. Forgery stays impossible (signatures), but a holder can delete or truncate logs. Mitigations: signed events reject tampering; every device keeps a full replica so deletions are recoverable; rotate the key + key epoch on removal. See §4.8.
Removed member vandalises storageA removed member still holding the S3 key can delete others' files until access is rotated. Removal therefore must rotate the per-group S3 key, not just the key epoch (§3.10). Rotation is two-phase — the old key stays valid until remaining members have received the new credential or a bounded deadline passes (default 7 days; §4.8 C-5, E-5) — so vandalism remains possible during that window. Recovery: any member re-uploads from their local replica.
Invite link forwarded to uninvited personThe invite is single-use, enforced by the provisioner (issue #17): whoever's device completes the join first wins the provisioner's invite claim. The app must first show an explicit Join/Decline screen and collect user consent before writing MemberJoined or redeeming the credential (issue #67), so merely parsing a link is not enough to bind identity or burn the invite. That self-signed MemberJoined remains pending until the organiser's device queries GET /v1/groups/:id/invites/:invite_id and emits MemberAdmitted for the provisioner-recorded pubkey, so duplicate-join races are not decided by attacker-chosen (lamport, event_id). If the wrong person wins, the organiser sees the wrong pending/admitted pubkey and the intended invitee's own join attempt fails loudly. Recourse is the same as any unwanted holder of storage access: rotate the group's shared key (§4.8, two-phase rotate-key) to cut off further storage writes. This does not retroactively hide content already synced to that device before the organiser notices — no different from any other E2EE system once ciphertext has been decrypted by a holder of group_key. Key-epoch rotation and full member removal remain out of v1 scope (§9.2), so this is a mitigation, not a cure.
Invite link leaked before redemptionServer-enforced short TTL + single-use consumption, both provisioner-side (issue #17) — not merely the client-checked expiry field. An invite link leaked before anyone redeems it self-expires quickly (default 30 minutes) even if the client-side check is bypassed or skewed. A link redeemed by someone other than the intended invitee makes the real invitee's own completion call fail with a loud, visible error (not a silent no-op) — a detectable compromise. If the same claimant retries after a transient provisioner/Garage failure, completion is idempotent and returns the descriptor again rather than misreporting compromise (issue #69). At that point the organiser's recovery path is the existing two-phase key rotation (§4.8, issue #15): rotate the group's shared key so the credential the unintended redeemer obtained stops working.
Invite link intercepted in transitinvite_secret (s) and the ciphertext (p) travel in the same URL #fragment — so invite_secret does not protect against anything that captures the whole link (clipboard sync services, a chat app's cloud message backup, a compromised share-sheet target, etc.); at that point the attacker has both pieces needed to decrypt. Its only real value is against something that observes just part of the link (e.g. a proxy or log that captures the URL path but not the fragment, which browsers and OS deep-link handlers do not send to servers in the first place). Don't rely on invite_secret alone as a barrier against a fully-intercepted link — that is what the short TTL + single-use enforcement above is for.
Backend retains invite ciphertextNot applicable — the self-contained link stores no invite data on the backend. The provisioner does persist the invite credential's metadata (invite_id, access_key_id, timestamps) for TTL/single-use enforcement, but never the encrypted package, invite_secret, or group_key.
Expense edited by non-creatorClient-enforced auth rule: author_id must equal expense.created_by when permission is creator_only. Violating events rejected on receipt. (Unchanged.)
Group closed by non-organiserNot currently enforced beyond "any active member" (tracked by issue #65): _isAuthorized does not special-case GroupClosed, and the reducer applies it from any active author. The spec's intent is organiser-only (author_id === group.created_by, per §4.4 above and data-model.md), but the sink/reducer do not yet check it — until #65 lands, any active member can freeze the group.
Member removed but retains historic dataBy design: removed members retain data already synced. Future events use a fresh-random key epoch (§3.10) — the removed member has no path to the new key. Plus storage-capability rotation (above).
Clock skew / ordering issuesEvents ordered by the envelope's (lamport, event_id) with client-side dedup by event_idnever by any server clock. happened_at (device clock) is informational only. (Corrects the legacy "ordered by backend created_at" wording, which contradicted §3.9/§3.10.)
Backend unavailableApp is local-first. All existing data and local expense logging remain fully functional. Sync resumes automatically on reconnect.
Backend lost or account closedThe storage descriptor lives locally and in the key-backup bundle. Organiser emits StorageMigrated pointing to a new backend; members follow on next sync. Because every device holds a full replica, the log can be re-uploaded to the new backend.
Operator sees metadataThe Managed backend (FEN's Garage) sees ciphertext + pseudonymous pubkey file names, sizes, timestamps — never real identities or content. Privacy-sensitive groups will (v2, planned) be able to self-host their own S3-compatible endpoint so only they see even this. (Avoiding real-identity linkage is one reason Google Drive was rejected.) See §4.5.

4.4 Data Validation Rules (Client-Side, Applied on Every Event Received)

ALL EVENTS:
✓ event.group_id matches expected group
✓ author_signature valid against author's active-roster public_key
✓ author_id is an active participant in the group, except for the bootstrap
events that establish a pending join/admission path (below)

MEMBERSHIP EVENTS:
✓ GroupCreated establishes group.created_by as the organiser authority
✓ MemberJoined is self-certifying only: it records status="invited" and
must include invite_id, except for the organiser's founding self-join
✓ MemberAdmitted is valid only if author_id === group.created_by and its
(public_key, invite_id) matches an existing pending MemberJoined
✓ Active roster membership is granted by MemberAdmitted, never by
MemberJoined alone

EXPENSE EVENTS (ExpenseLogged, ExpenseUpdated):
✓ sum(splits[i].settlement_minor_units) === settlement_amount_minor_units
✓ settlement_currency_code === group.settlement_currency (from GroupCreated event)
✓ group.status === "open"
✓ All participant_ids in splits[] are active group members
✓ expense_date is a valid YYYY-MM-DD

ExpenseUpdated / ExpenseDeleted:
✓ group.expense_edit_permission === "any_member"
OR author_id === original_expense.created_by
✓ expense_id, group_id, created_by, created_at unchanged

GroupSettingsUpdated:
⚠ author_id === group.created_by (organiser only) — spec'd, NOT yet
enforced by the sink/reducer beyond "any active member" (issue #65)
✓ group.status === "open"

MemberRemoved:
⚠ author_id === group.created_by (organiser only) — spec'd, NOT yet
enforced by the sink/reducer beyond "any active member" (issue #65)
✓ removed member is not group.created_by

MemberLeft:
⚠ author_id === payload.member_id (self-only) — spec'd, NOT yet
enforced by the sink/reducer beyond "any active member" (issue #65)

GroupClosed:
⚠ author_id === group.created_by (organiser only) — spec'd, NOT yet
enforced by the sink/reducer beyond "any active member" (issue #65)
✓ group.status === "open"

→ Events failing any check: silently dropped + logged locally for debugging.
Never forwarded to other peers.

Resolved (issue #26): the ALL EVENTS and MEMBERSHIP EVENTS blocks above are now cryptographically enforced, not just modeled by the reducer. VerifyingEventSink (apps/fen/lib/core/data/verifying_event_sink.dart) is the only path a raw synced line can take before it reaches reduce(): event.group_id is checked against the pull target; author_signature is verified against the envelope's own claimed author_id (Ed25519, over the still-encrypted canonical envelope — decryption is only attempted afterward); and the claimed author's roster standing is resolved from the SAME state reduce() already tracks (organiser from GroupCreated, active members via MemberAdmitted) before the event is allowed through. Concretely: GroupCreated is trusted once, at genesis, and a second claimed GroupCreated for an already-established group is rejected as an impostor; MemberJoined's self-signature is verified but never grants activity on its own; MemberAdmitted is accepted only if its signature verifies AND its author is cryptographically the organiser (closing the exact gap PR #56 deferred to this issue — previously only the plaintext author_id field was compared); every other event type is accepted only from a pubkey that is currently active/admitted AND whose signature verifies. Failing events are dropped before reduce() ever sees them and reported via a typed EventRejection — part of the EventSink/ SyncManager contract itself (packages/fen_sync/lib/src/sync_manager.dart), not a bonus method only a test can reach: EventSink.ingest and SyncManager.pull both return List<EventRejection> rather than discarding it, so a caller cannot silently lose rejection detail — see apps/fen/test/integration/verifying_event_sink_test.dart for the tampered-signature, wrong-key, non-roster-author, not-yet-admitted-author, and forged-MemberAdmitted cases.

Production wiring: VerifyingEventSink is registered as the app's sole EventSink (eventSinkProvider) and driven by a real, reachable call site, GroupSyncService (apps/fen/lib/core/data/group_sync_service.dart), which builds a real S3Backend from the group's group_storage binding and push+pulls through StorageBackendSyncManager. It runs opportunistically from groupBalanceProvider — the same group-load point PR #56 wired MemberAdmissionService.reconcilePendingAdmissions into — so opening a group's balance view actually exercises verification against whatever's arrived from other devices. This is deliberately not a continuous background pull loop (periodic polling, connectivity-aware scheduling): that remains a separate, larger milestone. See apps/fen/test/integration/group_sync_service_test.dart for coverage of the real wiring end-to-end (via a fake backend, no live network needed) and the defensive no-op paths (no identity yet, group not yet bound to a backend).

Every event-producing flow now seals consistently: implementing this issue surfaced that join_invite_service.dart (MemberJoined) and add_expense_service.dart (ExpenseLogged, both split modes) each had their own private duplicate of the sealing logic, using a different HKDF content_key info string and a different AAD convention than event_envelope_codec.dart's sealEvent() — the helper create_group_service.dart and member_admission_service.dart already used. Left alone, this would have made those event types undecryptable by any single consistent receiver, regardless of which convention it picked — a real interop bug, not merely a style inconsistency, and one that had gone undetected because nothing decrypted synced events before this issue. Both flows now call the shared sealEvent(); a repo-wide grep for SignedEnvelope(/hkdf(/encrypt( construction confirms no other production event producer remains divergent. Regression tests run the real JoinInviteService/AddExpenseService code paths and feed their actual produced wire bytes into VerifyingEventSink on a separate database.

Honestly remaining, not addressed by #26:

  • The EXPENSE EVENTS content-validation block (split-sum invariant, settlement-currency match, participant-is-member, date format) is a different layer — data validation, not authorship — and is still unenforced (tracked separately, issue #25). A signature-valid, roster-valid ExpenseLogged with an unbalanced split still reaches state today.
  • ExpenseUpdated/ExpenseDeleted's finer-grained edit-permission rule (creator_only vs any_member) is not evaluated by the sink — it only requires the author to be a currently-active member, the same bar as any other non-membership event. permission_rules.dart remains fully stubbed (issue #31) and is out of this issue's scope.
  • GroupSettingsUpdated, GroupClosed, MemberRemoved, and self-only MemberLeft authorship are not fully enforced by the current sink/reducer beyond "any active member" — narrowing those roster/lifecycle mutations is tracked by implementation-review issue #65.
  • The very first GroupCreated a device ever sees for a brand-new group is an inherent bootstrap trust question the sink cannot resolve on its own: nothing cryptographically distinguishes the legitimate organiser's genesis event from another self-consistently-signed one arriving first, before either has been folded into any state. This is a narrower, structural version of the already-documented invite-trust bootstrap problem (§4.3), not a regression introduced here.
  • Decrypting a verified event's payload does not re-authenticate the additional-data fields aadBytes() computes (author_id, event_type, group_id, event_id, epoch) at the symmetric-encryption layer — sealEvent() (event_envelope_codec.dart) computes aadBytes() but has never actually passed it to the AEAD call. In practice this is not a live gap: those same fields are part of what the Ed25519 signature covers via canonicalBytes(), so tampering with any of them still invalidates the signature before decryption is attempted. The wire-format decision is now explicit: fmt = 1 has signature context binding only; adding AEAD AAD is a fmt = 2 change tracked by implementation-review issue #66.

4.5 Privacy Properties

  • Content blindness (both backends): The backend stores only ciphertext, signatures, and file metadata. It cannot determine expense amounts, balances, participant names, or any content.
  • Metadata exposure (Managed tier): the operator (FEN's Garage) sees ciphertext plus per-group bucket names, per-member member_pubkey file names, sizes and timestamps — pseudonymous, never real identities or content. Groups wanting even this hidden from FEN will (v2, planned) be able to self-host their own S3-compatible endpoint, where only their own server sees it.
  • No push, no push metadata: v1 has no remote push, so there is no notification-metadata leak channel at all.
  • Invite link privacy: the group_key, invite_secret, and storage descriptor travel exclusively in the encrypted package / #fragment of the invite URL, never transmitted to any server.
  • No analytics by default: No telemetry, no usage tracking, no third-party SDKs that phone home with user data.

4.6 Data Portability & Deletion

RightImplementation
ExportOne-tap JSON export of full event log (JSONL format). Always available, offline-capable.
Delete (local)User deletes app → all local data gone.
Delete (backend)Managed: the group's bucket is deleted via the provisioner (organiser-authenticated), cutting off all members. BYO self-hosted (v2, planned): the owner deletes the bucket directly on their own endpoint. Members keep their local replicas until they delete the app.
PortabilityEvent log is self-contained and documented. Any compatible app can import a JSONL export.

4.7 Key Backup, Recovery & Device Management

This section addresses the most critical practical gap in FEN's security model: what happens when a user loses their device, reinstalls the app, or wants to use FEN on a second phone.

Why this is uniquely important for FEN: FEN uses a local Ed25519 keypair as the user's identity. Unlike a username+password app where the server holds the account, FEN has no server that can reset credentials. Losing the keypair means:

  • Losing the ability to sign new events (the user is effectively ejected from all groups)
  • The user's historical events are still in the storage backend (encrypted, readable with group_key), but they can no longer write new ones
  • Other members must re-invite the user with a new keypair — they appear as a new member

Group data (group_key) is equally critical: losing it makes all past events permanently unreadable.

Identity model summary

member_privkey (Ed25519, 32 bytes)
→ member_pubkey (32 bytes)
→ Signs all group events (author attribution)
→ Storage write access is SEPARATE: per-group S3 key (not derived from this key)
→ Stored in: device secure enclave / Keychain (iOS) / Android Keystore

group_key (XChaCha20-Poly1305, 32 bytes, one per group)
→ Encrypts all event content for that group
→ Stored in: device Keychain / Keystore
→ Received via: invite package (self-contained link #fragment)

Both secrets must be preserved for full recovery. Losing member_privkey alone means re-joining as a new member. Losing group_key alone means historical events are unreadable even after re-joining.

v1 Recovery mechanisms

FEN offers three backup methods. Users choose at first launch (or later in Settings → Identity → Backup). All three can coexist.


The app silently exports an encrypted key bundle to the user's iCloud (iOS) or Google Drive (Android) private app container. No user action is required after opt-in.

Key bundle contents:

{
"version": 1,
"created_at": "2026-06-27T14:00:00Z",
"member_privkey_enc": "<XChaCha20-Poly1305(member_privkey, kek)>",
"groups": [
{
"group_id": "...",
"group_name_enc": "<XChaCha20-Poly1305(group_name, kek)>",
"group_key_enc": "<XChaCha20-Poly1305(group_key, kek)>",
"storage_descriptor_enc": "<XChaCha20-Poly1305(storage_descriptor, kek)>"
}
]
}

Key encryption key (KEK): Derived from the device's hardware-backed secret via HKDF(device_secret, salt="fen-backup-v1", info=user_pubkey_hex). On iOS this uses the Secure Enclave; on Android the Android Keystore. The KEK never leaves the device — the cloud bundle is unreadable without the original device or its biometric/passcode.

Restore path:

  1. User installs FEN on new device, taps "Restore from iCloud / Google Drive"
  2. App downloads the encrypted bundle
  3. User authenticates with biometric or passcode
  4. Bundle decrypted, keys restored
  5. App reads each group's backend (from the restored storage_descriptor) → history fully restored

Limitation: The KEK is tied to the device hardware. If both the device and the backup are lost simultaneously, recovery is impossible via this method alone. Users with high data sensitivity should also use Method B.


Method B — BIP-39 recovery phrase (power user option)

A 12-word BIP-39 mnemonic is shown to the user once during setup. It encodes the entropy used to derive member_privkey. group_key values are NOT derivable from the mnemonic alone — they must be re-received via invite.

Key derivation from mnemonic:

mnemonic (12 words, BIP-39)
→ seed (PBKDF2, 2048 rounds, BIP-39 standard)
→ SLIP-0010 master key (ed25519)
→ m/44'/1237'/0'/0'/0' (all-hardened; SLIP-0010 ed25519)
→ member_privkey (Ed25519)

Recovery path:

  1. User installs FEN on new device, taps "Enter recovery phrase"
  2. Enters 12 words
  3. member_privkey re-derived → identity restored
  4. User can see their public key and verify it matches what other members know
  5. App attempts to read all backends it has records of (from Method A bundle if available, otherwise unknown)
  6. For each group: if the backend still has the event log for that group, history is restored
  7. For groups whose backend no longer has the data: user must receive a new invite and starts fresh in that group (but member_pubkey is the same, so they are recognised as the same person)

group_key recovery: After restoring via mnemonic, the app has the keypair but not the group keys. Two paths:

  • If Method A bundle is also available → group keys restored from bundle
  • Otherwise: any group member can re-share the group key by sending a new invite link. The user's member_pubkey is preserved, so they keep their expense history attributions.

Mnemonic storage guidance (shown in app during setup):

⚠️ Write these 12 words on paper and store them somewhere safe.
Do not screenshot them. Do not store them in another app.
If you lose your phone AND this phrase, your identity cannot be recovered.

Method C — Device-to-device transfer (QR code)

For users switching phones while the old phone is still functional. No cloud involved.

Old device: Settings → Identity → Transfer to new device
→ Shows full-screen QR encoding:
{member_privkey_enc, group_keys_enc, storage_descriptors}
encrypted with an ephemeral ECDH key (displayed as a 6-digit PIN)

New device: Settings → Restore → Scan QR from olddevice
→ Scans QR
→ User enters 6-digit PIN from old device
→ Decrypts → all keys transferred
→ Old device: optionally shows "Transfer complete — this device is now secondary"

The 6-digit PIN prevents a bystander with a camera from stealing the keypair. The QR is only valid for 5 minutes.

Device revocation

FEN does not have a centralised revocation mechanism. If a phone is lost or stolen:

Short-term (any member can do this):

  • Group members can stop trusting events from the old member_pubkey by applying a social consensus within the group — in practice, the group organiser removes the compromised member and re-invites them with a new keypair.

Protocol-level (v2 — Key Epoch Rotation):

  • Full group key rotation: a new group_key is distributed to all remaining members, invalidating the compromised device's ability to decrypt future events. See §9.2 Key Epoch Rotation.

v1 interim guidance (shown in app):

Lost your phone?
1. Ask your group organiser to remove you and re-invite you.
2. On your new device, restore via recovery phrase or cloud backup.
3. Rejoin each group with the new invite link.
Your past expenses remain in the group history.

Multi-device support (two phones simultaneously)

v1: Not natively supported. A user's identity is their keypair — using the same keypair on two devices simultaneously is possible (the keys can be copied), but:

  • Both devices push events signed by the same key → backend accepts both (no conflict)
  • Outbound queues on both devices may push duplicate events → idempotency rules in §3.9 handle this
  • This is an advanced use case; no UX is built for it in v1

v2: Dedicated multi-device support. Each physical device gets its own sub-keypair derived from the master key (BIP-32 child keys), presented in the group roster with a device label. This enables per-device revocation without full group key rotation.

Settings UI surface

Settings → Identity
├── Your public key: npub1abc... [Copy] [Share]
├── Backup
│ ├── ☑ iCloud backup — last backed up 10 min ago
│ ├── ☐ Recovery phrase — [Set up]
│ └── Transfer to new device — [Show QR]
└── Danger zone
└── Delete identity — [Delete] (requires confirmation; irreversible)

4.8 Storage Decision — Security Review

The storage model is Managed Garage (S3, FEN-hosted) for v1; a Bring-your-own self-hosted S3 tier for power users is planned for v2, reusing the same adapter (full design: backend-architecture.md). WebDAV was considered as the BYO tier and dropped — rationale in §9.2 of appendices.md. Google Drive was evaluated and rejected: its multi-writer shared-folder read model needs a Google restricted scope, which forces an annual third-party CASA security assessment (cost + lead time) — incompatible with an indie project. This review challenges the chosen design rather than reassuring.

A. Does the encryption & key exchange still hold? — Verdict: yes, backend-independent

The cryptography was already transport-independent. Nothing about confidentiality or integrity depends on the backend; it only moves opaque bytes.

MechanismStill holds?Why
Content/attachment/roster encryption (group_key → HKDF → XChaCha20-Poly1305)✅ HoldsEncryption is on-device before upload; the backend sees only ciphertext. Identical on any backend.
Event signing (Ed25519 over the canonical event id)✅ HoldsAuthor attribution and forgery resistance are properties of the signature, not the transport.
Invite confidentiality (invite_secret in #fragment)✅ HoldsOnly the package contents changed (the endpoint field became a StorageDescriptor). The secret never leaves the URL fragment.
Key hierarchy & sub-key separation (HKDF domains)✅ HoldsUnchanged.
Key epochs / membership rotation✅ Holds at the crypto layerForward secrecy unchanged — but removal now also rotates the storage capability (§C-2).
Ordering & completeness ((lamport, event_id) + author_seq)✅ HoldsNever relied on a server sequence (§3.9/§3.10).
Key backup/recovery (Methods A/B/C)✅ HoldsBundle and QR also carry the StorageDescriptor so a restored device knows where each group lives.

Bottom line: confidentiality and integrity hold on every backend. The storage choice is an authorization-and-metadata matter, not a confidentiality one.

B. What the backend can and cannot see

  • Sees: ciphertext; per-member logs/<pubkey>.jsonl file names (pseudonymous pubkeys → group structure); sizes; timestamps; client IPs.
  • Cannot see: content, real identities, amounts, balances.
  • Because the Managed tier (Garage) is keyed by pseudonymous Ed25519 pubkeys, the operator never learns real-world identities — a concrete privacy improvement over the rejected Google Drive option, which would have tied the group's social graph to real Google accounts.

C. Security regressions & trade-offs (the honest part)

  1. A shared write credential exists. The per-group S3 key (Managed today; the same mechanism for the planned v2 BYO self-hosted tier) is a bearer capability: any member can read/write/delete that group's storage. This reverses the old "no shared secret" property. It is scoped to one group (one bucket), so blast radius is contained. Mitigations: Ed25519 signatures make forgery impossible; full local replicas make deletion recoverable; removal rotates the credential. A leaked invite link is a narrower case with its own dedicated mitigation (issue #17, §4.3): the invite carries a short-TTL, single-use credential distinct from this shared key, not the shared key itself.
  2. Access is coarse and mutable. Object stores allow overwrite and delete by any holder. A removed member holding the key can delete others' logs until it is rotated. → Invariant: removal rotates the storage capability, not just the key epoch.
  3. You now operate infrastructure (Managed tier). A breach of your Garage leaks only ciphertext + pseudonymous metadata, but availability and abuse are now your responsibility — per-group quotas, rate-limited provisioning, monitoring (backend-architecture.md §2.7).
  4. No push. Object stores (S3-compatible) cannot push without a server → poll-based sync, so settlement coordination is slower. Acceptable for an expense app; set the expectation in UX.
  5. Credential rotation leaves a bounded dual-credential window. The S3 key is shared by every member and the bucket is the only in-band channel for delivering its replacement — revoking the old key at rotation time would lock out the entire remaining group (design-review finding #1 / issue #15). Rotation is therefore two-phase (backend-architecture.md §6.1, data-model.md §3.10): the old key stays valid until every remaining member acks the new credential, or a hard deny_old_at deadline (default 7 days) fires server-side. During the window, a removed member still holding the old key can read — no confidentiality loss: post-removal events are encrypted under K(n+1), which they never receive, and pre-removal ciphertext they could already decrypt from their local replica; write — their events are rejected by every client (not in the epoch member list; signatures unforgeable); delete/overwrite — vandalism, recoverable from full local replicas (C-1). Net: the S3 key is an availability/integrity control and its revocation may lag; the confidentiality cut on removal is the key epoch and is immediate. For hostile removals the organiser can force immediate revocation ("revoke now"), at the cost of re-inviting members who had not yet switched.

D. Inconsistencies found & resolved

  1. Signing curve — RESOLVED: Ed25519 (RFC 8032), provided by sodium_libs; the bip340/secp256k1 dependency is dropped. BIP-39 recovery uses the SLIP-0010 ed25519 derivation (all-hardened). Applied in §4.1, §4.2, §9.1, and the data-model / key-management companion docs.
  2. Ordering is (lamport, event_id) everywhere — never a server clock (§3.9/§3.10).
  3. Google Drive rejected (CASA) — recorded here and in backend-architecture.md so the decision is not relitigated.

E. Invariants (storage model)

  1. Member removal rotates both the key epoch and the storage write capability (per-group S3 key). The epoch cut is immediate; the storage-credential cut is two-phase (E-5).
  2. The storage write capability is a bearer secret: it travels only encrypted — inside the epoch-encrypted StorageKeyRotated event on rotation, and to a new joiner via the provisioner's complete-invite exchange (issue #17) rather than directly inside the invite package — and is rotated on removal.
  3. A backend must pass probe() (list/get/put/delete) before a group is bound to it.
  4. A group is bound to one backend; mixed-backend groups are unsupported.
  5. A superseded storage credential is revoked only after the replacement has been published to remaining members (StorageKeyRotated, client-enforced ordering), and no later than its deny_old_at deadline, which the provisioner enforces without any client cooperation. Deferral is bounded; revocation is never skipped. On removal, StorageKeyRotated is published under the post-removal epoch, so the removed member can never read the replacement credential (C-5).

F. Open questions

  • Long-lived per-group S3 key (default — control plane out of the hot path) vs. short-lived pre-signed URLs (credentials off-device)?
  • Group-creation abuse gating: Ed25519 signature + rate-limit now, device attestation later?
  • Per-group quota value and attachment size cap?
  • Filename privacy: hash pubkeys into opaque log filenames to blunt social-graph leakage?