Skip to content
Family Memories

End-to-end encryption — design (white paper)

Status: 8 November 2026 · version 1.11

This white paper is also available in German: Deutsche Fassung Where the two versions differ, the German text applies.

public description of the scheme (German and English) and basis of the

1. The goal in one sentence

A family's content and its content metadata are encrypted and decrypted only on the family's devices. The keys never leave these devices unencrypted. The service stores, distributes and synchronises, but cannot read any of it, not even if it wanted to. Exceptions exist only where a person explicitly consents (§9).

This applies to every family from the moment it is created (§12.2), and the families from before the switch have been fully migrated (§12.1). The only exception is the public example family: it is deliberately stored in plaintext so that anyone can look at it (§4.7).

1.1 What changes compared with the earlier scheme

Earlier schemeE2E
Who generates keysthe browser (v1), on rotation the Edge Functionalways a device of the family
Who can unwrap keysthe service with MEDIA_KEK, at any timeonly members' devices
Typed text, dates, places, tags, titlesplaintext (places with a server key)encrypted
Access after a lost passwordthe service hands out the key againown device, passkey, recovery code or the family (§6)
Shared download linksthe service decrypts for the recipientthe key is in the link fragment, the service delivers only ciphertext (§9.1)
MEDIA_KEKexistsdestroyed (17 Oct 2026, §12.3)

1.2 Non-goals

  • Not zero-knowledge. The service still sees: who is in which family, roles, e-mail addresses, profile names, timestamps, sizes, number of objects, media types and access patterns (full list in §10.4).
  • No protection against a compromised device. Whoever has a member's unlocked device sees what that member sees.
  • No retroactive revocation. Whoever leaves the family keeps access to what they could already decrypt. They do not get anything new.
  • No availability guarantee against the service. The service can withhold or delete data. It cannot alter it unnoticed (§4.3).

2. Threat model

AttackerCapabilityGoalCovered by
A1 Data thiefcopy of the database, storage or backupsread contentAll content is ciphertext; without a device there are no keys (§3). Old backups from before the switch are deleted or expire (§12.3)
A2 Curious operator (passive)full read access to all servers, logs and functionsread contentas A1; no plaintext in routes, logs or error reports (§8)
A3 Malicious operator (active, via the data)can forge, reassign or replay rows, create members, swap envelopes and public keys, set switchesinject its own key, falsify content, force plaintextsigned envelopes (§4.2), trust store (§5.4), human confirmation of every new or changed key (§5.3, §6.2), pairing with a verification code (§5.2), bound and signed rows with a revision state (§4.3), no downgrade to plaintext (§4.7). Residual risk see §10.3
A4 Malicious operator (active, via the code)serves different JavaScriptsteal keysnot covered on the web, stated openly (§10.1). The Android app ships its own code (§10.2)
A5 Former memberknows old family keys, possibly a backupread new contentrotation on leaving (§7), new tag indexes (§4.5)
A6 Link recipienthas a download linksee more than was shareda separate key per link; only the selection is re-encrypted with it (§9.1)
A7 Third party (DeepL, Gelato, render service, Mistral)receives plaintext with consentmore than was sharedplaintext only after consent, only what is needed, deletion periods (§9)
A8 NetworkTLS attacker—TLS + HSTS; on top of that the content is ciphertext anyway
A9 Account thiefknows the password or has a session, but no deviceread content, take over the accountThe session alone opens no key. Replacing an account key is only possible after a fresh sign-in, with an e-mail to the person and a 24 h waiting period (§6.2). Replacing the recovery code is only possible after a fresh sign-in and with an e-mail (§6.3). Whoever knows the password can sign in freshly. The e-mail and the waiting period make the attempt visible and allow it to be cancelled

3. Key hierarchy

                    ┌───────── recovery code ──(Argon2id)──┐
                    │                                      ▼
 device ── DSK (non-extractable) ──wraps──▶  account key AK  ◀──wraps── passkey PRF
                                             (X25519 + Ed25519, per person)
                                                      │  HPKE envelope, signed
                                                      ▼
                         ┌────────────────────────────┼─────────────────────────┐
                         ▼                            ▼                         ▼
              family key FMK_v             story key SK_v               profile picture key PAK_v
              (per family, versioned)      (per shared story)           (per person, versioned)
                         │                            │                         │
            rows, media (K_obj), tags (TIK),   content of the shared       profile picture
            family picture/logo, translations  story
KeyTypeLives whereGenerated by
DSK (device storage key)AES-256-GCM, extractable: falseper device and person: web as a CryptoKey in IndexedDB fm-e2e (key dsk:<user>); Android in the Keystore (alias fm-e2e-dsk2-<user>, usable only while the device is unlocked, StrongBox where available; keys of older apps under fm-e2e-dsk-<user> move on the next start and are deleted)the device, on first wrap
AK (account key)X25519 pair (wrapping) + Ed25519 pair (signing)private: only wrapped (DSK on the device; passkey PRF or recovery code in ak_backups); public: user_identity_keys with a self-signaturethe person's first device
FMK_v32 random byteson the server only as an envelope per authorised personowner's device (creation, rotation)
SK_v32 random bytesan envelope per participantthe author's device
PAK_v32 random bytesan envelope per person allowed to see the picturethe person's device
LK (link key)32 random bytesonly in the URL fragment #k=the sharing person's device
TIK_v (tag index)HMAC-SHA256 key, HKDF from FMK_vnever storedderived
K_obj (per media file)AES-256-GCM, HKDF from FMK_v/SK_vnever storedderived (§4.3)

AK format: 0x01 ‖ x25519_priv ‖ x25519_pub ‖ ed25519_seed ‖ ed25519_pub (129 bytes). When unwrapping, the device checks that the private and public parts match. Wrapping uses AES-256-GCM with AAD = enc("fm/ak-wrap/v1", kind, user_id, identifier). The kind is device, recovery or passkey. The identifier is the identity id for a device, the credential id for a passkey, and empty for the recovery code.

Key separation. Separate subkeys are derived from FMK_v/SK_v (or PAK_v) with HKDF: rows (fm/row-key/v2), tag index (§4.5), media base (§4.3) and pictures with mediaSeal (fm/media-seal/v2). In format v2 (§4.3) the raw key itself no longer encrypts anything. All devices read v2; until the switch (§4.3) is on, they still write v1, where FMK_v is at the same time the AES key for rows and pictures and the input for TIK and the media base. As far as is known today this is not exploitable, but it contradicts the rule "one key, one purpose".

Why per person and not per device: the family wraps once per person. A new device of one's own only needs the AK from one's own device, from the passkey or from the recovery code, and does not touch the family. The security code stays the same across device changes. A rotation costs O(members) instead of O(devices).


4. Cryptographic building blocks

4.1 Parameters

PurposeSchemeImplementation
SymmetricAES-256-GCM, 96-bit IV from crypto.getRandomValues, 128-bit tagWebCrypto
Wrapping to peopleHPKE (RFC 9180), base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, AES-256-GCM@hpke/core 1.9, @hpke/dhkem-x25519 1.8
Signatures, X25519 of the AKEd25519 (verified strictly according to RFC 8032: no non-canonical encodings, no small-order keys — not the library's ZIP-215 default) or X25519@noble/curves 2.4 (@hpke/dhkem-x25519 internally ships its own copy of the noble X25519 implementation)
DerivationHKDF-SHA256WebCrypto
Recovery codeArgon2id, m = 64 MiB, t = 3, p = 1, 16-byte salt, 32-byte outputhash-wasm
PasskeyWebAuthn extension PRF, its own 32-byte salt per passkeynavigator.credentials
Blind indexHMAC-SHA256, truncated to 128 bitsWebCrypto

Encoding. Every sequence of fields that goes into an AAD, an HKDF info, an HPKE info or a signature is encoded with enc(…): per field a 32-bit length (big endian) and the bytes; strings as UTF-8, numbers as u32 big endian. Every sequence starts with its own separator fm/<purpose>/v<n>. This makes every encoding unambiguous, and no value from one purpose fits into another. The only exception is the HKDF salt of the download links ("fm/share/v1" as raw UTF-8, §9.1). The row id of the translation cache joins table, id, column and language with | into one field. Because of the fixed values and the UUID this is unambiguous; in format v2 the four parts are additionally bound individually with enc(…) as references (§4.3), and the joined id then only names the entry.

The curves run on @noble/curves instead of WebCrypto because older systems (iOS < 17) do not have X25519/Ed25519 in WebCrypto. The private AK therefore sits as bytes in the page's memory once unlocked (§10.7).

4.2 Envelope (key_envelopes)

row:    scope ("family" | "story" | "avatar"), scope_id, version,
        recipient (user_id), recipient_identity, enc (32 B), ct (48 B),
        sender (user_id), sender_identity, sig (64 B)

HPKE info = enc("fm/env/v1", scope, scope_id, version, recipient, recipient_X25519_pub)
sig       = Ed25519(AK_sender, enc("fm/env-sig/v1", scope, scope_id, version, recipient,
                                   recipient_X25519_pub, sender, enc, ct))

The recipient accepts an envelope only if

  1. it is itself the recipient and the HPKE open succeeds with its current AK,
  2. the signature is valid under the sender's current AK from its own trust store (§5.4). An earlier, replaced AK of the sender (after a rescue, §6.2) only counts for versions older than the current one: what the person wrapped back then stays readable. But whoever stole their old device cannot, even together with the service, slip in a key for the current or a newer version.
  3. for a profile picture, the sender is the person themselves.

Several envelopes, one commitment. Per recipient and version, every authorised sender may store its own envelope (the primary key contains sender_identity); nobody occupies another's slot any more. Whoever creates a version publishes its commitment once (e2e_set_key_commit, only the creator, only once):

commit     = SHA-256(enc("fm/key-commit/v1", scope, scope_id, version, key))
commit_sig = Ed25519(AK_creator, enc("fm/key-commit-sig/v1", scope, scope_id, version, commit))

The device opens all envelopes of a version. If the version has a commitment, it only takes a key that matches it; if it knows the creator, the creator's signature must be valid, otherwise the older rule applies. A member who wraps a different key for a newcomer achieves nothing by it. Versions created before 20 Oct 2026 have no commitment: if there are two different keys there, only the one from the creator's envelope counts, otherwise none. For its own versions without a commitment (creation, legacy content) the creator's device adds it. After opening, the device stores a receipt (key_receipts, only for itself and only for a version for which it has an envelope). Waiting lists and the completion of a rotation are based on it (§5.3, §7), no longer on the mere existence of some envelope (result of the internal review).

Who may wrap at all is enforced by the database: for families the owner and members, not family friends; for stories only the author, as long as they belong to the family as owner or member — otherwise the owner; for the profile picture only the person themselves. In addition, only someone who created a version or received it themselves may wrap it. This protects against people in the family who overstep their role. Against the service itself, a role check on the recipient's side does not help, because the roles come from the service. Trust store and human confirmation protect against that.

recipient_identity and sender_identity serve assignment and the access rule. They are not signed; instead, the signature binds the public keys themselves.

First contact. A device that holds no key at all for a scope yet (new member) does not know the senders yet. It opens all validly signed envelopes (the commitment decides which key applies) and adds the senders to its trust store (TOFU). This residual risk is described in §10.3.

4.3 Encrypted rows (enc column)

Every content row has a column enc bytea and enc_key_version int. It holds a JSON object with all content fields.

Format v1 (built):

AAD  = enc("fm/row/v1", 1, table, row_id, scope_id, enc_key_version)
pt   = u32(len(json)) ‖ json ‖ 0x00…   (padded, §4.4)
enc  = 0x01 ‖ iv(12) ‖ AES-GCM(FMK_v or SK_v, pt, AAD)

scope_id is the family or, for shared stories, the story. The client generates the row_id (UUID) before encrypting. The AAD binds the ciphertext to table, row, scope and version: a caption cannot be attached to another photo or moved into another family.

Format v2 (reading and writing built, switched on 14 Oct 2026): v1 binds neither the references nor the author, and it has no revision state. A malicious service could therefore attach a comment to another photo, move a chapter into another story, or replay an older version. A member could write text in someone else's name. v2 closes this:

K_row = HKDF-SHA256(FMK_v or SK_v, salt = "", info = enc("fm/row-key/v2"))
AAD   = enc("fm/row/v2", table, row_id, scope_id, enc_key_version, enc(references…),
            signer key id, rev, flags)
pt    = padded(JSON {a: author, f: fields})
enc   = 0x02 ‖ signer key id(16) ‖ u32(rev) ‖ flags(u8) ‖ iv(12) ‖ AES-GCM(K_row, pt, AAD) ‖ sig(64)
sig   = Ed25519(signer, enc("fm/row-sig/v2", AAD, signer user_id, iv ‖ ct))

The AAD contains only what the reader knows without looking anything up: the row and the header of the ciphertext. The author is stored encrypted in the content and compared with the column after opening; the person behind the key id is in the signature. This way a row still opens with the family key even when its author's account has been deleted — the database then clears the author column, and the account key disappears with the account. The row is then not displayed (the author no longer matches the column), but the owner can take it over as "legacy" instead of losing it.

  • Key separation: in v2 the raw family or story key no longer encrypts anything itself. Rows, family picture/logo/profile picture (mediaSeal, below) and tag index (§4.5) each have their own HKDF subkey.
  • References and author: per table, the AAD contains the columns that locate a row — target of a comment, story and position of a chapter, album/story/chapter of a photo, is_collaborative of a story, name_idx of a tag, source and target language of a translation. The reader takes these values from the same row the service sends; if one of them has been changed, the row does not open. The author in the content must match the column, otherwise the row counts as unverified. If someone moves a chapter, it is resealed. albums.cover_photo_id is not bound (§10.8).
  • Signature and who may sign: every version is signed with the Ed25519 part of the AK. The reader resolves the key via the trust store (§5.4) and checks whether this person may sign the row: for stories, chapters, photos, albums and comments the author or the owner (in a shared story also its author), for tags every member, for translations only the person themselves (everyone has their own entry), for the family row only the current owner (it binds no author: after a transfer of ownership, the new owner's device reseals it). An earlier key of a person only counts for rows of an older key version.
  • Revision: every device remembers the highest revision it has seen per row. A lower one, or a v1 version of a row it has already seen as v2, counts as replayed.
  • What the reader shows: if the signature, the signer rule or the revision fails, the app shows the placeholder "Authenticity not confirmed" — never the text.
  • "v2 only": as soon as the family row signed by the owner (or the row of a shared story) carries the flag "v2 only", v1 rows of this scope no longer count on the device — permanently, even if the service later delivers an older header row without the flag.
  • Translations: the row id of a cache entry is source table|source id|column|target language, so it is unique per entry (for the revision); the four parts are additionally bound as references in an unambiguous encoding. An entry that fails verification counts as absent and is translated again. Each person reads only entries they signed themselves: otherwise a member could store an invented "translation" of grandma's story that others would read as grandma's text (result of the internal review). The database therefore keeps one entry per writer (written_by).
  • Links (tags on photos, stories and albums, timeline links) have no content. Whoever creates one signs it: sig = Ed25519(person, enc("fm/link/v2", table, enc(columns…), person, key id)) in the columns sig and sig_identity. Readers discard links with an invalid signature or from people who may not write in the family; unsigned ones count only as long as the scope is not "v2 only".
  • Order: first all devices read v2 (web and app 1.0.8); until then v1 is written. Then a global switch in the database (e2e_settings.rows_v2_since) moves all writers to v2 — read at runtime, because the app fixes environment values at build time. A device that has seen it on once stays on and remembers the earliest time: the service cannot turn anyone back to v1 and cannot push the deadline below further out. It can bring it forward: if it reports a time far in the past, the owner takes over other people's v1 rows immediately instead of after 14 days as "legacy" (§10.9). No content is lost, and the author's device replaces the flag with their own signature as soon as it checks in. Even without the switch, a device writes v2 as soon as the scope is "v2 only".
  • Resealing: after the switch, every device works through what is still open in the background. It reads what needs doing each time from the unencrypted header of the ciphertexts (format, key id, flag) without opening them; an interrupted run continues the next time the app is opened. Every person reseals their own old rows, as well as their own rows flagged "legacy" or under an earlier key of their own (after a rescue). The owner (in a shared story its author) immediately takes over the family row, tags, family picture and logo and the rows of deleted accounts; 14 days after the switch also all other people's remaining v1 rows, each with the flag "legacy" (flags bit 0) and keeping the author. If the author checks in later, their device replaces this with their own signature. Only the owner may set the flag. Timeline links are signed by their creator; tag links and orphaned ones by the owner. The client changes comments and timeline links only through two narrow functions.
  • Setting "v2 only": once no plaintext or v1 row and no unverified link is left in the scope, the owner's device sets the flag in the family row (or the row of the shared story) itself. Translations are not rewritten: old entries then count as absent and are translated again on the next request and stored in v2.
TableFields in encstays visible
storiestitle, text, transcript, dateid, family_id, author_id, created_at, is_collaborative, path of the recording
story_chapterstitle, text, transcript, dateid, story_id, author_id, position, created_at
photoscaption, date taken, duration, place (coordinates and place name)id, family_id, uploaded_by, created_at, media type (photo/video), paths, sizes, references
albumstitle, dateid, family_id, cover_photo_id
tagsnameid, family_id, name_idx (blind index, §4.5)
commentstextid, target, author_id, created_at
familiesname, motto, accent colourid, owner, plan, quota, paths of family picture and logo
content_translations{sourceHash, text}, always under FMK (v2: K_row from FMK)family_id, source table, source id, column, target language, provider, time
photo_tags, story_tags, album_tags, timeline_links— (pure links)everything; signed in sig/sig_identity (from the switch on)

Profile names (profiles.name) stay plaintext (§10.4). The database limits the size of enc per table (stories and chapters 512 KiB, comments 32 KiB, photos 16 KiB, albums and family 8 KiB, tags 4 KiB, translations 512 KiB).

Media. Every file has its own key, derived from the family or story key of version N:

K_obj = HKDF-SHA256(FMK_N or SK_N, salt = "",
                    info = enc("fm/media-object/v1", scope, scope id, path, N, row id))

The row id is the photo_id, or the story or chapter of an audio recording. If a file is moved to another path, another row, another family or another version, it opens with a different key, and GCM rejects it. The version is in the path (….e2e.v<N>.enc). The reader derives the key from exactly the path it loads. K_obj is a non-extractable WebCrypto key. Formats:

  • Photo, thumbnail, video poster: iv(12) ‖ AES-GCM(K_obj, file). The binding comes solely from K_obj. A full-resolution original is not stored in E2E families.
  • Video (FMV2): plaintext header: "FMV2", version (u8), stream id (16 random bytes), block size (u32, 1 MiB), block count (u32), total length (u64), MIME length (u16), MIME. Then per block iv(12) ‖ AES-GCM(K_obj, block, AAD = header ‖ u32(block index)). This authenticates the header in every block. Blocks can be neither reordered nor truncated. The service worker decrypts with the same key, which it receives via postMessage as a non-extractable CryptoKey. An unknown version byte is rejected when reading.
  • Audio: iv(12) ‖ AES-GCM(K_obj, u16(MIME length) ‖ MIME ‖ audio).
  • Family picture, logo, profile picture: mediaSeal with AAD = enc("fm/media/v1", scope, scope id, path, version). Family picture and logo are under FMK (<family>/branding/<kind>.<8 hex>.e2e.v<N>.enc), the profile picture under PAK (<user>/avatar.<8 hex>.e2e.v<N>.enc). In format v2 the name carries .s2 before .e2e.v<N>.enc; sealing then uses HKDF-SHA256(FMK_N or PAK_N, salt = "", info = enc("fm/media-seal/v2")) and AAD = enc("fm/media/v2", …) as above. The reader chooses key and format by the path it loads; a picture under .s2 never opens with the raw key.

Binding to the signed row (result of the internal review). K_obj alone is not enough: whoever holds an old family key, such as a former member, could, together with the service, build an object for any row that opens without error. The signed content of a row therefore carries obj: per role the SHA-256 of the stored ciphertext (base64url). Roles are full and thumb (photo; for a video thumb is the poster), blocks (video), audio (story, chapter), cover and logo (family header). The reader opens an object only if the hash of its ciphertext is in the obj of the opened row. A row that does not open shows no media either. The check happens in one place: every read key is created in objectKeyFor and bound to its row there, and decryption (picture, audio, video block) asks before opening.

  • Video: one hash per block does not fit into the row. Next to the video there is a block list ….blocks.e2e.v<N>.enc with its own K_obj: "FMB1" ‖ u32(n) ‖ SHA-256(header) ‖ n × SHA-256(ciphertext of block i). obj.blocks binds the ciphertext of the list. The reader loads it before the first block and checks every block. The service worker receives the verified list from the page and checks every block between loading and decrypting.
  • Family picture and logo: the family header is opened before the pictures and binds both.
  • Changes: replacing or removing audio and changing the family picture or logo reseals the row instead of only setting the path column. Without a key on the device, nothing is written.
  • Legacy content: objects uploaded before 18 Oct 2026 are bound later by a scheduler (§12.4). After that the owner sets mediaBound in the signed header. A device remembers this permanently and from then on opens no media without a binding.

Profile picture. It has no signed row, and its key PAK is held by everyone who knows the person. The person therefore signs the stored object themselves:

"FMAV" ‖ u8(1) ‖ identity id ‖ u64(signedAt) ‖ sig ‖ sealed picture
sig = Ed25519(AK, enc("fm/avatar/v1", user_id, identity id, path, u64(signedAt), SHA-256(sealed picture)))

The reader checks the signature against the key its trust store knows for this person. It rejects an older signedAt than one it has already shown, and once it has shown a signed picture from a person, it no longer accepts unsigned ones from them. The person's own device signs an older picture the next time the app is opened (and reseals it in format v2 if necessary).

4.4 Lengths

Texts are padded to steps before encryption: at least 64 bytes, up to 256 bytes in steps of 64 bytes, up to 4 KiB in steps of 512 bytes, above that in steps of 4 KiB. The calculation covers u32(length) ‖ JSON, filled with zero bytes that are checked when opening. This way the length of a title does not reveal its content. Media sizes stay visible. They are needed for the quota, and the FMV2 header states the exact length anyway (§10.4).

4.5 Tags without plaintext

TIK_v    = HKDF-SHA256(FMK_v, salt = "", info = enc("fm/tag-index/v1"))
name_idx = base64url( HMAC-SHA256(TIK_v, normalize(name))[0..16] )
normalize = NFKC → trim → whitespace to a single space → lower case

name_idx is unique per (family_id, name_idx). After a rotation, the rotating device reseals every tag of an older version and recomputes the index (reindexSealedTags). Until then, creating a tag also searches the indexes of older versions so that no duplicate is created. A former member could guess tag names with the old TIK and a database dump, but not after the recomputation.

4.6 Sorting, filtering, searching

The server only sorts by created_at and delivers a family's rows in one go. Dates, timeline, years, tags and albums are built in the browser from the decrypted rows. There is no stored search index. There is no full-text search over encrypted content today; if it comes, it will be local only.

4.7 No downgrade

Whether a family is encrypted must not depend on what the service happens to report:

  • If the device cannot load the list of its E2E scopes, it writes nothing. An error never means "carry on the classic way".
  • There is no plaintext write path any more. If the device lacks the key of a scope, it refuses every write, whatever the service reports: if it leaves a family off the list, marks a story as "shared" without its key being there, or if the device is new and does not know the family yet, nothing is written. This applies to stories, chapters, comments, transcripts, captions, tags, albums and the family's name, motto and colour; media were already blocked this way. The list of E2E scopes now only decides which key is used, never whether encryption happens.
  • In addition, every device remembers every scope it has once seen as E2E.
  • The database additionally rejects plaintext in E2E families: content columns, plaintext place, place under the server key, originals, objects without .e2e.v<N>.enc and family picture and logo in plaintext. This protects against old app versions, not against the service itself; the rules in the client protect against that.
  • Every family is created encrypted (§12.2). Since 17 Oct 2026, E2E simply means "not the example family"; the former switch families.e2e_pilot is gone.
  • The old read paths have been removed (17 Oct 2026): the client shows no plaintext rows, opens no media under the file names of the earlier scheme and loads no family picture or logo without .e2e.v<N>.enc. It reads plaintext only for demo accounts, and those see only the example family.

5. Identity and trust

5.1 First device, first AK

After the first sign-in (password or magic link, unchanged):

  1. The device generates the AK and wraps it with the DSK.
  2. It publishes AK_pub in user_identity_keys with the self-signature Ed25519(AK, enc("fm/ak-self/v1", user_id, x25519_pub, ed25519_pub)). At most one key per person is active. Older ones carry replaced_at and are never deleted, except together with the account. The device stores the AK locally first and only then publishes it. An interrupted run therefore does not lock the account. This path only creates a person's first key (or one when none is active). Only "A fresh start" replaces an active one (§6.2).
  3. If the account is already in a family or story but there is no AK on the server or on the device, the app first asks "Is this your first device?" instead of silently creating a new key. A key that has turned up in the meantime wins.
  4. Owners see a card without "Later" until a recovery code has been set up (mandatory). Everyone else is offered one. A passkey is available in the account settings.

The fingerprint of an AK is SHA-256(enc("fm/ak-fp/v1", x25519_pub, ed25519_pub)).

5.2 Further devices of one's own

PathProcedure
Pairing via QR code or short codesee below
PasskeySynced passkeys (iCloud, Google) deliver the same PRF output on the new device → unwraps the AK from ak_backups(kind='passkey')
Recovery codetype in the code → Argon2id → unwraps the AK from ak_backups(kind='recovery')
None of theserescue by the family (§6.2)

On every path, the new device accepts the AK only if it matches the person's active public key.

Pairing. The new device generates a throwaway X25519 pair and a nonce. In device_pairings it first stores only a commitment: commitment = SHA-256(enc("fm/pair-commit/v1", new_pub, nonce)). It shows a QR code with the link …/account/geraet-koppeln#p=<pairing-id>&k=<new_pub>&n=<nonce> and an 8-character short code (Crockford Base32) for typing. The old device also generates a throwaway pair and stores its key. Only then does the new device reveal new_pub. Both devices show a six-digit verification code:

code = u32(HKDF-SHA256(ECDH(new, old), salt = nonce,
                       info = enc("fm/pair-code/v1", new_pub, old_pub))[0..4]) mod 10^6

Only when the person taps "They match" on the old device does the old device seal the AK with AES-GCM under the same secret from which the compared code comes:

K_pair = HKDF-SHA256(ECDH(old, new), salt = 32 random bytes,
                     info = enc("fm/pair-transfer/v1", user_id, nonce, new_pub, old_pub))

Until 18 Oct 2026 the AK went to new_pub via HPKE. The service, which knows both public keys, could have built that too. The old device's trust store travels with the AK; the new device merges it in as its starting state. The new device only accepts the AK after its own "They match" (both devices confirm). Both show the notice to confirm only if one holds both devices in one's own hands; the team never asks for this number. Afterwards the new device deletes the row. A pairing expires after 10 minutes.

  • QR path: the old device takes new_pub and nonce from the new device's screen and checks them against the commitment in the row. A key slipped in by the service is noticed immediately.
  • Short code: both keys come via the server. The commitment forces each side to commit before it knows the other's key: the new device has committed to new_pub before it sees old_pub. The old device stores old_pub before new_pub is revealed, and checks what is revealed against the commitment. Whoever sits in between has exactly one attempt at the six-digit verification code (1 : 1,000,000) instead of being able to try out keys (as with ZRTP). The old device does not accept a row without a commitment (app before 12 Oct 2026) for the short code.
  • The database allows every value of a pairing to be set only once. Commitment, nonce, short code and expiry never change, and the wrapped AK may only go into the row after old_pub.

5.3 New members and co-authors

The service sends the invitation e-mails and decides who is a member. It could therefore accept an invitation itself. Hence the rule: a new person only receives the family key when a human confirms it.

  1. The invited person accepts; their device publishes their AK (§5.1).
  2. The card "Membership application from …" appears on the family's devices. The owner and members may confirm, not family friends (viewer). For shared stories the author confirms. The database enforces this circle; only these people receive the list of those waiting at all.
  3. Whoever confirms can compare the security code beforehand (§5.5). The device then wraps all versions it holds for the new AK, each signed. New members see the whole family history.
  4. The new person only disappears from the waiting list once their device has acknowledged the version or the creator of the version has given them an envelope. An envelope from any other member is no longer enough for that.

The invitation carries the inviting person's signature (Ed25519(AK, enc("fm/invite/v1", scope, scope id, invitation_id, e-mail, role, expiry))). The card therefore shows "invited by …" when the signature comes from someone the device trusts. No automatic approval follows from this any more (until 12 Oct 2026 it did), because the signature does not bind the invited person and their key.

External co-authors of a shared story only get SK, never the family key. The database enforces this.

5.4 Trust store

Every device keeps one entry per person: identity id, public keys, fingerprint, first seen, confirmed yes/no, a parked change and earlier fingerprints. The store is wrapped with the DSK (AAD = enc("fm/trust/v1", user_id)).

  • Origin per entry: confirmed, vouched for, at first contact (TOFU) or legacy. Entries from before 18 Oct 2026 are legacy and count as confirmed.
  • An AK is accepted when first seen, after the self-signature has been checked (trust on first use), but only on a device whose account key was created here first and which has never held the scope. Paired devices, devices taken over via recovery code or passkey, and devices that started afresh show cards for unknown senders. A TOFU entry opens envelopes, but never approves silently and does not vouch (§5.3). Row signers the device does not know yet are pinned with their first key; a second key of the same person then does not count.
  • Silent forwarding after a rotation: the device only forwards a new version silently to people who demonstrably held the previous version in the same scope (a validly signed envelope from a trusted sender for current − 1, e2e_prior_envelopes). Otherwise a vouch for exactly this scope and this version is needed, Ed25519(AK, enc("fm/vouch/v2", scope, scope id, version, user_id, identity_id, fingerprint)) (key_scope_vouches), or a card. This also applies when joining a second family and when returning after leaving.
  • Version memory: per scope the device remembers the highest current_version seen and per version the fingerprint of the key, SHA-256(enc("fm/key-commit/v1", scope, scope id, version, key)). If current is lower than what was seen, the device does not write. A second, different key for a version already seen is rejected. Versions that only an earlier key of a person opens are used for reading only.
  • If the AK of a known person changes, the device does not accept it silently. It is parked, and the person appears with "… needs access to your memories again" until someone confirms (§6.2).
  • No way back: A key whose fingerprint is among a person's earlier ones is never adopted again — not as a change, not through an (older) vouch, and not when syncing with one of my own devices; there the side that moved on wins. Otherwise a device that had once vouched for the old key would, after "A fresh start", open envelopes of the old key again — including planted current ones. A device that had already fallen back takes the key it adopted last back on the next load (only a key from the server with a valid self-signature and exactly that fingerprint).
  • One confirmation applies to the whole family. Whoever confirms stores a signed statement: Ed25519(AK, enc("fm/vouch/v1", user_id, identity_id, fingerprint)). Other devices accept the key if they themselves trust the confirming person (their current key) and the signature matches exactly this identity and this fingerprint; never from the person themselves. The same people who may approve may store such a statement; the database enforces this. Since 18 Oct 2026, such a confirmation only counts if the confirming person holds the current version of the scope. This way not every device asks again, and no device later silently discards a confirmed person's envelopes.
  • Synchronisation between one's own devices via trust_store_sync. The state is stored under its own random key, which is wrapped to one's own AK via HPKE (info = enc("fm/trust-sync-key/v1", user_id, identity_id)). Every state carries a version number that only increases and is part of the AAD (enc("fm/trust-sync/v3", user_id, identity_id, version)), and is signed with one's own AK: Ed25519(AK, enc("fm/trust-sync-sig/v1", user_id, identity_id, version, store)). A device only accepts a state signed by its own active AK (never an unsigned state in the old format v2), and none older than the last one seen. Reading, merging and writing run on the device under a lock per person (Web Lock). A device that has been taken over only reports itself ready after synchronisation. It only writes if the version is still the one it read; otherwise it reads and merges again. A state of its own current key that does not open is not overwritten by the device. Only a state of an earlier account key is replaced.

5.5 Security code (optional)

As with Signal, every person has a half of 30 digits: half = six blocks of 5 bytes each from SHA-512(enc("fm/safety/v1", user_id, ed25519_pub)), each mod 100,000. The code of a pair is both halves sorted one after the other, i.e. 60 digits in blocks of five. One's own half stays the same regardless of whom one compares with, and only changes with the account key. The X25519 part is bound via the self-signature. Whoever compares sets "confirmed" in the trust store. This only applies as long as the fingerprint shown is still current. Comparing is not needed in everyday use; it helps against the residual risk from §10.3.

The display labels the two halves ("Your half" / "Half of …") without changing their order: both devices show the same digits, and if they differ it is clear whose key is affected. Each person is shown with the families both share and the date since which one's own devices have known them (from the trust store), so people with the same name can be told apart. The list also shows family members whose key this device has not taken over yet, with the key the server names for them now (self-signature checked). That is display only — no trust and no "confirmed": a new member can thus compare the code before the first envelope, i.e. exactly at the first contact against which §10.3 recommends comparing.


6. Recovery

6.1 Order in which the app offers it

  1. Another device of one's own (QR or short code) → 2. passkey → 3. recovery code → 4. family.

6.2 Rescue by the family

For people who have lost everything:

  1. The person signs in as usual and chooses "A fresh start".
  2. This is only possible after a fresh sign-in: the token's session is at most 15 minutes old (e2e_fresh_login, measured against auth.sessions.created_at; refreshing does not create a new session). Otherwise the app asks the person to sign in again.
  3. Their device generates a new AK and registers it via the server (pending_identity_keys). The person receives an e-mail ("Was this you?") with a link to cancel. If the e-mail cannot be sent, the request does not count. The link carries its one-time token in the fragment, and cancellation only happens via a button on the page, so that a mail scanner triggers nothing. One's own devices that still have the old AK show a warning and can also cancel, but only with a signature of the current AK over the waiting key (fm/start-over-cancel/v1), no longer with the mere session. While a request is waiting, the server refuses a second one instead of replacing it. The e-mail, the waiting device and one's own devices show the same request code: ten digits from the security code half of the new key (§5.5). This lets the person recognise whether the waiting request is their own (result of the internal review).
  4. After 24 hours the new AK replaces the old one (database job every 10 minutes). The waiting period cannot be shortened. Until then the old one stays valid. The old one is marked as replaced, and the database marks all of the person's scopes for rotation (§7).
  5. The family sees "… needs access to your memories again", with a request to get in touch with the person briefly, and optionally the security code.
  6. One owner or member confirms (circle as in §5.3). Their device wraps all family versions and the keys of the shared stories both are in for the new AK.

A new member's key and a changed key of a known person both need a human who says "yes". That is exactly where an attacker (A3, A9) would start.

Last member: if nobody is left who can confirm, and there is no device, no passkey and no recovery code, the content is lost. That is why the recovery code is mandatory for owners.

6.3 Recovery code

  • 23 random characters (115 bits) plus one check character, shown as 24 characters of Crockford Base32 in six groups of four. The check character is Σ value_i · (2i + 1) mod 32 and detects every single typo before Argon2id runs. Input tolerates lower case, spaces, hyphens, O/0 and I/L/1.
  • In addition, a printable A4 sheet with name, date, code and instructions.
  • To confirm, the person types in two random groups. The code is only stored after this confirmation. Whoever gives up halfway keeps the old code.
  • wrap_key = Argon2id(code, salt) → wraps the AK → ak_backups(kind='recovery'). The device creates the first code itself.
  • Every backup row (recovery code and passkey) carries a signature of the AK: Ed25519(AK, enc("fm/backup/v1", user_id, kind, credential_id, identity_id, SHA-256(wrapped_ak))). The database only accepts new rows from the browser with a signature; the devices count and offer only validly signed rows (older ones without a signature still count). A junk row that someone creates with only the session therefore neither makes the reminder about the recovery code disappear nor appears when unlocking (result of the internal review).
  • Replacing an existing one is only possible via the server: after a fresh sign-in (§6.2), after which the person receives an e-mail that their recovery code has been renewed. If the e-mail cannot be sent, the old code stays valid. There is no waiting period here, because without the AK no usable code can be created, only the real one made unusable, and the e-mail shows that. The old code is invalid afterwards. Users cannot delete backups.
  • The device reads the Argon2 parameters from the row. It only accepts values between its own defaults and at most 256 MiB, 10 passes and parallelism 4. This way the service can neither weaken them nor inflate them to hang the device.

6.4 Passkey

  • WebAuthn with userVerification: required, residentKey: required and the PRF extension. Every passkey has its own 32-byte salt and its own row in ak_backups. Unlocking uses evalByCredential.
  • wrap_key = HKDF-SHA256(PRF output, salt = "", info = enc("fm/passkey/v1", credential_id)).
  • The passkey is only a way to unlock, not a way to sign in. Sign-in stays with Supabase Auth.
  • At most ten passkeys per person; every row carries the signature from §6.3.
  • Removing a passkey: in the security settings, only after a fresh sign-in (§6.2) and always with an information e-mail; if the e-mail cannot be sent, the passkey stays. This closes the path if the account with the passkey provider (Google, Apple) is no longer secure. Browsers themselves still may not delete backups.
  • Before creating one, the device asks for PRF via getClientCapabilities where the browser supports this, so that as far as possible no unusable passkey is created. If a new passkey still delivers no PRF, it is not stored as a way to unlock.
  • There are no passkeys in the Android app. Its WebView runs under https://localhost; a passkey would be bound to this name and be of no use anywhere else. Passkeys are created and used in the browser. The app unlocks via the Keystore (§3); a new app device gets its key through pairing (§5.2) or the recovery code (§6.3).

7. Rotation (leaving and removal)

  1. Every way out marks via trigger, not in the routes' code: removal, leaving and account deletion (DELETE on family_members) as well as withdrawing co-authorship. This sets key_scope_state.rotation_pending for the family, its shared stories and the profile pictures of those involved, and rotation_epoch counts up. If someone replaces their AK (§6.2), everything the person holds is marked. No key is created on the server.
  2. The next unlocked device of an owner (family) or of the author (shared story; if they have left the family or are only a family friend, the owner) generates FMK_{v+1} or SK_{v+1}, wraps the key for all remaining people and signs the envelopes. A Web Lock per scope prevents a double rotation from two tabs. Only a device that holds the current version may rotate. That only owners or the author rotate is enforced by the database. An author who has left remains named as author, but no longer manages the key — otherwise their story would never be rotated (result of the internal review).
  3. e2e_complete_key_version(scope, id, v+1) checks that every person already admitted (their current AK has acknowledged a version in this scope or has an envelope from the creator of a version; an envelope from any other member does not count) also has one for v+1. Then the function sets current and clears the mark, but only if nobody has left since begin (key_versions.epoch = rotation_epoch). Two owners on two devices get v+1 and v+2; the higher one becomes current.
  4. People whose key is still waiting for a confirmation (new or replaced) do not hold up the rotation. They receive all versions with the confirmation.
  5. Content uploaded before completion still runs under v. The window lasts until an owner opens the app. Old content is not re-encrypted.
  6. Tag indexes are recomputed (§4.5).
  7. Profile pictures (avatar) have no completion step. On a mark, the person's own device creates a new version; the visible picture stays under its version, and the next upload uses the new one.
  8. When a family, story or profile is deleted, triggers clear away versions, state and envelopes.

Nothing rotates on the server any more: the earlier scheme's rotation in leave, remove-member and removeCollaborator ended on 17 Oct 2026.


8. What the server still does

  • Stores ciphertext, envelopes, public keys and wrapped backups.
  • Enforces access rules (RLS). RLS remains the first line of defence, encryption the second.
  • Checks sizes, quotas, roles and invitation deadlines.
  • Sends e-mails without content: invitations name the inviting person but no family name and no title; the daily e-mail contains only numbers.
  • The private screens are client components. Server routes accept ciphertext and only check size and permission.
  • Error reports (GlitchTip): before sending, the app removes request bodies, headers, cookies, query parameters, user details and console breadcrumbs. Free text is redacted (addresses, URLs, link fragments, database rows, quotations). Extra fields and tags each go through an allow list, and their texts are redacted as well. Edge Functions and the render service send no error reports.

9. Exceptions with consent

Every exception has its own, labelled moment in which the person sees what goes where. No plaintext leaves the device in passing.

9.1 Shared download links

  1. The device generates LK and re-encrypts the selection. The manifest with names, captions, dates and story text is a sealed, padded block. Every file is an FMV2 object. The keys come from HKDF-SHA256(LK, salt = "fm/share/v1", info = enc("manifest", grant_id)) or enc("part", grant_id, file name). The upload goes to <family_id>/links/<grant_id>/.
  2. The link is https://family-memories.eu/<language>/d/<token>#k=<LK base64url>. The fragment never reaches a server.
  3. download-grant checks expiry and download count and delivers signed addresses of the ciphertext files that are valid for 2 hours. The function holds no key for these links.
  4. A revoked link is deleted immediately, an expired or used-up one after 3 hours, one that was never fully uploaded after 24 hours. A clean-up run happens every hour. Photos that are later marked "Not for distribution" or deleted drop out of existing links via a trigger.

The sharing person sees: "Anyone who has this link can open the selected content. Only share it with people you trust."

9.2 Translation (DeepL)

  • Button "Translate with DeepL". The first time, a dialog explains that the text goes to DeepL (Cologne) and is not stored there. "Don't ask again" applies per person on this device and is stored there, not with the service: a consent the service could set itself would skip the dialog and translate the motto automatically on every visit. On a new device the dialog therefore appears once more.
  • The route /api/translate forwards statelessly: no log of the content, no storage. On errors only the status is logged. The DeepL key stays on the server, which is why this does not go directly from the browser. Since 17 Oct 2026 the route only knows this pass-through; the earlier modes, which read the source text from the database themselves and cached it in plaintext, have been removed.
  • The translation is encrypted in the browser under FMK and stored in content_translations — as the own entry of the person who translated (§4.3). The database only accepts encrypted rows there.
  • At this moment our server sees the text in transit. That is the exception the person has consented to.
  • Everything currently being translated is sent: for a story also chapters, comments and captions of other members. Consent applies per reading person; whoever wrote the text is not asked. The dialog says so.
  • With "Don't ask again" the app translates without a click: the family motto every time the family page is opened, and the texts of a photo book whose language differs from the app language. The dialog says this too.

9.3 Photo book

  • Consent dialog before ordering: for printing, the book goes as a readable file to our print service and to Gelato — with texts, comments including names, maps with the photo locations, and the photos.
  • The browser sends content and pictures directly to the render service, not via the website. For this there is a token valid for 15 minutes and bound to the order, with a hash of the selection that the render service recomputes. With the order, only the selection (ids and options) is stored.
  • The render service keeps jobs only in memory and discards them after rendering or after 15 minutes.
  • The print PDF is stored in R2 and deleted 30 days after delivery (without a delivery notice 40 days after shipping or 50 days after submission, immediately on cancellation). This is checked every 10 minutes together with the Gelato reconciliation.
  • Gelato fetches the file and deletes it, according to its own privacy policy, as soon as it is no longer needed; Gelato does not state a fixed period. The consent dialog says "30 days with us".

9.4 Feedback and support with a screenshot or attachment

A notice before choosing a file — in feedback, in the support form and when replying in one's own ticket: the picture shows what is currently on the screen, including family photos, goes unencrypted to our team and is deleted after 90 days. It can be deselected.

Uploading already happens on selection, into separate storage areas (feedback/<user>/, ticket-attachments/<user>/). Feedback screenshots are also sent as an attachment of the team e-mail via our mail service (Mailjet); the team sees ticket attachments only in the admin area. Periods, enforced by an hourly job: screenshots and attachments 90 days after the last activity, texts of feedback and tickets 12 months; a file that nothing refers to any more (form abandoned, account deleted) after 24 hours. Feedback, tickets, chats and all files go immediately with the account.

9.5 Offline

The offline storage on one's own device (texts and places in local storage, media in the browser's cache) stays unencrypted. Protection on the device is a matter for the device lock (§1.2).

The copies belong to the account that downloaded them; /offline only shows them as long as this account was the last one signed in on the device, and if another account downloads something itself, the old copies go first. When signing out, the app asks whether the copies should stay — "Delete" is preselected, because copies that are kept can be viewed without signing in by anyone with access to the device. They always go with the account.

In the Android app, its own picker reads the original of a photo or video so that the location is preserved, and for this it stores an unencrypted copy including the location in the app storage. The app deletes it as soon as it has read the file; whatever a crash leaves behind is cleared by the next start and by signing out.

9.6 Example family

The invented example family (/beispielfamilie) is stored unencrypted and publicly. The database prevents it from ever becoming E2E. This way the server never needs a family key, not even for the demo.

9.7 Support

The team sees no family content and therefore cannot "take a look" either.

  • AI chat: Mistral (EU) only sees what someone types into the chat and, under its own rules, keeps it briefly for abuse prevention. A permanent notice above the input field says so, mentions the 90 days of storage and asks people not to paste family content. The team only reads a conversation if the person hands it over; it then goes as text into the ticket and by e-mail to the team. The exception is visitors to the example family, whose conversations the team reads and which go after 24 hours together with the guest account. Of the others the team only sees numbers (count, length, whether handed over), no text. Conversations are deleted 90 days after the last message.
  • Tickets: texts and history stay for 12 months after the last message, attachments 90 days (§9.4). If someone does not write in German, the team can have a ticket translated with DeepL at the push of a button; the form and the ticket history say so. Nothing is translated automatically.
  • Contact form and e-mails to the mailbox (at Hostinger): deleted after 12 months.
  • Server logs contain, on errors, only the kind, code and a redacted message — no addresses, no row contents, no response texts from third parties.

9.8 Map

The map decrypts the photo locations in the browser and loads map tiles from our archive (Cloudflare R2) for this. The photo locations themselves do not leave the device. Tiles are fetched at most up to zoom level 14, i.e. roughly 1.5 km per tile; whoever zooms in further sees these tiles enlarged. This way the requests reveal the area, but no single street. What they can still reveal is described in §10.4.


10. Limits of the model

10.1 Code delivery on the web (A4)

Whoever serves the website could serve modified code that steals keys. This applies to every end-to-end web app. A Content Security Policy protects against injected foreign code (XSS): scripts only run if they come from our address or carry the nonce of the respective request (in the app: the hash of the respective inline script); there is no 'unsafe-inline' for scripts — not even as a fallback: a page that would be served without a nonce gets no inline scripts at all, and every page under a language path goes through the middleware that sets the nonce (result of the internal review). The policy prevents the execution of foreign code, not every exfiltration: connections to our own Supabase project and navigations remain allowed because the app needs them. For styles it remains allowed because the components use style attributes; injected styles do not execute code. Behind this, since 24 Oct 2026, are Trusted Types (require-trusted-types-for 'script'), on the web and in the app, wherever the browser supports them (Chrome, Edge and the Android app's WebView): at the places where text becomes code or HTML (innerHTML, script URLs, workers), the browser no longer accepts plain strings. Only four named policies are allowed: the two from Next.js, fm solely for registering our service worker, and default, which lets through exactly what libraries cannot do differently — the map worker from our bundle, workers from blob: URLs of our own origin, the map's fixed attribution and React's empty script placeholder. The browser blocks everything else. This way even a mistake that puts user text into such a place does not become code (checked live on the web on 24 Oct 2026, public and signed in: no reports). The website's start-up scripts (runtime, main bundle, polyfills) also carry their hash (Subresource Integrity); the other page chunks do not, because Next.js only sets SRI for these in the App Router (checked live on 16 Oct 2026: 6 of 27 scripts on a page). The policy is in the <meta> tag because our host replaces the CSP header with its own; frame-ancestors has no effect there, and embedding is prevented by X-Frame-Options: DENY. These measures do not help against the operator itself. Later: reproducible builds and published hashes.

10.2 Android app

The app ships its own code (webDir), is signed with our key and receives code only through app updates. Protection comes from the signature: before the app hands an update to Android, it checks itself that the file comes from our release storage, is signed with the same key and is newer than itself; Android checks the signature again on installation. The SHA-256 from the update description (latest.json) is stored in the same place as the file and therefore only proves that the download is complete; without a matching value there is no update. For the first installation, when there is nothing to compare yet, we publish the fingerprint (SHA-256) of the signing certificate on the download page and here: 6F:F8:11:8E:FA:0A:03:5C:9F:1A:AE:2F:A8:30:B6:8C:80:C2:5D:5B:9A:39:83:F1:FA:B2:EE:B0:CB:29:2E:5D. The app's Content Security Policy allows no scripts from the website.

For speech transcription, the ONNX runtime (program code) is in the app. The app downloads the model (around 250 MB, data) from our server on first use, but only runs files whose SHA-256 matches the list built into the app; it does not even download other files. Only verified files go into its cache. Apps up to 1.0.8 still loaded the runtime from the website; since version 1.0.17 the website no longer serves it to them. They only lose transcription as a result.

The DSK is stored in the Android Keystore and is usable only while the device is unlocked (setUnlockedDeviceRequired, from Android 9). Only if the Keystore rejects this binding on an unlocked device in a second attempt as well, and on Android 7 and 8, which do not know it, is a fallback key without the binding created — otherwise E2E could not be set up on the device at all. It is never created behind the device lock. Binding and fallback have separate names in the Keystore: under "Devices and encryption" the app shows which one the device uses, and tries again at every start to get the bound one. The app data is excluded from Android backups and from transfer to a new device.

While a recovery code or pairing code is visible or being typed in, the app blocks screenshots and the preview under "Recent apps"; many phones upload screenshots to a cloud automatically. On the web this is not possible. It does not stop a second device photographing the screen. The app does not accept touches that pass through an overlaid window of another app.

10.3 The service as member management and first contact (A3)

The service still decides who is a member and displays names. Human confirmation (§5.3, §6.2) and the trust store prevent it from silently injecting its own keys. What remains:

  • For a new person, the service could present its own key under their name the very first time. Whoever confirms sees the name the service displays.
  • A new member accepts the first validly signed family key without knowing the senders yet (§4.2).

Comparing the security code helps against this (§5.5). Later: key transparency, i.e. a public, append-only log of all account keys.

10.4 Metadata

What stays visible is who is in a family with whom, with which role, profile names and e-mail addresses. Equally visible are the times of uploads and changes, the number and size of files and the media type (photo or video). The header of a video states the MIME type and exact length. Also visible are which rows are linked to each other, the access patterns and the coarse map requests (§9.8).

In detail:

  • Profile: time zone and language (the time zone reveals the region), the day of last use (last_seen_on) and whether someone has consented to DeepL translation.
  • Translations: which field of which row was translated into which language, and when (content_translations; the text itself is sealed).
  • Download links: what was shared when, how often it was downloaded, which parts were excluded and which photos are included.
  • Photo book orders: selected content (ids), number of pages, product, design, book language, cover variant, delivery address and name — the last two also go to Gelato.
  • Content: whether a photo is marked "Not for distribution", deleted comments and their reply chains, notifications (who commented on whose content), whether a story has a recording (via its size also its approximate length) and the order of the chapters.

Exactly which columns are encrypted and which are not is shown in the table in §4.3.

10.5 Family friends (viewer)

They receive the family key like members. Read access means the key. They may not approve others (§5.3).

10.6 Keys in browser storage

On the web, the DSK is a non-extractable WebCrypto key in IndexedDB. This protects against scripts reading it out. However, the browser stores it in its profile files. Whoever can copy these files from the computer generally has the key as well. We treat this like a compromised device (§1.2). In the Android app the DSK is in the Keystore and does not leave it; a browser DSK the app still has from earlier is deleted after the move to the Keystore. Deleted here means: logically. The WebView's database writes a deletion as a new entry; the old bytes — the browser DSK and the AK wrapped with it — can remain in its files until they are cleaned up, and on flash storage even beyond that. Whoever reads them out with root or forensic access can open the AK. This only affects people who already used the app before the Keystore. Because the app does not control the WebView's storage and a deletion on the device could not be verified, we treat this as a limit of the model.

10.7 Keys in memory

While the app is unlocked, the private AK and the opened family and story keys are held as bytes in the page's memory. This is necessary for the curve library (§4.1). Derived keys for rows, media and tags are non-extractable WebCrypto keys. On signing out, all key caches are cleared, and in the app also the picker's media copies (§9.5).

10.8 Replay, withholding and links

A device only detects a replayed older version for rows whose newer version it has already seen (§4.3). The revision state is per device, not per person: a new device or a cleared browser cannot compare a row it sees for the first time with an earlier state. The service can always withhold or delete data (§1.2).

For links (§4.3) this means: the service can remove a link or reinstate one that was previously valid and has since been deleted — its signature stays valid. An album's cover picture is not bound, because it must become empty when the photo is deleted; the service could show another photo of the same family as the cover picture. And because a person a device has never seen before is accepted at first contact (§10.3), the service could slip in links under such a new name. It cannot slip in texts this way: for that it lacks the family key.

For media (§4.3): until the owner has marked a scope as mediaBound, media without a binding still open. The retrofit (§12.4) binds what the service delivers at that moment. If someone with an old key swapped an object before that, it stays swapped, now bound. The profile picture of a person who has not opened the app since the rollout is unsigned. It opens on devices that have not yet shown a signed picture from them.

10.9 Legacy content

Texts from before format v2 whose author no longer has a device are sealed by the owner and carry the flag "legacy" (§4.3). For them, the signature only confirms that the owner has taken them over, not who wrote them. The owner may also change other people's content (signature rule "author or owner"). That is why, under every story, chapter, photo and comment not signed by its author, the devices show "Confirmed by …". This way an owner's text never passes as the author's text. For rows of deleted accounts, the owner's device does not check before taking them over who had signed them — the key was deleted with the account. It only knows that someone with the family key wrote them. When the takeover of other people's rows starts depends on the time of the switch; the service can bring this forward but not postpone it (§4.3) — so it can trigger the "legacy" flag earlier, but cannot falsify any text.

After a transfer of ownership, the former owner's signature under the family row no longer counts. Until the new owner's device has resealed it, the family's devices show name, motto and colour as "unverified".


11. Database

TableContentAccess
user_identity_keysid, user_id, x25519_pub, ed25519_pub, self_sig, created_at, replaced_atreadable for people who share a family or shared story; written only via a function for oneself; at most one active key per person; cannot be deleted except with the account
ak_backupsuser_id, kind (recovery/passkey), credential_id, identity_id, salt, kdf_params, wrapped_ak, sigread and create only for oneself, new ones only with a signature, at most 10 passkeys; replacing the recovery code only via the server after a fresh sign-in, with an e-mail (§6.3); removing a passkey likewise (§6.4); otherwise deleted only with the account
pending_identity_keysuser_id, x25519_pub, ed25519_pub, self_sig, requested_at, activates_at, cancel_token_hashreadable only for oneself; cancel via the e-mail link or with a signature of the current AK; created only via the server after a fresh sign-in, with an e-mail, no second request while one is waiting; activated after 24 h by a database job (§6.2)
key_versionsscope, scope_id, version, created_by, created_at, epoch, commit, commit_sig, commit_identityreadable for those authorised; created via e2e_begin_key_version; commitment set once by the creator via e2e_set_key_commit (§4.2)
key_scope_statescope, scope_id, current_version, rotation_pending, rotation_epochreadable for those authorised; current_version only via e2e_complete_key_version
key_envelopes§4.2one envelope per sender; readable only for the recipient; insert only as the sender, if sender and recipient are authorised and both keys are active; cannot be changed or deleted (cascade with the account)
key_receiptsscope, scope_id, version, recipient, recipient_identity, created_atreceipt (§4.2): readable and creatable only for oneself, with the active key and only for a version with an envelope; cannot be changed or deleted
device_pairingsuser_id, commitment, new_device_pub, nonce, old_device_pub, enc, ct, code, created_at, expires_atonly for oneself; expiry 10 min; every value only once, commitment/nonce/code/expiry immutable (§5.2)
trust_store_syncuser_id, blob, version, identity_id, wrapped sync keyonly for oneself; the version only increases (§5.4)
key_vouchesvoucher, voucher_identity, subject, subject_identity, sigreadable for people who share a scope with the person; stored only in one's own name, for an active key, within the circle from §5.3; cannot be changed or deleted

In addition there is the single-row table e2e_settings (rows_v2_since, readable for signed-in users, set only in the SQL editor), the functions e2e_reseal_comment (the author or whoever may migrate) and e2e_sign_timeline_link (the creator, if orphaned the owner; only with one's own active key; both only as long as one belongs to the family, links only as owner/member), the columns family_invitations.inviter_sig, story_collaborators.inviter_sig, enc/enc_key_version on the content tables (§4.3), sig (64 bytes) and sig_identity on photo_tags, story_tags, album_tags and timeline_links (§4.3) and the tables of the download links (download_grant_items, download_link_trash). Check and helper functions run as SECURITY DEFINER with a fixed search_path. e2e_may_hold only gives information about one's own person or about scopes one holds oneself. e2e_pending_recipients only returns a list to people who may approve. It contains the address to which a new person was invited: only with it can the approving person check the inviter's signature (§5.3). Whoever may approve (the head of the family and members, or the author of a shared story) therefore sees this address — a deliberate limit (result of the internal review).

Every new table gets the demo trigger demo_readonly_sperre and narrowed default privileges. family_media_keys, story_collaboration_keys and avatar_keys were deleted on 17 Oct 2026.


12. Migration of existing content

12.1 Procedure as built

Completed: on 17 Oct 2026, no family other than the example family had anything left on the old path (files, audio, family picture, logo, storage objects, classic links). A single old profile picture of an account without an account key was discarded instead of migrated; the person can upload a new one.

  1. Switching over: operations set families.e2e_pilot (until 17 Oct 2026). A trigger revoked all classic download links of the family and deleted the plaintext translation cache. From then on the database no longer accepted plaintext.
  2. Keys: the next unlocked owner device generates FMK_1 and wraps it for all members with an AK. The service never knew this key. Members without a confirmed key appear as a card (§5.3).
  3. Re-encryption on the owner's device, in the background, resumable, with a progress display and a Web Lock per family. The device derives the state from the rows: open is every file not stored under ….e2e.v<N>.enc, every text row without enc and every plaintext tag. Per file: load, open with the old key, reseal under K_obj, upload, switch the row over conditionally on the old path, delete the old object. Shared stories are migrated by the author's device, profile pictures by the person's own device.
  4. The owner's device seals the family picture and logo from before the switch (plaintext) like a new picture under FMK and deletes the old object.

12.2 New families

New families are created encrypted. The founding device chooses the family ID, generates FMK_1, seals the family name under (family, ID, 1) and wraps FMK_1 in an envelope to its own account key. e2e_create_family creates the family (without a plaintext name), the owner membership, key version 1 and this envelope in one transaction. The name therefore never reaches the server in plaintext, and a family without a readable key cannot be created. A family can only be created with an unlocked vault; a new account gets its account key automatically in the process (§5.1). The old path create_family refuses.

12.3 Destruction

Rolled out on 17 Oct 2026:

  • family_media_keys, story_collaboration_keys and avatar_keys have been deleted, as have the functions that read them and the redemption of classic links. The database change aborts as long as anything in a family other than the example family is still on the old path.
  • The functions media-key, story-key and avatar-key have been removed; download-grant only knows E2E links and holds no key.
  • MEDIA_KEK has been deleted from the Edge environment and from both places where it was kept.
  • The nightly backup no longer stores key material; all earlier key-material-*.json files have been deleted.

Expires on its own, before the end of the beta:

  • The old states in the storage mirror at Hetzner (grace folder geloescht/, 30 days) disappear by mid-November 2026. Without keys they are already unreadable now.
  • The provider's database backups from before the switch expire in the same way. After that there is no plaintext and no unwrappable key from the time before anywhere.

12.4 Binding media

What was uploaded before 18 Oct 2026 has no obj yet (§4.3). A scheduler on the family's devices catches up on this, in the background after the switch to format v2:

  1. The device of the person who uploaded or wrote them binds their own rows immediately.
  2. The owner's device (shared story: the author's) sets mediaBindSince in the signed header on the first run. The service cannot shorten this period. 14 days later it binds the remaining rows as "legacy", as well as the family picture and logo.
  3. Only what opens with its own key is bound: photos, posters and audio as a whole, videos block by block via range requests (never entirely in memory); the block list is stored sealed alongside.
  4. Once nothing is left open (second count), the owner's device sets mediaBound in the header. Rows that do not open on this device hold up the mark.

13. Review and reporting

  • Tests in the repository: cryptographic building blocks, envelopes, trust store, rotation, migration and the database's access rules (with PGlite). In addition, attack tests: manipulated envelopes, swapped or replayed rows, foreign signatures, access via other accounts.
  • Source available for review: the crypto core — encryption on the devices (src/lib/e2e), media encryption and the device key on Android — has been public since 27 Oct 2026 at github.com/family-memories-eu/krypto-kern: byte-identical to the app, together with the tests that run without a database. Licence: PolyForm Strict 1.0.0 with an addendum for security research (reading, running, testing and publishing findings with excerpts yes, using it in your own products no); this is not open source. MANIFEST.json names the app commit the snapshot comes from; when these files change, they are re-exported before the next deploy. Not included are the database rules (§8, §11), and the export does not prove that the website serves exactly this code (§10.1). Summarised on one page: the fact sheet "What we see — and what we don't" (§15).
  • Review against checklists for web and server (OWASP ASVS), Android (OWASP MASVS) and the known cryptography pitfalls (nonces, binding, encoding, downgrade). Every finding is cross-checked and fixed before E2E counts as finished.
  • Plaintext flows: a network capture shows that nothing leaves the device in plaintext outside §9.
  • Reporting: security vulnerabilities are accepted by the page "Report a security vulnerability" (/sicherheit, in all of the app's languages) via a form or by e-mail to security@family-memories.eu; /.well-known/security.txt (RFC 9116) points there. Commitments: acknowledgement within three working days, disclosure after the fix, at the latest after 90 days, credit on request. Whoever follows the page's rules and reports in good faith need not fear legal action. There is no monetary reward.

14. Open points

  • Measure memory use on weak devices (target: 5,000 photos on a tablet with 2 GB RAM).
  • Safari: check the behaviour of IndexedDB for an installed web app compared with a tab (7-day rule); show the "Add to Home Screen" notice specifically.
  • Test WebAuthn PRF on the target devices (iOS 18+, Android with Google Password Manager, Windows Hello).

15. Questions from the privacy community

These questions come up almost every time people from the privacy community look at the design for the first time. The answers are short; the details are in the sections named. Summarised on one page: the fact sheet "What we see — and what we don't".

1. Why isn't the code open? The crypto core — everything that creates and wraps keys and encrypts and decrypts content, plus media encryption and the device key on Android — is available for review at github.com/family-memories-eu/krypto-kern, byte-identical to the app and together with the tests that run without a database (§13). Reading, running, testing and publishing findings with excerpts is allowed (PolyForm Strict 1.0.0 with an addendum for security research). The rest of the app is not published: Family Memories is a one-person project that lives on subscriptions. This is not open source, and we do not call it that.

2. A web app can betray itself. True — that is the biggest limit of any end-to-end encrypted web app (§10.1). A strict Content Security Policy without unsafe-inline for scripts, Trusted Types and checksums for the start-up scripts protect against foreign code — they do not protect against us as the operator. If that is not acceptable to you, use the Android app: it ships its code and only gets new code through signed updates (§10.2). Whether the website serves the published crypto core can be compared (the page's build ID against MANIFEST.json) but not proven; reproducible builds are planned.

3. Which metadata do you see? Who is in a family with whom and in which role, profile names and email addresses, times of uploads and changes, number, size and type of files, which rows belong together, and coarse map requests. The complete list is in §10.4, the encrypted columns in §4.3.

4. Which providers, where? According to the privacy policy (section 14): Hostinger (website, Germany), Supabase (database, sign-in and files in Frankfurt; provider based in the USA, EU standard contractual clauses), Mailjet (system emails, EU), Hetzner (our error monitoring and print service, Germany), Cloudflare R2 (map tiles — Cloudflare sees the IP address — and print files; stored in the EU, provider based in the USA), Gelato (printing and shipping, Norway; partner printers also outside the EU), Stripe (payments, Ireland), sevDesk (accounting, Germany), DeepL (translation on request, EU) and Mistral (support chat Lumi, EU; sees only what is typed into the chat). Of these, only the services you explicitly hand something to see content (§9).

5. Printing is plaintext. Yes. For a printed book the printer has to be able to read it; that is why there is a separate consent dialog (§9.3). The browser sends the content directly to our print service, which keeps it in memory only. We delete the print PDF 30 days after delivery; Gelato does not state a fixed period.

6. Who is behind it, and what happens if the project ends? A sole trader from Germany (legal notice). The business model is a subscription without advertising. If the project ends, we will announce it at least six months in advance — by email to all accounts and in the app. Until the last day everything can be downloaded, decrypted in your own browser: under "Account & privacy" → "Download my data" a ZIP with all families, stories and media (media up to 400 MB per export; anything beyond that is listed), for large archives "Download all media" in the family as a single file, plus the PDF chronicle.

7. What about iOS? No native app. The web app runs in Safari and can be placed on the home screen like an app via "Add to Home Screen". The limits from question 2 apply there just the same; we are still checking how Safari's storage behaves (§14).

8. F-Droid? No. F-Droid requires a free licence, and the app is not freely licensed. The APK is available directly from us; we publish the fingerprint of the signing certificate on the download page and in §10.2 so that the first installation can be checked.

9. Can the server slip in keys? Not silently for existing contacts: human confirmation and the trust store prevent it (§5.3, §5.4). On the very first contact with a new person, the service could present a key of its own under their name (§10.3). The safety code that two people can compare helps against this (§5.5) — even before the new member has received the family key. Later: key transparency.

10. Why advertise on Meta? Because a one-person project with no reach would otherwise hardly get to families who don't already read about privacy. "No tracking, no ads" applies to the service: Family Memories shows no ads and contains no trackers. We still advertise the service itself, with paid ads on Instagram. We don't talk that tension away. There is no Meta Pixel and no Conversions API, and we give Meta no address lists or any other data about people who use us. Measurement relies only on the campaign label in the link (utm_source, utm_campaign, utm_content): it names an ad, never a person. It sits in the address bar, isn't stored on the device, and only reaches our database when someone joins the waitlist or creates a family. From Meta we only get aggregated figures such as reach, clicks and cost. As a result, Meta can only optimise for clicks, not for sign-ups. What Meta sees for itself: who saw and clicked the ad, because that happens on Meta's platform. Meta adds its own click ID to the link (fbclid). We don't read it, but like any requested address it can end up in the host's access log. In the Instagram app, the page usually opens in the app's built-in browser. What the app can capture there is something we can't reliably prevent from the page. Signing in and viewing content therefore belong in a browser of your own or in the Android app (§10.1, §10.2). Details are in the privacy policy, section 25 (German).

Found a vulnerability? Report a security vulnerability