Skip to main content

FEN — Appendices & Future Iterations (§9)

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

9. Appendices & Future Iterations

9.1 Technology Stack

LayerTechnologyNotes
Mobile frameworkFlutter + RiverpodSingle codebase, iOS + Android
Local databaseSQLite via drift (Dart ORM)No CRDT library needed — append-only log is conflict-free
Cryptographysodium_libs (libsodium bindings; cross-platform)XChaCha20-Poly1305 (AEAD) + Ed25519 (signing) + X25519 (ECDH epoch-key wrapping), all from libsodium. The former bip340 / secp256k1 dependency is dropped (§4.8 decision).
Storage — Managed (default, v1)Garage (S3), FEN-hosted, via a Dart S3 client (SigV4)Per-group bucket + scoped key minted at group creation; no per-member account. See backend-architecture.md.
Storage — BYO self-hosted S3 (v2, planned)Any S3-compatible endpoint (user's own Garage, MinIO, …) via the same Dart S3 clientFull data sovereignty; user supplies the connection string. Invite carries the storage descriptor; no client code changes. WebDAV was considered and dropped — see §9.2.
Storage adapterStorageBackend interface (§2.7) with S3BackendAll product / ledger / crypto code is backend-agnostic.
Exchange ratesfrankfurter.app (free, open source)No API key required. Fallback: manual entry.
Push notificationsNone in v1 (FCM/APNs deferred)S3-compatible object-store backends cannot push without a server. Sync is poll-based.

9.2 Future Iterations

Key Epoch Rotation (member removal)

When a member is removed, all remaining members should generate a new group key and re-encrypt future events with it. The removed member retains decryption capability for past events (unavoidable without deleting their local copy) but cannot decrypt any new events. Implementation involves a KeyEpochRotated event type and storing an epoch_id on each event.

Bring-Your-Own S3 (Self-Hosted, v2)

Power users will be able to point their group at their own S3-compatible endpoint — their own Garage instance, MinIO, or any other S3-compatible object store — by pasting a connection string (endpoint, region, bucket, access_key_id, secret_access_key) instead of having FEN's provisioner mint one. Architecturally this is nearly free once the Managed tier's S3Backend/S3Descriptor ship (backend-architecture.md §2.3, §4): it is the exact same adapter and descriptor kind (kind: "s3"), just sourced from user input instead of the provisioner — no new adapter, no new StorageBackend implementation, no new descriptor kind. What's missing for v1 is only the UX to accept/validate a user-supplied connection string and skip the provisioner call; that is explicitly deferred to v2.

Why WebDAV was dropped instead (and why Nextcloud specifically might return later). WebDAV was the original planned BYO tier design. It was dropped because a BYO tier needs to be close to zero-config the same way the Managed tier is — creating a group must not require touching the storage provider by hand. Almost every WebDAV server (dufs, rclone serve webdav, Caddy, a NAS's built-in WebDAV, ownCloud) has no API to provision an isolated per-group share; the owner would have to log in and manually create/configure a share for every single group, which breaks one-tap group creation and doesn't scale past a handful of groups. Nextcloud is the one exception — its OCS Share API can create and manage public shares programmatically, so it's the only WebDAV-family server that could, in principle, support automated per-group provisioning the way Garage does. Even so, running a full Nextcloud instance (PHP app + database + cron + its own maintenance burden) is a heavy footprint just to get per-group file shares, next to a ~15 MB single-binary Garage container. So WebDAV — Nextcloud included — is out of the design for both v1 and the v2 BYO plan above. Nextcloud specifically may be reconsidered later as an additional power-user backend option (on top of, not instead of, BYO-S3) if there's real user demand for it, precisely because it is the only WebDAV server that wouldn't force manual per-group server-side work.

Group-to-Group Settlement

Allow a member to carry a balance from one closed group into a new one. Useful for recurring friend groups.

Receipt Attachment

Attach an encrypted receipt photo to an expense event. Storage uses the attachment storage protocol (BUD-01/02) — a content-addressed HTTPS blob store identified by sha256(ciphertext).

How it works:

  1. Client encrypts the photo with attachment_key + random nonce (XChaCha20-Poly1305) → ciphertext
  2. Client computes sha256(ciphertext)blob_hash
  3. Client uploads ciphertext to the user's configured attachment storage server via PUT /<blob_hash> with auth
  4. The expense event's encrypted payload references the blob by hash: {"receipt": {"sha256": "<hash>"}} — the reference lives inside the encrypted content
  5. Other members decrypt the event, fetch attachments/<sha256> from the group backend, and decrypt with attachment_key

Where attachments live: Receipt blobs are stored in the same group backend as events, under attachments/<sha256(ciphertext)> (see backend-architecture.md). They are encrypted with attachment_key before upload, so the backend sees only ciphertext, and any member fetches them with the group's backend credential — no separate attachment server.

Resilience: The event stores multiple URLs ("urls": [...]). If the primary URL 404s, the app falls back to secondary URLs and can trigger a re-upload. Group members who have fetched a receipt can re-upload it to a new server if the original disappears.

UX posture: Receipt photos are a power feature. If no attachment storage server is configured, the receipt camera icon is hidden and the feature is silently unavailable rather than broken. FEN works fully without it.

Recurring Groups

Support groups with no formal closure — e.g. flatmates tracking shared bills month after month. Requires periodic balance snapshots and archiving of old events to keep the log size manageable.

Web Companion

A read-only web view that runs entirely in the browser. The user pastes their export JSON and sees the group summary — no server involved, no upload.

Local P2P Sync (Deferred)

Local peer-to-peer sync via QR code, Bluetooth, or WiFi Direct was evaluated and deliberately deferred from v1. The backend handles the sync need for 99%+ of real-world scenarios (including offline logging with later reconnection). P2P would add significant platform-specific complexity: MultipeerConnectivity (iOS), Nearby Connections (Android), Bluetooth/local network permissions, QR scanner UI, and a local HTTP server — all for the edge case where a user has no mobile data at all and cannot wait a few minutes to sync. Revisit only if real users consistently report this scenario as blocking. If added, it should use the same encrypted event envelopes as backend sync so no new crypto surface is introduced.

Closed Group Archiving — Local Export

When a group closes, every member receives a permanent, self-contained local archive of all expenses and receipt images. The archive is independent of backend availability — once exported it can be read on any computer without FEN.


9.1 Platform Feasibility

Core principle: build the archive inside the app sandbox, then hand it to the OS share sheet. No special permissions are required on either platform.

iOS

MechanismAPINotes
Share sheet (recommended default)share_plusShare.shareXFiles()Zero extra permissions; iCloud Drive, AirDrop, Files app, and Mail all appear as destinations
Files app (browsable in-app folder)getApplicationDocumentsDirectory() + UIFileSharingEnabled = YES + LSSupportsOpeningDocumentsInPlace = YES in Info.plistArchive appears under “On My iPhone → FEN”

Android (API 29+)

MechanismAPINotes
Share sheetshare_plusSimplest; user picks destination (Downloads, Drive, SD card, etc.)
Downloads (no permission)media_store_plusInserts directly into public Downloads collection; no MANAGE_EXTERNAL_STORAGE needed
Folder picker (SAF)file_pickerACTION_CREATE_DOCUMENT or ACTION_OPEN_DOCUMENT_TREEUser picks any folder including SD card; covers all scoped-storage targets

Flutter package set:

path_provider: ^2.1.0 # sandbox temp directory
share_plus: ^7.2.0 # share sheet (iOS + Android)
archive: ^3.4.0 # pure-Dart ZIP, no native deps
file_picker: ^8.0.0 # optional SAF folder/SD-card picker
# media_store_plus if one-tap Downloads on Android is desired

9.2 Archive Format

Format: ZIP with a structured folder layout.

fen_group_vacation-paris_2026-06-27.zip
├── README.md ← human-readable; no app needed to open
├── manifest.json ← machine-readable metadata (see below)
├── members.json ← pubkeys + display names
├── events/
│ ├── events.json ← all expenses, decrypted, sorted by date
│ └── schema.json ← JSON Schema (self-documenting)
└── receipts/
├── {sha256}.jpg ← content-addressed; integrity verifiable with sha256sum
└── missing.json ← hashes that could not be fetched (see §9.4)

Why ZIP over SQLite:

  • Universally openable on any computer without special tooling
  • JSON files are readable in any text editor
  • Receipt filenames = attachment storage content hashes — integrity is verifiable after the fact with sha256sum
  • Compresses JSON 60–80 %; produces one shareable file
  • SQLite requires tooling; storing image BLOBs inside it is awkward

manifest.json — machine-readable:

{
"format": "fen-group-archive",
"format_version": "1.0",
"app_version": "2.1.0",
"export_timestamp_iso": "2026-06-27T14:32:00Z",
"group": {
"id": "<uuid>",
"name": "Summer Trip 2026",
"currency_default": "CHF",
"created_at_iso": "2026-05-01T10:00:00Z",
"closed_at_iso": "2026-06-27T14:30:00Z",
"member_count": 5,
"group_key_fingerprint_hex": "a3f9bc12..."
},
"storage": {
"kind": "s3",
"bucket": "grp_<group_id>"
},
"stats": {
"total_expenses": 142,
"total_receipts_referenced": 45,
"receipts_included": 38,
"receipts_missing": 7
},
"encryption": "none"
}

group_key_fingerprint_hex — first 16 bytes of SHA-256(group_key), hex-encoded. Lets a user identify the right key later without exposing it.

README.md — human-readable:

This archive was exported from FEN on 2026-06-27.
Group: Summer Trip 2026 (5 members, 142 expenses, CHF 3,840 total)

To view: unzip this file and open any .json file in a text editor,
or import it back into FEN.

Receipts are in the /receipts folder (named by content hash).
7 receipts were unavailable at export time (see receipts/missing.json).

9.3 Encryption

Default: plaintext ZIP. Optional: XChaCha20-Poly1305 wrapper — but NOT raw ZIP password encryption.

OptionDefault?ProCon
Plaintext ZIPOpenable on any computer, no key neededReadable by anyone with physical file access
Password-encrypted (user-chosen, Argon2 → XChaCha20-Poly1305)OffProtects financial data at restUser must remember password; archive is permanently locked if forgotten

Rationale for plaintext default:

  • iOS encrypts the file system at rest (Apple Data Protection); Android uses file-based encryption behind the lock screen
  • Cloud destinations (e.g. iCloud Drive) encrypt at rest, in transit and storage
  • The group_key is a transient session secret — if the user loses it, the archive is permanently unreadable; don’t couple archive longevity to the session secret

If encryption is enabled:

  • Derive the archive encryption key from a user-chosen password via Argon2id (not from group_key)
  • Wrap the entire ZIP bytes in XChaCha20-Poly1305; store the wrapped file as .fen.enc
  • Do not use ZIP internal password encryption (typically AES-128, weak, implementation-inconsistent)
  • Include a plaintext .key-instructions.txt alongside explaining how to decrypt
  • Store "encryption": "argon2id+aes-256-gcm" in manifest.json (inside the envelope, decrypted on open)

9.4 Receipt Fetching

Receipt images must be eagerly fetched at export time — do not assume they are cached.

Fetch strategy — parallel with bounded concurrency:

Future<ReceiptResult> fetchReceipt(String hash, List<String> servers) async {
// 1. Check local cache first
final cached = await cache.getFileFromCache('attachment:$hash');
if (cached?.file != null) return ReceiptResult.hit(cached!.file);

// 2. Race all attachment storage servers — first response wins
final response = await Future.any(
servers.map((url) => http.get(Uri.parse('$url/$hash'))
.timeout(const Duration(seconds: 10))),
);

if (response.statusCode == 200) {
// 3. Verify content hash before trusting
final actual = sha256.convert(response.bodyBytes).toString();
if (actual != hash) throw ArchiveIntegrityError('Hash mismatch: $hash');
return ReceiptResult.fetched(response.bodyBytes);
}
return ReceiptResult.missing(hash, servers);
}

// Process in batches of 4 to avoid hammering servers
const concurrency = 4;
for (var i = 0; i < hashes.length; i += concurrency) {
final batch = hashes.sublist(i, min(i + concurrency, hashes.length));
results.addAll(await Future.wait(batch.map(fetchReceipt)));
onProgress(i + batch.length, hashes.length);
}

For receipts that cannot be fetched:

  • Write receipts/missing.json:
    [
    {
    "sha256": "abc123...",
    "referenced_by": "expense-042",
    "description": "Dinner receipt",
    "path": "attachments/abc123...",
    "error": "HTTP 404"
    }
    ]
  • Never silently omit a missing receipt — the user must know the archive is incomplete
  • Set a 5-minute total timeout; show a Cancel button
  • At completion, display a summary: “38/45 receipts included — 7 unavailable” with a “View Missing” link

Decrypt each receipt locally (using the appropriate sub-key) before writing to the ZIP (plaintext archive) or re-encrypt with the archive key (encrypted archive).


9.5 UX Flow
User taps "Close Group" (or balances reach zero and app offers to close)

[■ Dialog]
┌──────────────────────────────────────────┐
│ This group has 142 expenses and │
│ 45 receipt images (~18 MB est.) │
│ │
│ Export an archive before closing? │
│ [Export & Close] [Close Without] [×] │
└──────────────────────────────────────────┘
↓ "Export & Close"
[■ Progress] Fetching receipts (12 / 45)… [Cancel]
↓ Done
[■ Summary]
✓ Archive saved
• 142 expenses exported
• 38 / 45 receipts included
⚠ 7 receipts were unavailable [View Missing]
[Share Archive] [Close Group]
↓ "Share Archive"
■ Platform share sheet (iOS) / save dialog (Android)
↓ "Close Group"
Group enters Closed state; all events frozen

If user taps “Close Without”:

  • Group enters a soft-close state: fully accessible in a “Closed groups” view for 30 days
  • A dismissible banner on the group: “No archive saved — [Export Archive]”
  • After 30 days: permanent close with a final export prompt

Manual export (always available):

  • Group Settings → “Export Archive” — identical flow, without the close step
  • Works for both open and soft-closed groups

Do not auto-export silently — the user must consent to where financial data is saved.


9.3 Open Questions

QuestionNotes
What happens if the organiser loses their device before closing the group?Consider: any member can propose closure, organiser must confirm. Or: after 90 days of inactivity, any member can close.
Sync notification strategy (resolved for v1)v1: poll on app-open, foreground-resume, network-reconnect, and a 30 s foreground timer. OS background-fetch every 15–30 min. No APNs/FCM. Backend signal is an opaque cursor only — no content metadata. v2: SSE stream (GET /stream) emitting id:{cursor}\ndata:{}\n\n; client runs the identical poll-then-apply loop on SSE trigger. Design the M4 sync loop as a single syncGroup() function so the v2 SSE trigger is a drop-in replacement for the timer. See §5.5 for the full strategy.
How are push notification device tokens registered and rotated?Deferred to v2+. APNs/FCM explicitly out of v1 scope. If added, treat push as a hint only — never as a delivery guarantee. Token registration, refresh, and expiry handling adds real infrastructure complexity.
Should exchange rates be set per-expense or per-group-per-currency?Current design: per-group-per-currency (one agreed rate for all CHF expenses). Per-expense rates are more accurate but more friction.
What is the invite single-use enforcement mechanism?Resolved 2026-06-29, superseded 2026-07-05: originally best-effort client-side only (if multiple MemberJoined events shared an invite_id, clients accepted the one sorting first by (lamport, event_id)), with backend enforcement deferred. Issue #17 added the actual backend enforcement: the provisioner denies the invite credential the moment the first complete-invite call succeeds, so a losing racer's own call fails loudly instead of relying solely on the client-side tie-break. See §2.4/§4.3 of fen.md/security.md and design-review-findings.md finding #2.
Maximum group size?Design is sound for 2–20 members. Above that, event fan-out volume should be monitored.
Catching Name?Find a catching name, possibly from another language (Japanese, French). In the best case it involves a play of words.
Security & threat model reviewThe current threat model (§4.3) covers the primary attack surface but has not been subjected to a formal security review. Before public launch, commission or conduct a structured threat analysis (e.g. STRIDE or PASTA) covering: backend impersonation, invite link interception, event log tampering, key material exposure on device loss, and backend-side metadata inference. Update §4.3 with findings.
Backend housekeeping policyResolved in §9.2 Closed Group Archiving (§9.6). Default 90-day grace period; backend retains a lightweight tombstone (group_id + closed_at + closed_by_pubkey) indefinitely; explicit deletion by organiser as alternative to auto-purge.
Friends list multi-device syncDeferred to a future version. v1 stores friends only in a device-local table used for display names, invite resolution, and known-friend direct-add. Sync can be designed after that local model exists.
Known-friend direct-add relay selectionResolved: when adding a confirmed friend by public key, relay the wrapped invite through the newest-created eligible active shared group. Closed groups, cold/local-only archives, groups scheduled for deletion, and purged groups are ineligible. If no eligible active shared group exists, fall back to the normal invite-link flow.

9.4 Glossary

TermDefinition
Event logThe append-only sequence of GroupEvent records. The ground truth. The only data synced.
Materialised stateLocal SQLite tables derived by replaying the event log. Never synced.
Storage backendThe owner-controlled store that holds a group's encrypted log files. Blind to content — sees only ciphertext + file metadata. Managed (v1): FEN-hosted Garage (S3); BYO self-hosted S3 (v2, planned): the user's own S3-compatible endpoint (§2.7).
Group keyA 32-byte symmetric key used to encrypt all event data for a group. Shared only with members.
Storage write capabilityThe backend-native, non-cryptographic credential granting write access: a per-group S3 key (Managed/Garage today; same mechanism for the planned v2 BYO self-hosted S3 tier). A bearer secret — rotated on member removal via the two-phase hand-off (StorageKeyRotated/StorageKeyAck, deferred-deny; §3.10, §4.8, backend-architecture.md §6.1).
Invite secretA one-time 32-byte key that decrypts the invite package. Travels only in the URL #fragment.
Invite packageAn encrypted blob containing {group_key, storage_descriptor, group_id, member_pubkeys, expiry}, encrypted with the one-time invite_secret. Travels entirely in the deep link #fragment — never stored on any server.
StorageDescriptorThe pointer to a group's backend carried in the invite and key-backup bundle: {kind:"s3", endpoint, bucket, access_key_id, secret_access_key} — the same kind serves both the Managed tier (v1) and the planned BYO self-hosted tier (v2), differing only in who supplies the values (§2.4).
ETag / file cursorA per-file revision tag used to detect which logs/<pubkey>.jsonl changed since last sync. A private pull cursor — never a global order (that is the Lamport clock). Replaces the old server_sequence.
Settlement currencyThe currency chosen by the group for final balance calculation. Each expense's original currency is preserved; conversion uses agreed exchange rates.
OutboxLocal table of events not yet successfully written to the backend. Flushed on next connectivity.
Debt simplificationAlgorithm that reduces N pairwise IOUs to at most N-1 settlement transactions.
StorageMigrated eventA signed organiser-only group event instructing members to switch to a new StorageDescriptor. Written to both old and new backends while the old one is still readable; carries effective_after and a monotonic epoch to prevent replay (§3.3).
StorageKeyRotated eventA signed organiser-only group event distributing a rotated per-group S3 credential (same endpoint/bucket, fresh keys). Encrypted under the post-removal epoch so a removed member cannot read it; published while the old credential can still fetch it; carries rotation_id and the provisioner's deny_old_at revocation deadline (§3.10).
StorageKeyAck eventA member's confirmation — written with the new credential — that it has switched after a StorageKeyRotated. Once every current-epoch member has acked, the organiser asks the provisioner to revoke the old credential early (§3.10).
Two-tier storage modelManaged (default, v1): FEN-hosted Garage (S3) with a per-group bucket + key, no per-member account. BYO self-hosted S3 (v2, planned): the user's own S3-compatible endpoint, same descriptor/adapter. Both store only ciphertext (§2.7).
expense_edit_permissionA group-level setting (creator_only or any_member) that governs who may edit or delete expenses. Set by the organiser at group creation via GroupCreated; changeable afterwards via GroupSettingsUpdated. Default: creator_only.
GroupSettingsUpdated eventAn organiser-only event that changes group-level settings (currently expense_edit_permission) without closing or re-creating the group.
Solo expenseAn expense where splits contains exactly one entry and that entry’s participant_id equals paid_by. Represents a personal cost the member wants to track as part of their total trip spend without sharing the cost with others. Contributes zero to inter-member balances; fully included in Total Cost by Member.
InvolvementA member M is "involved" in an expense if paid_by === M or splits.some(s => s.participant_id === M). Derived locally; never stored. Used to drive the default expense list filter.
"Show expenses without involvement" filterA toggle in the group expense list (parallel to "Show deleted") that reveals expenses the current user is not involved in. Non-involved expenses are shown at reduced contrast (grey) with a label indicating no balance impact.
Total Cost by MemberA report (available in the Reports section) showing each member’s gross spend: Σ(split.amount_cents for participant === M) across all active expenses, converted to the settlement currency. Includes solo expenses; excludes deleted expenses and settlement transactions. Distinct from the net Balance (which reflects who owes whom after group payments).
Soft delete / tombstoneAn ExpenseDeleted event that marks an expense as deleted without removing any data from the event log. The original ExpenseLogged event is preserved permanently. is_deleted = true is a derived flag on the local materialised expense record.
group_ui_prefsA local-only SQLite table (never synced) storing per-group display preferences: show_deleted and show_without_involvement. Both default to false and persist across app restarts independently per group.
show_deleted toggleA per-group UI preference that reveals soft-deleted expenses in the expense list. Deleted expenses appear at reduced contrast (grey) with a strikethrough and “Deleted” badge; they are excluded from all calculations regardless of this toggle.