Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/STORAGE-ENCRYPTION.md
Fimeg 341a2fe060 docs: take the lockscreen out of casey's uid
The current user unit wins the boot race but still runs as the human whose Personal key the lock is meant to evict. Record the target souveraine-session principal, its narrow Wayland/PAM/data reach, and the fact that the rich shell is presentation—not release authority.
2026-08-17 12:43:45 -04:00

344 lines
19 KiB
Markdown

---
description: Storage encryption at rest — three-class model, per-agent keys, hardware-throttled unlock, portal-shaped permissions
status: Design of record — secrets store built (AEAD, machine wrap, Argon2id wrap slot; live on the phone); rest of userspace greenfield, kernel primitives present
date: 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.
### Each admitted agent 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). The living account, admission, worker, health, and
generated operating-skill contract is
`../../souveraine/saf/identity/02-agent-principal.md`.
The existing `souveraine` system account belongs to the machine tier; it is
not Souvie. The initial agent set uses distinct local principals for Souvie,
Annie, and Vanguard on each body where they are admitted. Primary and
subconscious positions share their agent's principal unless the subconscious
is later admitted as a distinct authority.
Own principals also resolve the "can the agent think while the human is
locked" problem: her memory is Personal-class **under her uid, keyed by her
credential**, not the human's. Locking the human session does not evict the
agent's key. She keeps her own memory, schedules herself, and reconciles at her
own pace. Android's per-app-UID isolation, applied to agents.
This also means agent memory encryption and per-agent admission are one
project, not two. Merely adding passwd entries while one human-owned server
still executes every turn does not provide the boundary.
## 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.
The lockscreen must not run as the human whose Personal key it evicts. The
target is a dedicated `souveraine-session` principal: package code plus
Ambient and tightly scoped Revealable state, with no access to Casey's home or
any agent's memory. PAM authenticates Casey; it does not make the lock
authority Casey or hand it the decrypted Personal tree. The living owner and
current user-unit gap are recorded in
`../../souveraine/saf/authority/01-session.md`.
## 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).
- **Waydroid is installed now:** measured 2026-08-07 on the phone with
`ANDROID_BINDER_IPC=y` and `ANDROID_BINDERFS=y`. Its container unit was
active while its session was stopped; [`WAYDROID.md`](WAYDROID.md) owns that
layered status. Flatpak and an app-identity portal backend remain absent, so
the runtime-isolation design here is still unbuilt.
- **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 |