Expand description
§cratefield-module-sealed
The server half of Cratefield’s sealed-blob store (issue #757). A client —
the @cratefield/sealed TypeScript package — encrypts data in the browser
under a content key that is wrapped once per unlock (a passkey PRF output or
an Argon2id recovery code) and posts the result here. This module stores it,
serves it back byte-identical, and never learns what it holds.
use cratefield_kms::{Dek, Kms, KmsError};
use cratefield_module_sealed::{NoopNotifier, Sealed};
struct MyKms;
#[async_trait::async_trait]
impl Kms for MyKms {
fn provider(&self) -> &'static str {
"example"
}
fn key_ref(&self) -> &str {
"example-key"
}
// A real provider wraps the data key under its master key, and
// `unwrap` reverses exactly that; this stub stores nothing anywhere.
async fn wrap(&self, dek: &Dek) -> Result<Vec<u8>, KmsError> {
Ok(dek.expose().to_vec())
}
async fn unwrap(&self, wrapped: &[u8]) -> Result<Dek, KmsError> {
Dek::from_bytes(wrapped.to_vec())
}
}
let module =
Sealed::new(std::sync::Arc::new(MyKms), std::sync::Arc::new(NoopNotifier));Mounted at /v1/sealed. The Db, Auth and Blob ports are declared
required, RateLimiter and Clock used-when-present, and a
cratefield_kms::Kms comes with the constructor — the KMS is not a
Port because the harness has no binding for one yet; a venture wires the
provider it chose when it composes the module.
§What the server holds
The client posts {blob_id, version, purpose, alg, ciphertext, wraps, created_by_credential}. The server treats ciphertext and wraps as
opaque bytes — it validates their wire shape and nothing else. On the way in
it seals the client’s ciphertext body under a fresh per-blob data key
(AES-256-GCM, additional authenticated data binding
subject|blob_id|version) and stores the DEK wrapped by the KMS. Three
tables:
sealed_blobs— one row per(subject, blob_id): the outer-wrapped body (inline belowINLINE_BODY_MAX_BYTES, in the blob store above it), the client’s wraps as JSON, and the metadata the wire record carries.sealed_deks— the wrapped per-blob data key, in a table of its own so erasure is a visible, countable row deletion.sealed_audit— the append-only, hash-chained access log. Every read is appended;crate::audit::verifywalks the chain and names the first broken link. An append that loses a sequence race retries on the new head; under sustained contention it fails loudly — refusing the request — rather than let a mutation commit unrecorded.
The outer wrap is defence in depth, not a decryption service. The server unwraps a DEK only to serve a record back to its subject; it has no code path — no endpoint, no function — that decrypts the client layer, and the content key itself never exists outside the user’s device. There is no server-side recovery path: lose every unlock and the data is gone, by design.
§Erasure is crypto-shredding
DELETE /v1/sealed/blobs/{id} deletes the DEK row first, then the body row
and the blob-store object, in one atomic batch followed by the object
removal. From the moment the DEK row is gone, every remaining copy of the
body — in a database backup, a replica that has not caught up, an object the
object-store delete missed — is inert ciphertext. The same shred happens
through cratefield-module-privacy, whose erasure deletes both declared
tables from the personal-data catalog. What an export returns is the
metadata, the wrap set, and the body as the server holds it — in the outer
wrap, so not bytes the user can open; a venture honouring a portability
request serves the wire record through the API instead, which hands back
exactly the client ciphertext.
The sealed_audit rows are declared Retain: the chain is append-only and
tamper-evident precisely so that no one — including an operator — can
remove one person’s rows without breaking the proof that nobody altered
anyone else’s. A retained row holds the subject id, the blob id, the action
and the time. It never holds content, because the server never has any.
§Routes
All routes are subject-scoped: the caller must present a credential the
deployment’s Auth port accepts, and every query is bounded by the
authenticated subject’s id, never by a value in the request.
POST /blobs— create;201with the stored record.GET /blobs— list the subject’s blob metadata (no bodies).GET /blobs/{blob_id}— read; rate-limited per subject and appended to the audit chain, and fires the “blob unlocked” notification.PUT /blobs/{blob_id}/wraps— replace the wrap set; the payload is untouched, and at least two wraps remain required. Guarded byIf-Match(below).PUT /blobs/{blob_id}— content-key rotation: a new payload and wrap set under a fresh DEK,versionbumped. Guarded byIf-Match(below).DELETE /blobs/{blob_id}— erase; the crypto-shred above.
created_by_credential comes from the request body: the signed-in subject
(Auth) exposes an id, a session and a verified address, but not the
WebAuthn credential behind the session, and the client — which ran the
ceremony — records which credential owns the PRF wrap. The server validates
that it is present and stores it opaquely.
§Concurrent writes: ETag and If-Match
Every record response — read, create, wrap edit, rotation, and the list —
carries an ETag naming the server-side revision the response was built
from (the revision column; version cannot serve, because the client’s
payload AAD binds it, so a wrap edit must leave it alone). The two mutating
routes are compare-and-swap writes: they must echo that ETag back as
If-Match.
- A wrap edit or rotation without
If-Matchis refused with428(sealed-precondition-required) rather than applied blind. - One whose
If-Matchno longer matches — somebody wrote first — is refused with412(sealed-precondition-failed), and writes nothing: of two rotations racing from the same revision exactly one lands, and the loser’s new body never mixes with the winner’s key material.If-Match: *is not accepted — “any state will do” is the one thing a compare-and-swap write may not assert.
The guard lives in the storage layer itself: the UPDATE carries the
revision in its WHERE clause and writes a fresh value there, and the
read-back after the batch attributes the row’s state to one request. A lost
race therefore cannot commit half of itself, at any layer. The wire body is
unchanged — the TypeScript client keeps sending {version, wraps} and only
adds the header. A body too big for the row parks in the blob store under an
object key unique to that one write, so a rotation frees the previous body
only after the commit names the new one, and a refused create never unparks
a body that belongs to a live record.
§Notification
crate::notify::UnlockNotifier is the “blob unlocked” hook fired on every
download. The default is a no-op; crate::notify::MailNotifier sends a
short notice through the harness Mailer port to the subject’s verified
address when the deployment’s verifier provides one (no address, no notice —
the server does not guess one); a recording fake backs the tests, and
crate::notify::conformance holds any implementation to the contract. A
notification failure is logged and never fails the download.
§Documented limits
- Offline automation is impossible by design. Opening a record needs a user unlock — a passkey touch or a recovery code — so nothing server-side, scheduled or otherwise, can read a blob. A venture that needs server-side automation over this data should not use this module for it.
- A compromised server can serve malicious JavaScript. The client runs in the browser, so an attacker who controls what the origin serves can steal unlocks as they happen — no storage design helps. Mitigate by serving the app from a separate static origin, pinning scripts with SRI, a strict CSP, and reviewing signed releases.
- Custody reads on the key. Some regulators treat access to a user’s encrypted key material as custody of the asset it protects (SEC staff statement of 2026-04-13). A venture whose sealed blobs protect regulated assets should prefer designs where no one but the user ever holds the key — passkey-owned smart accounts — over any layout in which this module’s tables are load-bearing.
- No server-side recovery path, as above. This is a promise, not a gap: there is no escrowed key, no override, and no endpoint that returns plaintext.
Re-exports§
pub use crate::notify::MailNotifier;pub use crate::notify::NoopNotifier;pub use crate::notify::Notice;pub use crate::notify::NotifyError;pub use crate::notify::UnlockNotifier;pub use crate::notify::conformance as notifier_conformance;
Modules§
- audit
- The append-only, tamper-evident access chain, modelled on
cratefield-secrets’ audit ledger. - notify
- The “blob unlocked” notification: a small port, because a download is worth telling the subject about and nothing else in the harness fires on one.
- record
- The wire record, and the validation the server does on the way in.
- store
- The sealed store’s tables, read and written in the portable SQL subset (ADR 0004). Every query is bounded by the subject the caller’s verified credential resolved to — a blob id alone names nothing.
Structs§
- Sealed
- The sealed-blob store. Compose it with a
Kms— the only thing that can open a wrapped data key, and therefore the thing erasure and compromise both answer to — and anUnlockNotifierfor the per-download notice (NoopNotifierwhen none is wanted).
Enums§
- Sealed
Error - Why the sealed store refused something on the way in, or noticed
tampering on the way out. The route layer turns these into problem+json;
store::StoreErroris the storage-shaped superset.
Constants§
- MAX_
RECORD_ BYTES - The largest request body any route of this module accepts: what
Module::max_body_bytesreports — so the runtime’s door guard and the router’s own per-routeDefaultBodyLimitcannot disagree — and a full order of magnitude above the harness-wide default, because a record carries a payload plus a wrap set. - MODULE_
NAME - The module’s name: mounted at
/v1/sealed.