Watch
1
0
Fork
You've already forked SouveraineOS
0

secrets landed: archive TASK-11, storage-encryption status, handoff

souveraine-secrets is live on the phone (reboot-verified, keyring
masked) — TASK-11 complete, moved to tasks/archive/ with the outcome.
STORAGE-ENCRYPTION.md: add the doc (design of record, 2026-07-20) with
status updated to match code — secrets-store AEAD + machine wrap built,
Argon2id wrap built with ingress pending; the derive_storage_key gap
paragraph now scoped to what remains. Add session handoff.
This commit is contained in:
Fimeg 2026-07-20 21:20:38 -04:00
commit ae6e2be533
3 changed files with 461 additions and 0 deletions

304
docs/STORAGE-ENCRYPTION.md Normal file
View file

@ -0,0 +1,304 @@
---
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.
### 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.
**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 |
| `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 |