Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/STORAGE-ENCRYPTION.md

18 KiB

description status date
Storage encryption at rest — three-class model, per-agent keys, hardware-throttled unlock, portal-shaped permissions Design of record — secrets store built (AEAD, machine wrap, Argon2id wrap slot; live on the phone); rest of userspace greenfield, kernel primitives present 2026-07-20

Storage encryption — design of record

User data on a SouveraineOS device must be cryptographically inert when the device is locked, not merely permission-hidden. A stolen powered-off phone should yield ciphertext and nothing else. This is the property Android calls File-Based Encryption and iOS calls Data Protection; we have neither today.

This is the end-state design. It records what the platform can already do (with citations), what must be built, and where a decision is deliberately deferred. Where this and the code disagree, the code is right — fix one or the other in the same change. No v1/v2: the shape below is the whole product; components light up as their backends earn them (doctrine §6).

The problem, measured

On the reference laptop, ~/.souveraine today:

agents/              69M   world-readable, default perms
subconscious-agents/ 30M   world-readable
server/              24M   world-readable
events/             7.6M   world-readable
secrets/store.json          0600, AES-256-CBC, seed-derived key
seed-id/private.key   32B   0600

The one encrypted file is a rounding error beside ~130M of plaintext conversation history, memory, and ledger. When this was first measured (2026-07-20 morning), the store's key derived entirely from a seed file sitting on the same disk and the tree had no Argon2/AEAD at all — 0600 as the only real barrier, no secret living only in the user's head. That gap is now closed for the secrets store: items are sealed AES-256-GCM under a random store key, wrapped by a machined signature (private key outside the daemon) and optionally by an Argon2id passphrase slot (src/secrets/storage_key.rs). The measurement stands for everything else in ~/.souveraine — the ~130M of plaintext is still the problem this design exists to solve.

Three classes

Modelled on iOS Data Protection classes and Android DE/CE, but with a deliberate difference: most data evicts its key on lock. No shipping OS does this — Android never evicts on lock (only at reboot), and iOS's evict-on-lock class (A) is the exception, not the default. This is a novel synthesis and its costs are named honestly below.

Class Key lifetime Holds Analogue
Ambient Resident from boot; machine-seed-rooted, no human present Clock, weather, timers, alarms, transport controls, modem, boot path Android DE / iOS Class C-ish
Personal Installed on first unlock; evicted on lock Photos, messages, payment credentials, agent memory, calendar, conversation history iOS Class A (NSFileProtectionComplete)
Revealable Resident while locked, so the lockscreen can display it Notification previews, now-playing metadata, next-alarm text iOS Class C, scoped tiny

Maps onto the existing capability tiers (SESSION-TRUST-ARCHITECTURE.md): Ambient → ambient tier, Personal → personal/stepUp, Revealable is the storage face of doctrine §2's reveal-on-lock-surface feature — a fingerprint touch reveals personal content in place without unlocking. Revealable exists precisely so that feature survives key eviction: it is the only Personal-class data whose key we deliberately keep live while locked.

Scope Revealable ruthlessly. Everything kept resident while locked is, by construction, After-First-Unlock attack surface. Notification preview text, media metadata, alarm strings. Not message bodies, not thumbnails, not memory. When in doubt, it is Personal.

Key hierarchy

The doctrine already separates machine identity from agent identity (saf/identity/01-seed-identity.md, summon.rs:1-14): the machine seed signs "this left this box," the agent seed signs "this was done by her." That split is the encryption hierarchy.

Machine seed (Ed25519, /var/lib/souveraine/seed-id, machined)
  └─ HKDF → Ambient class key            [headless, boot-available]

User PIN/passphrase
  └─ Argon2id(salt) → KEK
       └─ (target) sealed by hardware throttle — see "Unlock"
       └─ wraps → Personal class key      [evicted on lock]
       └─ wraps → Revealable class key    [stays resident while locked]

Per-agent identity (own UNIX user — see below)
  └─ agent's own PIN/credential → Argon2id → agent KEK
       └─ wraps → that agent's Personal class key

The KEK wraps the class keys; the class keys are the fscrypt master keys. A password change re-wraps the class keys — it never re-encrypts data and never orphans it. Recovery (forgotten PIN) is a recovery-key wrap of the same class keys, generated at provision time; without it, forgotten PIN = data gone, by design.

Annie is her own user

Agents get their own UNIX accounts with scoped permissions. This is the P3 fix in audit-status.md — per-agent accounts are named there as the enabler for caller identity (P0/P1/P2) — and it resolves the "can Aster think while locked" problem for free: her memory is Personal-class under her uid, keyed by her credential, not the user's. The user locking their session does not evict her key. She keeps her own memory, cron-summons herself, reconciles at her own pace. Android's per-app-UID isolation, applied to agents.

This also means agent memory encryption and the per-agent-account work are one project, not two.

Unlock — binding the PIN to hardware

The user unlocks the phone by swiping up and typing a PIN. A PIN is low-entropy; a million guesses fall in under a second to any GPU against any KDF. Android survives this because Titan M holds the secret and rate-limits attempts in hardware (Weaver + Gatekeeper): ~20 tries, exponential backoff, unbypassable even under full TEE compromise because it is a separate secure element.

State of the hardware path on blueline (verified):

  • Titan M is present. Its interface is citadel-spi — a small SPI transport driver (/dev/citadelN, two ioctls) that exists only in the LOS 4.9 reference tree, not in our mainline kernel. The Weaver wire protocol is public (AOSP platform/external/nos/host/generic, Quarkslab's titanm toolkit).
  • CONFIG_QCOMTEE=m gives a real mainline Qualcomm TEE client, but CONFIG_TRUSTED_KEYS is off, and even flipped on there is no known loadable QSEE trustlet doing HUK-sealing for it to talk to. Plumbing without a cryptographic endpoint.
  • postmarketOS/Mobian on Qualcomm do plain passphrase-derived LUKS, no hardware throttling anywhere. There is no prior art to lean on; the reference is the LineageOS slot on this same device.

Consequence — two milestones, not two products:

  1. Ship: Argon2id over a real passphrase (not a 6-digit PIN) at first unlock. Aggressive parameters, honest about the ceiling: no hardware root of trust, so a passphrase with genuine entropy is required for the at-rest claim to mean anything. A 6-digit PIN here is theatre.
  2. Target: port citadel-spi (bounded — a few hundred lines, self-contained SPI/GPIO, model the DT node on sdm845-b1c1-citadel.dtsi) + a minimal Linux libnos_datagram backend speaking Weaver only. Then the PIN binds to hardware throttling and the "swipe-up-and-type-a-PIN" UX becomes secure. Inherits Titan M's known (Quarkslab-documented) vulnerability surface — using it means trusting a chip that has been partially broken.

The design assumes milestone 2 as the target and degrades honestly to milestone 1 until the citadel port lands. Kernel rebuilds to enable TRUSTED_KEYS/ENCRYPTED_KEYS/PERSISTENT_KEYRINGS/ANDROID_BINDERFS are in scope — that is what we do.

fscrypt is the mechanism

Not dm-crypt. fscrypt is per-directory with independent keys, which is what lets three classes and multiple per-agent keys coexist on one filesystem; whole-disk dm-crypt gives one key for everything and cannot express the split.

Platform (verified on blueline): CONFIG_FS_ENCRYPTION=y, FS_ENCRYPTION_INLINE_CRYPT=y, BLK_INLINE_ENCRYPTION=y, and the Snapdragon 845 Inline Crypto Engine is fully wired — CONFIG_QCOM_INLINE_CRYPTO_ENGINE=y, driver drivers/soc/qcom/ice.c feeding a blk_crypto_profile via ufs-qcom.c. Encryption runs in the storage controller at near-zero CPU cost, the same engine Android uses for FBE here. Root is ext4, which supports fscrypt natively.

Do not confuse ICE with QCE — and keep QCE off. The Inline Crypto Engine (QCOM_INLINE_CRYPTO_ENGINE, drivers/soc/qcom/ice.c) is our hardware at-rest path and is a separate driver from the Qualcomm Crypto Engine async-offload driver (CRYPTO_DEV_QCE, drivers/crypto/qce/). The blueline config currently has the QCE family flipped fully on (CONFIG_CRYPTO_DEV_QCE_ENABLE_ALL=y), but QCE is unused here — it isn't even loaded (/proc/crypto shows only qcom-rng) because its cra_priority is too low to ever be selected over the CPU. Upstream is marking QCE BROKEN and dropping it from default ARM/ARM64 builds in Linux 7.3: it is slower than the CPU, has a history of bugs, and — most relevant to us — "does not have exclusive access to the hardware, causing races with the secure world" (Phoronix, 2026-07-20, https://www.phoronix.com/news/Linux-QCE-Disabling; cryptodev commit marking it BROKEN). That last point is the same secure-world-contention hazard this doc flags for the Titan M path, and it is the reason our design uses ICE + the userspace AES-256-GCM secrets store, never QCE. Action: when we do the in-scope kernel rebuild (below), leave CRYPTO_DEV_QCE and its *_ENABLE_* sub-options OFF. We lose nothing (it was never selected) and avoid shipping a driver about to be BROKEN upstream.

Provisioning (Pixel3Arch): the rootfs is baked by mkfs.ext4 -d populating a tree (provision-rootfs.sh:220). fscrypt needs -O encrypt on the fs and a policy set on the target dirs before population — a change at that line plus a login-time unlock hook in rootfs-overlay/etc/pam.d/. The fscrypt userspace tool is not yet packaged; it must be. A custom initramfs hook (alongside blueline-debug) is the place for any pre-switch_root key supply. Migration of existing data is a non-issue — pre-users, provision fresh.

Known fscrypt limits (from kernel docs, carry into implementation):

  • Key removal while mounted works but does not wipe per-file keys for already-open files (FILES_BUSY, retryable). A process holding a Personal file open at lock keeps reading it until it closes. Eviction must be paired with closing handles.
  • Dirty-page / writeback behaviour on key removal is undocumented in the kernel itself. Test it; do not assume.
  • Evicting a key does nothing about plaintext already in page cache, app memory, or swap. This is the single most-ignored gap across Android, iOS, and fscrypt alike. Handle separately: disable/encrypt swap, and treat the eviction as "no new reads," not "RAM is clean."

Eviction on lock

Wire to the authoritative bit. screenLockSecure is set in exactly one place (LockScreen.qml:124, from WlSessionLock.onSecureChanged) and is the only proof suitable for personal disclosure; screenLocked is just the request. Evict on screenLockSecure, never on screenLocked.

The precedent exists: StepUpAuth.qml:345 already does revokeAll() of auth grants on lock, fail-closed. Key eviction is structurally identical — same Connections { target: GlobalStates } hook, different resource. Ai.qml:181 already redacts in-flight output on lock; SessionAudit.qml:145 hash-chains an entry on every transition and a key-eviction event should feed it too.

The gap: souveraine-secrets has zero lock awareness — it derives its key once at startup and holds it for process life; Unlock/Lock are no-ops (service.rs, collection.rs:139). And no D-Bus signal is emitted on lock/unlock today — SessionEvents only consumes logind signals. Options for delivery, decision deferred:

  1. The secrets daemon grows its own logind ingress (gdbus monitor, same shape as SessionEvents.qml; doctrine §6 permits).
  2. It becomes a client of sessiond's socket and drives eviction off Phase.
  3. sessiond gains a notification verb.

Unlock stops being a no-op once the daemon consults lock state; this closes audit-status.md P2 as a consequence, not separate work.

Federation — encrypt then push

The Gitea remote is untrusted by design. It is trusted in the reference deployment's private network, but the product cannot assume everyone has one — if confidentiality depended on the network being private, the software would only work in the author's house. So ciphertext crosses; TLS/PQC on the wire is hardening on top, not the confidentiality boundary.

Memory sync landed (a62379a, push/fetch to instance/{label} branches), superseding FEDERATION.md gap 1 — fix that doc in the same change.

Consequence for merge: encrypted content breaks git's three-way merge — git cannot diff what it cannot read, so FEDERATION.md's domain-aware merge policy (journal/ auto-merge, system/ never, else three-way) must run on plaintext, locally, after fetch+decrypt, then re-encrypt on push. The DAG still models divergence; reconciliation moves into our code. A system/ conflict is a key-split event, surfaced as felt, not auto-unioned.

Per-node key wrapping, not shared keys. Doctrine forbids the agent root key travelling to a node (saf/identity/01-seed-identity.md: "The root key does not travel to a federated node"; a copied key "cannot distinguish a legitimate fork from a stolen duplicate"). So Personal-class content keys are wrapped per commissioned node — each device gets its own wrapping via the commissioning ceremony (node.rs, currently #![allow(dead_code)]). Decommission a node → stop re-wrapping for it → revocation with teeth. This is the Signal/Matrix multi-device model, and it is what makes encrypt-then-push to untrusted storage sound.

Sequencing: per-node wrapping cannot ship before commissioning is real (provisioning-gaps.md: first-boot commissioning is manual today). Encryption and the commissioning ceremony are one project. The legacy pre-split seed (seed-id.pre-split-20260716 on the phone) is an artifact of the superseded one-seed model; the phone should get a node key via commissioning, not a restored old seed. Restore-vs-regenerate is therefore mostly moot — the only live question is whether anything under the old seed needs recovering, and store.json is the sole seed-derived ciphertext (pre-users: nothing).

App permission gating (separate track, shared foundation)

Encryption-at-rest and permission-gating are the same project from two angles; both rest on identity per application. Recorded here so the foundation is built once; the gating design is its own doc.

  • Surface: org.freedesktop.portal.* (Documents, Camera, Location, Secret) — the interface third-party software already speaks. Not a bespoke Souveraine API. Doctrine §6: only expose a kind once it owns a real backend. Design the full contract once; light up backends as they earn it (this is the anti-v1/v2 discipline, not a crippled first slice).
  • Identity, three tiers: Flatpak app ID from the bwrap sandbox (free, once Flatpak is installed — it is not today), Waydroid/Android UID inside the container (free once binder is up — ANDROID_BINDER_IPC=y, but ANDROID_BINDERFS off is a real Waydroid blocker, kernel change), per-agent UNIX accounts for native callers (the Personal-class work above).
  • Nothing installed today: no Flatpak, bubblewrap, xdg-desktop-portal, or Waydroid in either repo. The namespace/cgroup/seccomp primitives are compiled in and unused. "Free granularity from Flatpak" is a design premise that requires installing Flatpak first.
  • Runtime vs at-rest: fscrypt protects a powered-off device. It does not stop a process in the unlocked session reading another's files — that is the runtime half, and per-app UIDs are its foundation too. At-rest encryption alone must not be sold as "app data is locked down"; the runtime half is per-app identity, tracked separately.

Also found while mapping (fix independently)

  • Gitea tokens in plaintext. Two agents have http://<token>@10.10.20.120:4455/... remotes — credentials in .git/config at default perms next to a 0600 seed, and http:// so tokens cross the LAN in clear on every push. Not git-tracked, nothing leaked upstream. Survivable on a private LAN, not as shipped product.
  • Agent seeds safe positionally, not by design. agents/{id}/seed/ is a sibling of the memory/ git root, so sync structurally cannot push private keys (verified: git ls-files | grep seed empty on live agents). But nothing enforces it — no .gitignore, no runtime check. Harden as part of this work.

Status

Piece State
Kernel crypto primitives (ext4 fscrypt, ICE inline) Present, verified, unused
Qualcomm QCE async-offload driver (CRYPTO_DEV_QCE) On in config but never loaded/selected; keep OFF — BROKEN upstream in 7.3, races with secure world. Not our path (we use ICE + userspace AEAD)
TRUSTED_KEYS/ENCRYPTED_KEYS/PERSISTENT_KEYRINGS/BINDERFS Off — kernel rebuild in scope
fscrypt userspace + provisioning hook Not built; hook points identified
Three-class model Designed here; nothing built
Secrets store AEAD (AES-256-GCM, machine wrap via machined) Built, live on the phone, reboot-verified
Argon2id passphrase unlock (milestone 1) Built for the secrets store key wrap (Manage verbs live); ingress (stepUp/PAM) pending, options 1-3 undecided
citadel-spi port + Weaver (milestone 2, hardware throttle) Not built; bounded, referenced
Per-agent UNIX accounts + own-key memory Not built; enables P0-P3
Eviction-on-lock hook + secrets lock-awareness Not built; precedent + delivery options identified
Per-node key wrapping Blocked on commissioning ceremony (dead-code today)
Portal permission backends Not built; nothing installed