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.
344 lines
19 KiB
Markdown
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 |
|