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 (AOSPplatform/external/nos/host/generic, Quarkslab'stitanmtoolkit). CONFIG_QCOMTEE=mgives a real mainline Qualcomm TEE client, butCONFIG_TRUSTED_KEYSis 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:
- 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.
- Target: port
citadel-spi(bounded — a few hundred lines, self-contained SPI/GPIO, model the DT node onsdm845-b1c1-citadel.dtsi) + a minimal Linuxlibnos_datagrambackend 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:
- The secrets daemon grows its own logind ingress (
gdbus monitor, same shape asSessionEvents.qml; doctrine §6 permits). - It becomes a client of
sessiond's socket and drives eviction offPhase. sessiondgains 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, butANDROID_BINDERFSoff 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/configat default perms next to a 0600 seed, andhttp://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 thememory/git root, sosyncstructurally cannot push private keys (verified:git ls-files | grep seedempty 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 |