1use std::sync::Arc;
9
10use chacha20poly1305::aead::{Aead, KeyInit, Payload};
11use cratefield_core::{Database, Row, Statement};
12use cratefield_kms::{Dek, Kms};
13use sea_query::Value as SeaValue;
14
15use crate::{
16 Access, Actor, Audit, AuditEvent, CIPHER, SecretBytes, SecretMeta, SecretsError, StoreId,
17 TracingAudit, Version, aad, scoped_store,
18};
19
20const NONCE_LEN: usize = 24;
21
22pub struct HarnessOnly(());
56
57pub struct Secrets {
59 kms: Arc<dyn Kms>,
60 audit: Arc<dyn Audit>,
61}
62
63impl Secrets {
64 #[must_use]
67 pub fn new(kms: Arc<dyn Kms>) -> Self {
68 Self {
69 kms,
70 audit: Arc::new(TracingAudit),
71 }
72 }
73
74 #[must_use]
75 pub fn with_audit(mut self, audit: Arc<dyn Audit>) -> Self {
76 self.audit = audit;
77 self
78 }
79
80 #[must_use]
109 pub fn global(&self, db: Arc<dyn Database>, _proof: HarnessOnly) -> SecretStore {
110 self.store(StoreId::Global, db)
111 }
112
113 pub fn tenant(&self, tenant: &str, db: Arc<dyn Database>) -> Result<SecretStore, SecretsError> {
130 if tenant == "global" {
131 return Err(SecretsError::Invalid(
132 "`global` names the control plane's store, which only the harness may open"
133 .to_owned(),
134 ));
135 }
136 if tenant.trim().is_empty() {
137 return Err(SecretsError::Invalid(
138 "a tenant id cannot be blank: a store stamped with nothing is nobody's".to_owned(),
139 ));
140 }
141 Ok(self.store(StoreId::Tenant(tenant.to_owned()), db))
142 }
143
144 #[cfg(feature = "control-plane")]
159 #[must_use]
160 pub fn control_plane_global(&self, db: Arc<dyn Database>) -> SecretStore {
161 self.store(StoreId::Global, db)
162 }
163
164 fn store(&self, id: StoreId, db: Arc<dyn Database>) -> SecretStore {
165 SecretStore {
166 id,
167 db,
168 kms: Arc::clone(&self.kms),
169 audit: Arc::clone(&self.audit),
170 }
171 }
172
173 #[must_use]
186 #[allow(
187 dead_code,
188 reason = "the harness wiring that calls this lands with #23/#29"
189 )]
190 pub(crate) fn harness_only() -> HarnessOnly {
191 HarnessOnly(())
192 }
193}
194
195pub struct SecretStore {
197 id: StoreId,
198 db: Arc<dyn Database>,
199 kms: Arc<dyn Kms>,
200 audit: Arc<dyn Audit>,
201}
202
203impl SecretStore {
204 #[must_use]
205 pub fn id(&self) -> &StoreId {
206 &self.id
207 }
208
209 pub(crate) fn db(&self) -> &Arc<dyn Database> {
210 &self.db
211 }
212
213 pub(crate) fn kms(&self) -> &Arc<dyn Kms> {
214 &self.kms
215 }
216
217 pub(crate) fn seal(
221 &self,
222 name: &str,
223 version: Version,
224 key_id: &str,
225 dek: &Dek,
226 plaintext: &[u8],
227 ) -> Result<(Vec<u8>, Vec<u8>), SecretsError> {
228 let mut nonce = [0_u8; NONCE_LEN];
229 getrandom::fill(&mut nonce)
230 .map_err(|err| SecretsError::Invalid(format!("the OS random source failed: {err}")))?;
231 let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
232 .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
233 let ciphertext = cipher
234 .encrypt(
235 (&nonce).into(),
236 Payload {
237 msg: plaintext,
238 aad: &aad(&self.id, name, version, key_id),
239 },
240 )
241 .map_err(|_| SecretsError::Invalid("the secret could not be sealed".to_owned()))?;
242 Ok((nonce.to_vec(), ciphertext))
243 }
244
245 pub(crate) async fn open_version(
248 &self,
249 name: &str,
250 version: Version,
251 key_id: &str,
252 dek: &Dek,
253 ) -> Result<SecretBytes, SecretsError> {
254 let rows = self
255 .db
256 .query(&Statement::with_values(
257 format!(
258 "SELECT nonce, ciphertext FROM harness_secrets \
259 WHERE name = ? AND version = ? AND {}",
260 scoped_store()
261 ),
262 vec![
263 text(name),
264 SeaValue::BigInt(Some(i64::from(version))),
265 text(self.id.as_str()),
266 ],
267 ))
268 .await?;
269 let row = rows.first().ok_or_else(|| SecretsError::NotAuthentic {
270 store: self.id.to_string(),
271 name: name.to_owned(),
272 version,
273 })?;
274 let nonce: Vec<u8> = row.get("nonce").unwrap_or_default();
275 let ciphertext: Vec<u8> = row.get("ciphertext").unwrap_or_default();
276 let nonce: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| SecretsError::NotAuthentic {
277 store: self.id.to_string(),
278 name: name.to_owned(),
279 version,
280 })?;
281 let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
282 .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
283 let plaintext = cipher
284 .decrypt(
285 (&nonce).into(),
286 Payload {
287 msg: &ciphertext,
288 aad: &aad(&self.id, name, version, key_id),
289 },
290 )
291 .map_err(|_| SecretsError::NotAuthentic {
292 store: self.id.to_string(),
293 name: name.to_owned(),
294 version,
295 })?;
296 Ok(SecretBytes::new(plaintext))
297 }
298
299 pub(crate) async fn prepare_key(
303 &self,
304 state: &str,
305 ) -> Result<(String, Dek, Statement), SecretsError> {
306 let dek = Dek::generate()?;
307 let wrapped = self.kms.wrap(&dek).await?;
308 let key_id = new_key_id()?;
309 let insert = Statement::with_values(
310 "INSERT INTO harness_secret_keys \
311 (key_id, kms_provider, kms_key_ref, wrapped_dek, cipher, state, created_at, \
312 store) \
313 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
314 vec![
315 text(&key_id),
316 text(self.kms.provider()),
317 text(self.kms.key_ref()),
318 bytes(wrapped),
319 text(CIPHER),
320 text(state),
321 text(&now()),
322 text(self.id.as_str()),
323 ],
324 );
325 Ok((key_id, dek, insert))
326 }
327
328 pub(crate) async fn audit_action(
331 &self,
332 access: Access,
333 actor: &Actor,
334 allowed: bool,
335 ) -> Result<(), SecretsError> {
336 self.audit(access, actor, None, None, allowed).await
337 }
338
339 pub async fn put(
348 &self,
349 name: &str,
350 value: &SecretBytes,
351 actor: &Actor,
352 ) -> Result<Version, SecretsError> {
353 let result = self.put_inner(name, value).await;
354 self.audit(
355 Access::Put,
356 actor,
357 Some(name),
358 result.as_ref().ok().copied(),
359 result.is_ok(),
360 )
361 .await?;
362 result
363 }
364
365 async fn put_inner(&self, name: &str, value: &SecretBytes) -> Result<Version, SecretsError> {
366 validate_name(name)?;
367 let (key_id, dek) = self.active_key().await?;
368 let version = self.next_version(name).await?;
369
370 let mut nonce = [0_u8; NONCE_LEN];
371 getrandom::fill(&mut nonce)
372 .map_err(|err| SecretsError::Invalid(format!("the OS random source failed: {err}")))?;
373 let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
374 .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
375 let ciphertext = cipher
376 .encrypt(
377 (&nonce).into(),
378 Payload {
379 msg: value.expose(),
380 aad: &aad(&self.id, name, version, &key_id),
381 },
382 )
383 .map_err(|_| SecretsError::Invalid("the secret could not be sealed".to_owned()))?;
384
385 self.db
386 .execute(&Statement::with_values(
387 "INSERT INTO harness_secrets \
388 (name, version, key_id, nonce, ciphertext, created_at, created_by, store) \
389 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
390 vec![
391 text(name),
392 SeaValue::BigInt(Some(i64::from(version))),
393 text(&key_id),
394 bytes(nonce.to_vec()),
395 bytes(ciphertext),
396 text(&now()),
397 text("pending"),
398 text(self.id.as_str()),
399 ],
400 ))
401 .await?;
402 Ok(version)
403 }
404
405 pub async fn get(
413 &self,
414 name: &str,
415 actor: &Actor,
416 ) -> Result<Option<SecretBytes>, SecretsError> {
417 let result = self.get_inner(name).await;
418 let version = result
419 .as_ref()
420 .ok()
421 .and_then(|found| found.as_ref().map(|(v, _)| *v));
422 self.audit(Access::Get, actor, Some(name), version, result.is_ok())
423 .await?;
424 result.map(|found| found.map(|(_, value)| value))
425 }
426
427 async fn get_inner(&self, name: &str) -> Result<Option<(Version, SecretBytes)>, SecretsError> {
428 validate_name(name)?;
429 let rows = self
430 .db
431 .query(&Statement::with_values(
432 format!(
433 "SELECT version, key_id, nonce, ciphertext FROM harness_secrets \
434 WHERE name = ? AND deleted_at IS NULL AND {} \
435 ORDER BY version DESC LIMIT 1",
436 scoped_store()
437 ),
438 vec![text(name), text(self.id.as_str())],
439 ))
440 .await?;
441 let Some(row) = rows.first() else {
442 return Ok(None);
443 };
444 let version = version_of(row)?;
445 let key_id: String = row
446 .get("key_id")
447 .ok_or_else(|| SecretsError::Invalid("a secret row has no key id".to_owned()))?;
448 let nonce: Vec<u8> = row
449 .get("nonce")
450 .ok_or_else(|| SecretsError::Invalid("a secret row has no nonce".to_owned()))?;
451 let ciphertext: Vec<u8> = row
452 .get("ciphertext")
453 .ok_or_else(|| SecretsError::Invalid("a secret row has no ciphertext".to_owned()))?;
454 if nonce.len() != NONCE_LEN {
455 return Err(SecretsError::NotAuthentic {
456 store: self.id.to_string(),
457 name: name.to_owned(),
458 version,
459 });
460 }
461
462 let dek = self.key(&key_id).await?;
463 let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(dek.expose())
464 .map_err(|_| SecretsError::NoKey(self.id.to_string()))?;
465 let nonce: [u8; NONCE_LEN] = nonce.try_into().expect("length checked");
466 let plaintext = cipher
467 .decrypt(
468 (&nonce).into(),
469 Payload {
470 msg: &ciphertext,
471 aad: &aad(&self.id, name, version, &key_id),
472 },
473 )
474 .map_err(|_| SecretsError::NotAuthentic {
475 store: self.id.to_string(),
476 name: name.to_owned(),
477 version,
478 })?;
479 Ok(Some((version, SecretBytes::new(plaintext))))
480 }
481
482 pub async fn list(&self, actor: &Actor) -> Result<Vec<SecretMeta>, SecretsError> {
488 let result = self.list_inner().await;
489 self.audit(Access::List, actor, None, None, result.is_ok())
490 .await?;
491 result
492 }
493
494 async fn list_inner(&self) -> Result<Vec<SecretMeta>, SecretsError> {
495 let rows = self
496 .db
497 .query(&Statement::with_values(
498 format!(
499 "SELECT name, version, created_at, created_by, deleted_at \
500 FROM harness_secrets WHERE {} ORDER BY name ASC, version DESC",
501 scoped_store()
502 ),
503 vec![text(self.id.as_str())],
504 ))
505 .await?;
506 let mut out: Vec<SecretMeta> = Vec::new();
507 for row in &rows.rows {
508 let name: String = row
509 .get("name")
510 .ok_or_else(|| SecretsError::Invalid("a secret row has no name".to_owned()))?;
511 if out.iter().any(|seen| seen.name == name) {
512 continue; }
514 out.push(SecretMeta {
515 version: version_of(row)?,
516 created_at: row.get("created_at").unwrap_or_default(),
517 created_by: row.get("created_by").unwrap_or_default(),
518 deleted: row.get::<String>("deleted_at").is_some(),
519 name,
520 });
521 }
522 Ok(out)
523 }
524
525 pub async fn delete(&self, name: &str, actor: &Actor) -> Result<(), SecretsError> {
533 let result = self.delete_inner(name).await;
534 self.audit(Access::Delete, actor, Some(name), None, result.is_ok())
535 .await?;
536 result
537 }
538
539 async fn delete_inner(&self, name: &str) -> Result<(), SecretsError> {
540 validate_name(name)?;
541 self.db
542 .execute(&Statement::with_values(
543 format!(
544 "UPDATE harness_secrets SET deleted_at = ? \
545 WHERE name = ? AND deleted_at IS NULL AND {}",
546 scoped_store()
547 ),
548 vec![text(&now()), text(name), text(self.id.as_str())],
549 ))
550 .await?;
551 Ok(())
552 }
553
554 pub(crate) async fn active_key(&self) -> Result<(String, Dek), SecretsError> {
556 let rows = self
557 .db
558 .query(&Statement::with_values(
559 format!(
560 "SELECT key_id, wrapped_dek FROM harness_secret_keys \
561 WHERE state = 'active' AND {} ORDER BY created_at DESC LIMIT 1",
562 scoped_store()
563 ),
564 vec![text(self.id.as_str())],
565 ))
566 .await?;
567 if let Some(row) = rows.first() {
568 let key_id: String = row
569 .get("key_id")
570 .ok_or_else(|| SecretsError::NoKey(self.id.to_string()))?;
571 let wrapped: Vec<u8> = row
572 .get("wrapped_dek")
573 .ok_or_else(|| SecretsError::NoKey(self.id.to_string()))?;
574 let dek = self.kms.unwrap(&wrapped).await?;
575 return Ok((key_id, dek));
576 }
577 self.provision().await
578 }
579
580 pub(crate) async fn key(&self, key_id: &str) -> Result<Dek, SecretsError> {
582 let rows = self
583 .db
584 .query(&Statement::with_values(
585 format!(
586 "SELECT wrapped_dek FROM harness_secret_keys \
587 WHERE key_id = ? AND {}",
588 scoped_store()
589 ),
590 vec![text(key_id), text(self.id.as_str())],
591 ))
592 .await?;
593 let wrapped: Vec<u8> = rows
594 .first()
595 .and_then(|row| row.get("wrapped_dek"))
596 .ok_or_else(|| SecretsError::NoKey(self.id.to_string()))?;
597 Ok(self.kms.unwrap(&wrapped).await?)
598 }
599
600 async fn provision(&self) -> Result<(String, Dek), SecretsError> {
604 let (key_id, dek, insert) = self.prepare_key("active").await?;
605 self.db.execute(&insert).await?;
606 Ok((key_id, dek))
607 }
608
609 async fn next_version(&self, name: &str) -> Result<Version, SecretsError> {
610 let rows = self
611 .db
612 .query(&Statement::with_values(
613 format!(
614 "SELECT version FROM harness_secrets \
615 WHERE name = ? AND {} ORDER BY version DESC LIMIT 1",
616 scoped_store()
617 ),
618 vec![text(name), text(self.id.as_str())],
619 ))
620 .await?;
621 match rows.first() {
622 Some(row) => Ok(version_of(row)?.saturating_add(1)),
623 None => Ok(1),
624 }
625 }
626
627 async fn audit(
632 &self,
633 access: Access,
634 actor: &Actor,
635 name: Option<&str>,
636 version: Option<Version>,
637 allowed: bool,
638 ) -> Result<(), SecretsError> {
639 self.audit
640 .record(&AuditEvent {
641 store: &self.id,
642 access,
643 actor,
644 name,
645 version,
646 allowed,
647 request_id: None,
648 })
649 .await
650 }
651}
652
653fn validate_name(name: &str) -> Result<(), SecretsError> {
654 if name.trim().is_empty() {
655 return Err(SecretsError::Invalid(
656 "a secret name cannot be empty".to_owned(),
657 ));
658 }
659 if name.len() > 200 {
660 return Err(SecretsError::Invalid(
661 "a secret name is at most 200 bytes".to_owned(),
662 ));
663 }
664 if let Some(frag) = cratefield_core::card_data_hit(name) {
665 return Err(SecretsError::Invalid(format!(
666 "a secret name must not look like card data (`{frag}`): card details belong in \
667 Stripe, not here. Store Stripe's own secrets (webhook signing secret, API key) \
668 under a plain name instead"
669 )));
670 }
671 Ok(())
672}
673
674fn version_of(row: &Row) -> Result<Version, SecretsError> {
675 let raw: i64 = row
676 .get("version")
677 .ok_or_else(|| SecretsError::Invalid("a secret row has no version".to_owned()))?;
678 u32::try_from(raw).map_err(|_| SecretsError::Invalid("a version is out of range".to_owned()))
679}
680
681fn text(value: &str) -> SeaValue {
682 SeaValue::String(Some(Box::new(value.to_owned())))
683}
684
685fn bytes(value: Vec<u8>) -> SeaValue {
686 SeaValue::Bytes(Some(Box::new(value)))
687}
688
689fn now() -> String {
690 time::OffsetDateTime::now_utc()
691 .format(&time::format_description::well_known::Rfc3339)
692 .unwrap_or_default()
693}
694
695fn new_key_id() -> Result<String, SecretsError> {
698 let mut random = [0_u8; 8];
699 getrandom::fill(&mut random)
700 .map_err(|err| SecretsError::Invalid(format!("the OS random source failed: {err}")))?;
701 let mut id = String::with_capacity(24);
702 id.push_str("dek_");
703 for byte in random {
704 use std::fmt::Write as _;
705 let _ = write!(id, "{byte:02x}");
706 }
707 Ok(id)
708}
709
710#[cfg(test)]
711mod tests {
712 use super::{HarnessOnly, Secrets};
713 use crate::{Actor, SecretBytes, StoreId};
714 use cratefield_adapter_sqlite::SqliteDatabase;
715 use cratefield_core::Database;
716 use cratefield_kms::{Dek, Kms, LocalFileKms};
717 use std::sync::Arc;
718
719 fn kms() -> Arc<dyn Kms> {
720 let kek = Dek::generate().expect("rng");
721 Arc::new(LocalFileKms::from_key(kek, "test-kek", "test").expect("not production"))
722 }
723
724 fn migrated_db() -> Arc<dyn Database> {
725 let db = SqliteDatabase::in_memory().expect("in-memory db");
726 db.apply_migrations("secrets", crate::migrations().sqlite)
727 .expect("schema applies");
728 Arc::new(db)
729 }
730
731 #[pollster::test]
738 async fn the_global_store_round_trips_for_a_caller_holding_the_proof() {
739 let secrets = Secrets::new(kms());
740 let db = migrated_db();
741 let proof: HarnessOnly = Secrets::harness_only();
742 let global = secrets.global(Arc::clone(&db), proof);
743 assert_eq!(global.id(), &StoreId::Global);
744
745 let actor = Actor::new("harness-boot").expect("named");
746 let version = global
747 .put(
748 "tenants/acme/db_ref",
749 &SecretBytes::from("postgres://acme"),
750 &actor,
751 )
752 .await
753 .expect("the control store accepts a write");
754 assert_eq!(version, 1);
755
756 let found = global
757 .get("tenants/acme/db_ref", &actor)
758 .await
759 .expect("readable")
760 .expect("present");
761 assert_eq!(found.expose(), b"postgres://acme");
762
763 let tenant = secrets
766 .tenant("acme", migrated_db())
767 .expect("the tenant store opens");
768 assert!(
769 tenant
770 .get("tenants/acme/db_ref", &actor)
771 .await
772 .expect("readable")
773 .is_none(),
774 "the global store's rows must not be visible to a tenant"
775 );
776 }
777}