Skip to main content

cratefield_module_sealed/
lib.rs

1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4pub mod audit;
5pub mod notify;
6pub mod record;
7pub mod store;
8
9mod handlers;
10mod outer;
11
12use std::sync::Arc;
13
14use cratefield_core::{
15    Config, ConfigError, DataKind, Disposition, Migrations, Module, ModuleContext, PersonalDataSet,
16    Port, SqlMigration,
17};
18use cratefield_kms::Kms;
19
20pub use crate::notify::{
21    MailNotifier, NoopNotifier, Notice, NotifyError, UnlockNotifier,
22    conformance as notifier_conformance,
23};
24
25/// The module's name: mounted at `/v1/sealed`.
26pub const MODULE_NAME: &str = "sealed";
27
28/// The largest request body any route of this module accepts: what
29/// [`Module::max_body_bytes`] reports — so the runtime's door guard and the
30/// router's own per-route `DefaultBodyLimit` cannot disagree — and a full
31/// order of magnitude above the harness-wide default, because a record
32/// carries a payload plus a wrap set.
33pub const MAX_RECORD_BYTES: usize = 1024 * 1024;
34
35/// Why the sealed store refused something on the way in, or noticed
36/// tampering on the way out. The route layer turns these into problem+json;
37/// [`store::StoreError`] is the storage-shaped superset.
38#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39pub enum SealedError {
40    /// A field failed its wire grammar.
41    #[error("{0}")]
42    BadInput(String),
43    /// A wrap failed the shape invariants `keys.ts` enforces.
44    #[error("{0}")]
45    BadWrap(String),
46    /// A recovery wrap's Argon2id cost is below the accepted floor —
47    /// refused, so a record can never be the weak link.
48    #[error("{0}")]
49    WeakParams(String),
50    /// The audit chain does not follow from genesis; `verify` named the row.
51    #[error("{0}")]
52    ChainBroken(String),
53    /// An outer ciphertext did not authenticate.
54    #[error("{0}")]
55    Tampered(String),
56    /// The RNG failed, or a state was reached that only a writer outside
57    /// this module could have produced.
58    #[error("{0}")]
59    Internal(String),
60}
61
62impl From<cratefield_core::DbError> for SealedError {
63    fn from(err: cratefield_core::DbError) -> Self {
64        Self::Internal(format!("a database statement failed: {err}"))
65    }
66}
67
68/// The sealed-blob store. Compose it with a [`Kms`] — the only thing that
69/// can open a wrapped data key, and therefore the thing erasure and
70/// compromise both answer to — and an [`UnlockNotifier`] for the per-download
71/// notice ([`NoopNotifier`] when none is wanted).
72pub struct Sealed {
73    pub(crate) kms: Arc<dyn Kms>,
74    pub(crate) notifier: Arc<dyn UnlockNotifier>,
75}
76
77impl Sealed {
78    /// A module that seals under `kms` and notifies through `notifier`.
79    #[must_use]
80    pub fn new(kms: Arc<dyn Kms>, notifier: Arc<dyn UnlockNotifier>) -> Self {
81        Self { kms, notifier }
82    }
83}
84
85/// The first migration: the three tables of the crypto-shredding contract.
86/// The Postgres set differs only in `BYTEA` for `BLOB` and in how
87/// append-only is enforced; see `migrations/`.
88const MIGRATION_INIT: SqlMigration = SqlMigration::new(
89    "0001",
90    "init",
91    include_str!("../migrations/sqlite/0001_init.sql"),
92);
93
94const MIGRATION_INIT_PG: SqlMigration = SqlMigration::new(
95    "0001",
96    "init",
97    include_str!("../migrations/postgres/0001_init.sql"),
98);
99
100/// `revision` widened to BIGINT on Postgres; a no-op on SQLite, whose
101/// INTEGER is already 64-bit.
102const MIGRATION_REVISION_BIGINT: SqlMigration = SqlMigration::new(
103    "0002",
104    "revision_bigint",
105    include_str!("../migrations/sqlite/0002_revision_bigint.sql"),
106);
107
108const MIGRATION_REVISION_BIGINT_PG: SqlMigration = SqlMigration::new(
109    "0002",
110    "revision_bigint",
111    include_str!("../migrations/postgres/0002_revision_bigint.sql"),
112);
113
114impl Module for Sealed {
115    fn name(&self) -> &'static str {
116        MODULE_NAME
117    }
118
119    fn version(&self) -> &'static str {
120        env!("CARGO_PKG_VERSION")
121    }
122
123    fn requires(&self) -> &'static [Port] {
124        // Auth: every route is subject-scoped, and a deployment that cannot
125        // identify callers has no sealed store, only a public ciphertext
126        // drop. Blob: bodies above `INLINE_BODY_MAX_BYTES` have nowhere to
127        // live without one.
128        &[Port::Db, Port::Auth, Port::Blob]
129    }
130
131    fn optional(&self) -> &'static [Port] {
132        // RateLimiter: reads fail open without one — the durable backstops
133        // are the auth gate and per-subject row scoping. Clock: wall-clock
134        // fallback for timestamps.
135        &[Port::RateLimiter, Port::Clock]
136    }
137
138    fn tables(&self) -> &'static [&'static str] {
139        &["sealed_blobs", "sealed_deks", "sealed_audit"]
140    }
141
142    fn personal_data(&self) -> &'static [PersonalDataSet] {
143        const SETS: &[PersonalDataSet] = &[
144            PersonalDataSet {
145                table: "sealed_blobs",
146                subject: "subject",
147                kind: DataKind::Content,
148                disposition: Disposition::Erase,
149                description: "Blobs you sealed in your browser: we hold the encrypted \
150                     bytes and the wrapped keys for them — never the plaintext, and \
151                     never the key that opens it. An export lists the metadata, the \
152                     wrap set and our own encrypted copy of the body; the copy you \
153                     can open exists only in your browser. Erasing a blob deletes it \
154                     and the internal key that unlocks our encrypted copy, after \
155                     which nothing on our side can open what you stored.",
156                redacted: &[],
157                subject_via: None,
158            },
159            PersonalDataSet {
160                table: "sealed_deks",
161                subject: "subject",
162                kind: DataKind::Identifier,
163                disposition: Disposition::Erase,
164                description: "The internal, wrapped key that unlocks our encrypted copy \
165                     of each of your blobs, kept in its own table so erasure deletes it \
166                     first — the step that makes every remaining copy unreadable. It is \
167                     key material, so an export lists it without copying it.",
168                redacted: &["wrapped_dek"],
169                subject_via: None,
170            },
171            PersonalDataSet {
172                table: "sealed_audit",
173                subject: "subject",
174                kind: DataKind::Usage,
175                disposition: Disposition::Retain(
176                    "The access chain is append-only and hash-linked: its value is that \
177                     no one — including an operator — can remove one person's rows \
178                     without breaking the proof that nobody altered anyone else's. A \
179                     row holds your account id, the blob's id, the action and the time, \
180                     and never any content, because the server never has any.",
181                ),
182                description: "Which of your blobs were stored, opened, edited, rotated or \
183                     erased, and when.",
184                redacted: &[],
185                subject_via: None,
186            },
187        ];
188        SETS
189    }
190
191    fn migrations(&self) -> Migrations {
192        const MIGRATIONS: [SqlMigration; 2] = [MIGRATION_INIT, MIGRATION_REVISION_BIGINT];
193        const MIGRATIONS_PG: [SqlMigration; 2] = [MIGRATION_INIT_PG, MIGRATION_REVISION_BIGINT_PG];
194        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
195        Migrations {
196            sqlite: &MIGRATIONS,
197            postgres: &MIGRATIONS_PG,
198        }
199    }
200
201    fn validate_config(&self, _cfg: &dyn Config) -> Result<(), ConfigError> {
202        // Nothing to configure: every choice — the KMS, the notifier, the
203        // sending address — is a constructor argument a venture composes in
204        // code, so there is no key to miss and no value to mistype here.
205        Ok(())
206    }
207
208    fn max_body_bytes(&self, _cfg: &dyn Config) -> usize {
209        MAX_RECORD_BYTES
210    }
211
212    fn router(&self, ctx: ModuleContext) -> axum::Router {
213        handlers::router(self, ctx)
214    }
215}