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:
parent
b736139590
commit
ae6e2be533
3 changed files with 461 additions and 0 deletions
304
docs/STORAGE-ENCRYPTION.md
Normal file
304
docs/STORAGE-ENCRYPTION.md
Normal 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 |
|
||||
Loading…
Reference in a new issue