cratefield-module-sealed 0.1.2

Cratefield module: the server half of client-side sealed blobs — opaque ciphertext at rest, crypto-shred erasure, no server-side recovery path
Documentation

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 below INLINE_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::verify walks 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; 201 with 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 by If-Match (below).
  • PUT /blobs/{blob_id} — content-key rotation: a new payload and wrap set under a fresh DEK, version bumped. Guarded by If-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-Match is refused with 428 (sealed-precondition-required) rather than applied blind.
  • One whose If-Match no longer matches — somebody wrote first — is refused with 412 (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.