LCOV - code coverage report
Current view: top level - antares-sql/src/store/pg - seal.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.4 % 388 374
Test Date: 2026-11-02 13:21:27 Functions: 69.7 % 99 69

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Notification credentials sealed at rest.
       3              : //!
       4              : //! A Subscription's `endpoint.receiverInfo` values are the headers its
       5              : //! notifications carry (6.3.8), typically a bearer token. With
       6              : //! `ANTARES_SECRET_KEY` set, the doc store writes each value as
       7              : //! `enc:v1:<base64(nonce | AES-256-GCM ciphertext)>` and opens it again on
       8              : //! every read, so every caller above the store sees the plaintext and a dump
       9              : //! or a `SELECT` sees none. A dead letter keeps the resolved pairs it would
      10              : //! have sent (`receiverInfo`, and `headers` in letters from before the sink
      11              : //! registry) and is sealed the same way.
      12              : //!
      13              : //! A value without the prefix is read as it is, so rows written before the
      14              : //! key was set stay readable; [`super::doc::PgDocStore::seal_plaintext`]
      15              : //! rewrites them at boot. Without a key, a client's own text that starts
      16              : //! with `enc:` is stored behind `LITERAL`, so `enc:v1:` in a row always
      17              : //! means ciphertext. A sealed value read without the key, or under
      18              : //! another key, is an error: a notification never goes out with ciphertext in
      19              : //! its headers.
      20              : //!
      21              : //! A Snapshot's `receiverInfo` (Table 5.2.41-1) is the header set of its
      22              : //! 5.16.6 notification and is sealed the same way. An endpoint URI that
      23              : //! carries `user:password@` userinfo (7.2; an HTTP client sends it as Basic
      24              : //! auth) is a credential as well: such a URI is sealed whole, in every kind
      25              : //! above. A URI without userinfo stays readable.
      26              : //!
      27              : //! The bus KV mirror (ANTARES_BUS=nats) carries the Subscription documents
      28              : //! between pods; the wiring seals them with [`sealed`] before the put and
      29              : //! opens them with [`open`] where the matcher applies an entry, so the
      30              : //! bucket holds what the row holds. The memory/file store keeps plaintext;
      31              : //! the Pg store is the one deployments run, seal it when a deployment runs it.
      32              : 
      33              : use base64::Engine as _;
      34              : use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
      35              : use ring::rand::{SecureRandom, SystemRandom};
      36              : use serde_json::Value;
      37              : 
      38              : use super::doc::DocKind;
      39              : 
      40              : /// The marker of a sealed value; `v1` names AES-256-GCM with a random
      41              : /// 96-bit nonce prepended to the ciphertext.
      42              : pub const PREFIX: &str = "enc:v1:";
      43              : 
      44              : /// The marker of a client's own text stored without a key: a value that
      45              : /// starts with `enc:` (`PREFIX` or this marker itself) is written behind it,
      46              : /// and `open` strips it again. Without it a client's `enc:v1:…` text read
      47              : /// back as ciphertext and failed every read of its row, the listing included.
      48              : const LITERAL: &str = "enc:lit:";
      49              : 
      50              : const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
      51              : 
      52              : /// The key `ANTARES_SECRET_KEY` names: 32 bytes, as 64 hex digits (what the
      53              : /// deployment's secret generator writes with `encoding: hex`) or base64.
      54              : pub struct SecretKey {
      55              :     key: LessSafeKey,
      56              :     rng: SystemRandom,
      57              : }
      58              : 
      59              : impl std::fmt::Debug for SecretKey {
      60            2 :     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
      61            2 :         f.write_str("SecretKey(..)")
      62            2 :     }
      63              : }
      64              : 
      65              : impl SecretKey {
      66              :     /// The key from 64 hex digits or its base64 text (standard alphabet,
      67              :     /// padded). The error names the variable and never echoes the value.
      68           46 :     pub fn from_base64(raw: &str) -> Result<Self, String> {
      69           46 :         let raw = raw.trim();
      70          128 :         let hex = (raw.len() == 64 && raw.bytes().all(|b| b.is_ascii_hexdigit())).then(|| {
      71            2 :             raw.as_bytes()
      72            2 :                 .chunks(2)
      73           64 :                 .filter_map(|p| u8::from_str_radix(std::str::from_utf8(p).ok()?, 16).ok())
      74            2 :                 .collect::<Vec<u8>>()
      75            2 :         });
      76           46 :         let bytes = match hex {
      77            2 :             Some(b) => b,
      78           44 :             None => B64.decode(raw).map_err(|_| {
      79            2 :                 "ANTARES_SECRET_KEY must be 32 bytes as 64 hex digits or base64 \
      80            2 :                  (openssl rand -hex 32)"
      81            2 :                     .to_owned()
      82            2 :             })?,
      83              :         };
      84           44 :         if bytes.len() != 32 {
      85            2 :             return Err(format!(
      86            2 :                 "ANTARES_SECRET_KEY must be 32 bytes in base64 (got {} bytes; \
      87            2 :                  openssl rand -base64 32)",
      88            2 :                 bytes.len()
      89            2 :             ));
      90           42 :         }
      91           42 :         let unbound = UnboundKey::new(&AES_256_GCM, &bytes)
      92           42 :             .map_err(|_| "ANTARES_SECRET_KEY: the key was refused".to_owned())?;
      93           42 :         Ok(Self {
      94           42 :             key: LessSafeKey::new(unbound),
      95           42 :             rng: SystemRandom::new(),
      96           42 :         })
      97           46 :     }
      98              : 
      99           50 :     fn seal(&self, plain: &str, aad: &[u8]) -> Result<String, Unreadable> {
     100           50 :         let mut nonce = [0u8; NONCE_LEN];
     101           50 :         self.rng.fill(&mut nonce).map_err(|_| Unreadable)?;
     102           50 :         let mut buf = plain.as_bytes().to_vec();
     103           50 :         self.key
     104           50 :             .seal_in_place_append_tag(
     105           50 :                 Nonce::assume_unique_for_key(nonce),
     106           50 :                 Aad::from(aad),
     107           50 :                 &mut buf,
     108              :             )
     109           50 :             .map_err(|_| Unreadable)?;
     110           50 :         let mut out = nonce.to_vec();
     111           50 :         out.append(&mut buf);
     112           50 :         Ok(format!("{PREFIX}{}", B64.encode(out)))
     113           50 :     }
     114              : 
     115           78 :     fn open(&self, sealed: &str, aad: &[u8]) -> Result<String, Unreadable> {
     116           78 :         let mut raw = B64.decode(sealed).map_err(|_| Unreadable)?;
     117           78 :         let nonce = raw.get(..NONCE_LEN).ok_or(Unreadable)?;
     118           76 :         let nonce = Nonce::try_assume_unique_for_key(nonce).map_err(|_| Unreadable)?;
     119              :         // Opened inside the decoded buffer: the plain text moves to its
     120              :         // front, so the buffer becomes the String without a copy.
     121           76 :         let len = self
     122           76 :             .key
     123           76 :             .open_within(nonce, Aad::from(aad), &mut raw, NONCE_LEN..)
     124           76 :             .map_err(|_| Unreadable)?
     125           60 :             .len();
     126           60 :         raw.truncate(len);
     127           60 :         String::from_utf8(raw).map_err(|_| Unreadable)
     128           78 :     }
     129              : }
     130              : 
     131              : /// A sealed value this process cannot open: no key, another key, or a
     132              : /// damaged value.
     133              : #[derive(Debug)]
     134              : pub struct Unreadable;
     135              : 
     136              : impl From<Unreadable> for sqlx::Error {
     137            3 :     fn from(_: Unreadable) -> Self {
     138            3 :         tracing::error!(
     139              :             "a stored notification credential (receiverInfo) cannot be opened: \
     140              :              ANTARES_SECRET_KEY is unset or differs from the key it was sealed with"
     141              :         );
     142            3 :         sqlx::Error::Configuration(Box::new(antares_model::NgsiError::InternalError(
     143            3 :             "a stored notification credential cannot be read".into(),
     144            3 :         )))
     145            3 :     }
     146              : }
     147              : 
     148              : /// Whether an endpoint URI carries userinfo (`scheme://user:pass@host`), the
     149              : /// rule `antares_notifier::redact_userinfo` strips. A URI starting with
     150              : /// `enc:` is read as one too: it is the sealed (or escaped) form of such a URI,
     151              : /// or a client's text that has to round-trip like one.
     152        55691 : fn credential_uri(uri: &Value) -> bool {
     153        55691 :     let Some(uri) = uri.as_str() else {
     154            0 :         return false;
     155              :     };
     156        55691 :     if uri.starts_with("enc:") {
     157           10 :         return true;
     158        55681 :     }
     159        55681 :     uri.split_once("//").is_some_and(|(_, rest)| {
     160         1875 :         rest.split(['/', '?', '#'])
     161         1875 :             .next()
     162         1875 :             .is_some_and(|authority| authority.contains('@'))
     163         1875 :     })
     164        55691 : }
     165              : 
     166              : /// The `receiverInfo` KeyValuePair values (5.2.22) and the endpoint URI with
     167              : /// userinfo of one endpoint object: `uri` names the URI member.
     168        56876 : fn endpoint_credentials<'a>(
     169        56876 :     endpoint: &'a mut serde_json::Map<String, Value>,
     170        56876 :     uri: &str,
     171        56876 : ) -> Vec<&'a mut Value> {
     172        56876 :     let mut out = Vec::new();
     173        71365 :     for (k, v) in endpoint.iter_mut() {
     174        71365 :         if k == uri && credential_uri(v) {
     175           20 :             out.push(v);
     176        71345 :         } else if k == "receiverInfo" {
     177          127 :             if let Some(pairs) = v.as_array_mut() {
     178          127 :                 out.extend(pairs.iter_mut().filter_map(|p| p.get_mut("value")));
     179            0 :             }
     180        71218 :         }
     181              :     }
     182        56876 :     out
     183        56876 : }
     184              : 
     185              : /// The string values of `doc` that hold a credential of `kind`.
     186     10804207 : fn credentials(kind: DocKind, doc: &mut Value) -> Vec<&mut Value> {
     187     10804207 :     match kind {
     188        56024 :         DocKind::Subscription | DocKind::CSourceSubscription => doc
     189        56024 :             .pointer_mut("/notification/endpoint")
     190        56024 :             .and_then(Value::as_object_mut)
     191        56024 :             .map(|ep| endpoint_credentials(ep, "uri"))
     192        56024 :             .unwrap_or_default(),
     193              :         // Table 5.2.41-1 keeps `endpoint` (the URI) and `receiverInfo` as
     194              :         // members of the Snapshot itself
     195         1232 :         DocKind::Snapshot => doc
     196         1232 :             .as_object_mut()
     197         1232 :             .map(|o| endpoint_credentials(o, "endpoint"))
     198         1232 :             .unwrap_or_default(),
     199              :         // Outbound::receiver_info and the old rendered header list are
     200              :         // `[key, value]` pairs.
     201              :         DocKind::DeadLetter => {
     202           18 :             let Some(o) = doc.as_object_mut() else {
     203            0 :                 return Vec::new();
     204              :             };
     205           18 :             let mut out = Vec::new();
     206           48 :             for (k, v) in o.iter_mut() {
     207           48 :                 if k == "uri" && credential_uri(v) {
     208            4 :                     out.push(v);
     209           44 :                 } else if k == "receiverInfo" || k == "headers" {
     210           14 :                     if let Some(pairs) = v.as_array_mut() {
     211           14 :                         out.extend(pairs.iter_mut().filter_map(|p| p.get_mut(1)));
     212            0 :                     }
     213           30 :                 }
     214              :             }
     215           18 :             out
     216              :         }
     217     10746933 :         _ => Vec::new(),
     218              :     }
     219     10804207 : }
     220              : 
     221              : /// Whether `kind` holds credentials this module seals.
     222        23309 : pub(crate) fn holds_credentials(kind: DocKind) -> bool {
     223        22520 :     matches!(
     224        23309 :         kind,
     225              :         DocKind::Subscription
     226              :             | DocKind::CSourceSubscription
     227              :             | DocKind::Snapshot
     228              :             | DocKind::DeadLetter
     229              :     )
     230        23309 : }
     231              : 
     232              : /// The row a sealed value belongs to. It is the AEAD's associated data, so
     233              : /// a value opens only in the row it was sealed for: an `enc:v1:` text copied
     234              : /// out of a dump into another subscription (another tenant, kind or id)
     235              : /// fails to open instead of being delivered to its new receiver.
     236              : #[derive(Clone, Copy)]
     237              : pub struct Row<'a> {
     238              :     pub kind: DocKind,
     239              :     pub tenant: &'a str,
     240              :     pub id: &'a str,
     241              : }
     242              : 
     243              : impl Row<'_> {
     244     10803475 :     fn aad(&self) -> Vec<u8> {
     245     10803475 :         let kind = match self.kind {
     246        54925 :             DocKind::Subscription => "subscription",
     247          426 :             DocKind::CSourceSubscription => "csource-subscription",
     248         1178 :             DocKind::Snapshot => "snapshot",
     249           13 :             DocKind::DeadLetter => "dead-letter",
     250     10746933 :             _ => "other",
     251              :         };
     252              :         // NUL never occurs in a tenant name or an id, so the parts cannot
     253              :         // run into each other
     254     10803475 :         format!("antares-seal-v1\0{kind}\0{}\0{}", self.tenant, self.id).into_bytes()
     255     10803475 :     }
     256              : }
     257              : 
     258              : /// `doc` as it is stored: every credential sealed under `key` for `row`. A
     259              : /// value the client sent already starting with `enc:v1:` is sealed like any
     260              : /// other text, so it reads back as that literal text, never decrypted.
     261              : /// Without a key such a value is kept behind `LITERAL` for the same reason;
     262              : /// nothing to seal or escape writes the doc as it came.
     263        23309 : pub fn sealed<'a>(
     264        23309 :     key: Option<&SecretKey>,
     265        23309 :     row: Row<'_>,
     266        23309 :     doc: &'a Value,
     267        23309 : ) -> Result<std::borrow::Cow<'a, Value>, Unreadable> {
     268        23309 :     if !holds_credentials(row.kind) {
     269        22520 :         return Ok(std::borrow::Cow::Borrowed(doc));
     270          789 :     }
     271          789 :     let Some(key) = key else {
     272          734 :         let mut out = doc.clone();
     273          734 :         let mut escaped = false;
     274          734 :         for v in credentials(row.kind, &mut out) {
     275           17 :             if let Some(s) = v.as_str().filter(|s| s.starts_with("enc:")) {
     276            4 :                 *v = Value::String(format!("{LITERAL}{s}"));
     277            4 :                 escaped = true;
     278           13 :             }
     279              :         }
     280          734 :         return Ok(if escaped {
     281            4 :             std::borrow::Cow::Owned(out)
     282              :         } else {
     283          730 :             std::borrow::Cow::Borrowed(doc)
     284              :         });
     285              :     };
     286           55 :     let aad = row.aad();
     287           55 :     let mut out = doc.clone();
     288           55 :     for v in credentials(row.kind, &mut out) {
     289           48 :         if let Some(s) = v.as_str() {
     290           48 :             *v = Value::String(key.seal(s, &aad)?);
     291            0 :         }
     292              :     }
     293           55 :     Ok(std::borrow::Cow::Owned(out))
     294        23309 : }
     295              : 
     296              : /// Open every sealed credential of the stored `doc` of `row` in place;
     297              : /// plaintext values (rows from before the key) pass through, and a client's
     298              : /// text kept behind `LITERAL` comes back as it was sent.
     299     10803418 : pub fn open(key: Option<&SecretKey>, row: Row<'_>, doc: &mut Value) -> Result<(), Unreadable> {
     300     10803418 :     let aad = row.aad();
     301     10803418 :     for v in credentials(row.kind, doc) {
     302          100 :         let Some(s) = v.as_str() else {
     303            0 :             continue;
     304              :         };
     305          100 :         if let Some(text) = s.strip_prefix(LITERAL) {
     306            8 :             *v = Value::String(text.to_owned());
     307           92 :         } else if let Some(body) = s.strip_prefix(PREFIX) {
     308           73 :             *v = Value::String(key.ok_or(Unreadable)?.open(body, &aad)?);
     309           19 :         }
     310              :     }
     311     10803403 :     Ok(())
     312     10803418 : }
     313              : 
     314              : /// Whether `doc` of `kind` still holds a credential that is not sealed: a
     315              : /// value put before the key was set, or one kept behind `LITERAL`.
     316            0 : pub fn holds_plaintext(kind: DocKind, doc: &Value) -> bool {
     317            0 :     let mut doc = doc.clone();
     318            0 :     credentials(kind, &mut doc)
     319            0 :         .into_iter()
     320            0 :         .any(|v| v.as_str().is_some_and(|s| !s.starts_with(PREFIX)))
     321            0 : }
     322              : 
     323              : /// The jsonpath that finds a row still holding a plaintext credential of
     324              : /// `kind` (the boot pass's filter). The URI regex is [`credential_uri`]'s
     325              : /// userinfo rule: the authority after the first `//` holds an `@`.
     326           16 : pub(crate) fn plaintext_path(kind: DocKind) -> Option<&'static str> {
     327           16 :     match kind {
     328            8 :         DocKind::Subscription | DocKind::CSourceSubscription => Some(
     329            8 :             "$ ? (exists(@.notification.endpoint.receiverInfo[*].value ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\"))) \
     330            8 :              || exists(@.notification.endpoint.uri ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\") && @ like_regex \"^([^/]|/[^/])*//[^/?#]*@\")))",
     331            8 :         ),
     332            4 :         DocKind::Snapshot => Some(
     333            4 :             "$ ? (exists(@.receiverInfo[*].value ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\"))) \
     334            4 :              || exists(@.endpoint ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\") && @ like_regex \"^([^/]|/[^/])*//[^/?#]*@\")))",
     335            4 :         ),
     336            4 :         DocKind::DeadLetter => Some(
     337            4 :             "$ ? (exists(@.receiverInfo[*][1] ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\"))) \
     338            4 :              || exists(@.headers[*][1] ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\"))) \
     339            4 :              || exists(@.uri ? (@.type() == \"string\" && !(@ starts with \"enc:v1:\") && @ like_regex \"^([^/]|/[^/])*//[^/?#]*@\")))",
     340            4 :         ),
     341            0 :         _ => None,
     342              :     }
     343           16 : }
     344              : 
     345              : #[cfg(test)]
     346              : mod tests {
     347              :     use super::*;
     348              :     use serde_json::json;
     349              : 
     350           28 :     fn key(byte: u8) -> SecretKey {
     351           28 :         SecretKey::from_base64(&B64.encode([byte; 32])).expect("key")
     352           28 :     }
     353              : 
     354           96 :     fn row(kind: DocKind) -> Row<'static> {
     355           96 :         Row {
     356           96 :             kind,
     357           96 :             tenant: "t",
     358           96 :             id: "urn:ngsi-ld:Subscription:s",
     359           96 :         }
     360           96 :     }
     361              : 
     362              :     /// The bounce of a value copied into another row (tenant, kind
     363              :     /// or id) does not open there, and a client-sent `enc:v1:` text is kept
     364              :     /// as text, never decrypted.
     365              :     #[test]
     366            2 :     fn a_sealed_value_opens_only_in_its_own_row() {
     367            2 :         let k = key(8);
     368            2 :         let stored = sealed(Some(&k), row(DocKind::Subscription), &sub("Bearer t"))
     369            2 :             .expect("seal")
     370            2 :             .into_owned();
     371            2 :         let elsewhere = [
     372            2 :             Row {
     373            2 :                 id: "urn:ngsi-ld:Subscription:other",
     374            2 :                 ..row(DocKind::Subscription)
     375            2 :             },
     376            2 :             Row {
     377            2 :                 tenant: "u",
     378            2 :                 ..row(DocKind::Subscription)
     379            2 :             },
     380            2 :             row(DocKind::CSourceSubscription),
     381            2 :         ];
     382            6 :         for other in elsewhere {
     383            6 :             assert!(open(Some(&k), other, &mut stored.clone()).is_err());
     384              :         }
     385            2 :         let pasted = value_of(&stored);
     386            2 :         let again = sealed(Some(&k), row(DocKind::Subscription), &sub(&pasted))
     387            2 :             .expect("seal")
     388            2 :             .into_owned();
     389            2 :         let mut back = again.clone();
     390            2 :         open(Some(&k), row(DocKind::Subscription), &mut back).expect("open");
     391            2 :         assert_eq!(value_of(&back), pasted);
     392            2 :     }
     393              : 
     394           22 :     fn value_of(doc: &Value) -> String {
     395           22 :         doc["notification"]["endpoint"]["receiverInfo"][0]["value"]
     396           22 :             .as_str()
     397           22 :             .unwrap_or_default()
     398           22 :             .to_owned()
     399           22 :     }
     400              : 
     401           22 :     fn sub(value: &str) -> Value {
     402           22 :         json!({"id": "urn:ngsi-ld:Subscription:s", "notification": {"endpoint": {
     403           22 :             "uri": "http://h/n",
     404           22 :             "receiverInfo": [{"key": "Authorization", "value": value}]
     405              :         }}})
     406           22 :     }
     407              : 
     408              :     #[test]
     409            2 :     fn a_sealed_subscription_shows_no_credential_and_opens_to_it() {
     410            2 :         let k = key(7);
     411            2 :         let doc = sub("Bearer t");
     412            2 :         let stored = sealed(Some(&k), row(DocKind::Subscription), &doc).expect("seal");
     413            2 :         let text = stored.to_string();
     414            2 :         assert!(!text.contains("Bearer t"), "{text}");
     415            2 :         assert!(text.contains(PREFIX), "{text}");
     416            2 :         let mut back = stored.into_owned();
     417            2 :         open(Some(&k), row(DocKind::Subscription), &mut back).expect("open");
     418            2 :         assert_eq!(back, doc);
     419            2 :     }
     420              : 
     421              :     /// A Snapshot's `receiverInfo` (Table 5.2.41-1) is its notification's
     422              :     /// header set: sealed, and opened back in its own row only.
     423              :     #[test]
     424            2 :     fn a_sealed_snapshot_shows_no_credential_and_opens_to_it() {
     425            2 :         let k = key(10);
     426            2 :         let doc = json!({"id": "urn:ngsi-ld:Snapshot:s", "type": "Snapshot",
     427            2 :                          "endpoint": "http://h/n",
     428            2 :                          "receiverInfo": [{"key": "Authorization", "value": "Bearer t"}]});
     429            2 :         let stored = sealed(Some(&k), row(DocKind::Snapshot), &doc).expect("seal");
     430            2 :         assert!(!stored.to_string().contains("Bearer t"), "{stored}");
     431            2 :         assert_eq!(stored["receiverInfo"][0]["key"], "Authorization");
     432            2 :         assert_eq!(stored["endpoint"], "http://h/n");
     433            2 :         let mut back = stored.clone().into_owned();
     434            2 :         open(Some(&k), row(DocKind::Snapshot), &mut back).expect("open");
     435            2 :         assert_eq!(back, doc);
     436            2 :         let elsewhere = Row {
     437            2 :             id: "urn:ngsi-ld:Snapshot:other",
     438            2 :             ..row(DocKind::Snapshot)
     439            2 :         };
     440            2 :         assert!(open(Some(&k), elsewhere, &mut stored.into_owned()).is_err());
     441            2 :     }
     442              : 
     443              :     /// 7.2: `user:password@` in an endpoint URI is a credential (an HTTP
     444              :     /// client sends it as Basic auth). Such a URI is sealed whole in every
     445              :     /// kind that keeps one; a URI without userinfo, or with an `@` only in
     446              :     /// its path, stays readable.
     447              :     #[test]
     448            2 :     fn an_endpoint_uri_with_userinfo_is_sealed_whole() {
     449            2 :         let k = key(11);
     450            2 :         let secret = "https://user:pa55@h/n?x=1";
     451            2 :         let docs = [
     452            2 :             (
     453            2 :                 DocKind::Subscription,
     454            2 :                 json!({"notification": {"endpoint": {"uri": secret}}}),
     455            2 :                 "/notification/endpoint/uri",
     456            2 :             ),
     457            2 :             (
     458            2 :                 DocKind::CSourceSubscription,
     459            2 :                 json!({"notification": {"endpoint": {"uri": secret}}}),
     460            2 :                 "/notification/endpoint/uri",
     461            2 :             ),
     462            2 :             (DocKind::Snapshot, json!({"endpoint": secret}), "/endpoint"),
     463            2 :             (DocKind::DeadLetter, json!({"uri": secret}), "/uri"),
     464            2 :         ];
     465            8 :         for (kind, doc, at) in docs {
     466            8 :             let stored = sealed(Some(&k), row(kind), &doc).expect("seal");
     467            8 :             assert!(!stored.to_string().contains("pa55"), "{kind:?}: {stored}");
     468            8 :             assert!(
     469            8 :                 stored
     470            8 :                     .pointer(at)
     471            8 :                     .and_then(Value::as_str)
     472            8 :                     .is_some_and(|u| u.starts_with(PREFIX)),
     473              :                 "{kind:?}: {stored}"
     474              :             );
     475            8 :             let mut back = stored.into_owned();
     476            8 :             open(Some(&k), row(kind), &mut back).expect("open");
     477            8 :             assert_eq!(back, doc, "{kind:?}");
     478              :         }
     479            6 :         for bare in ["https://h/n", "mqtt://h:1883/a@b", "urn:antares:distsub:x"] {
     480            6 :             let doc = json!({"notification": {"endpoint": {"uri": bare}}});
     481            6 :             let stored = sealed(Some(&k), row(DocKind::Subscription), &doc).expect("seal");
     482            6 :             assert_eq!(stored["notification"]["endpoint"]["uri"], bare);
     483              :         }
     484            2 :     }
     485              : 
     486              :     #[test]
     487            2 :     fn a_dead_letter_seals_its_resolved_pairs() {
     488            2 :         let k = key(1);
     489            2 :         let doc = json!({"receiverInfo": [["Authorization", "Bearer t"]],
     490            2 :                          "headers": [["X-A", "b"]], "uri": "http://h/n"});
     491            2 :         let stored = sealed(Some(&k), row(DocKind::DeadLetter), &doc).expect("seal");
     492            2 :         assert_eq!(stored["receiverInfo"][0][0], "Authorization");
     493            2 :         assert!(!stored.to_string().contains("Bearer t"));
     494            2 :         assert!(!stored.to_string().contains("\"b\""));
     495            2 :         let mut back = stored.into_owned();
     496            2 :         open(Some(&k), row(DocKind::DeadLetter), &mut back).expect("open");
     497            2 :         assert_eq!(back, doc);
     498            2 :     }
     499              : 
     500              :     /// S3-05: without a key a client's own `enc:v1:` text is stored and read
     501              :     /// back as that text, not opened as a sealed value no key can open (which
     502              :     /// failed every read of the row, and the whole listing with it). The boot
     503              :     /// pass of a key set later still finds it as plaintext and seals it.
     504              :     #[test]
     505            2 :     fn a_client_prefixed_value_reads_back_without_a_key() {
     506            6 :         for text in ["enc:v1:AAAA", "enc:lit:x", "Bearer t"] {
     507            6 :             let doc = sub(text);
     508            6 :             let stored = sealed(None, row(DocKind::Subscription), &doc)
     509            6 :                 .expect("no key")
     510            6 :                 .into_owned();
     511            6 :             let mut back = stored.clone();
     512            6 :             open(None, row(DocKind::Subscription), &mut back)
     513            6 :                 .expect("a client literal is not a sealed value");
     514            6 :             assert_eq!(value_of(&back), text);
     515            6 :             assert!(
     516            6 :                 !value_of(&stored).starts_with(PREFIX),
     517              :                 "{text} is stored as {}",
     518            0 :                 value_of(&stored)
     519              :             );
     520            6 :             let k = key(9);
     521            6 :             let mut later = stored;
     522            6 :             open(Some(&k), row(DocKind::Subscription), &mut later).expect("open under a key");
     523            6 :             let resealed = sealed(Some(&k), row(DocKind::Subscription), &later)
     524            6 :                 .expect("seal")
     525            6 :                 .into_owned();
     526            6 :             let mut again = resealed.clone();
     527            6 :             open(Some(&k), row(DocKind::Subscription), &mut again).expect("open");
     528            6 :             assert_eq!(value_of(&again), text);
     529              :         }
     530            2 :     }
     531              : 
     532              :     #[test]
     533            2 :     fn plaintext_passes_and_ciphertext_never_leaves_without_its_key() {
     534            2 :         let mut plain = sub("Bearer t");
     535            2 :         assert!(open(None, row(DocKind::Subscription), &mut plain).is_ok());
     536            2 :         assert_eq!(plain, sub("Bearer t"));
     537              : 
     538            2 :         let stored = sealed(Some(&key(2)), row(DocKind::Subscription), &sub("Bearer t"))
     539            2 :             .expect("seal")
     540            2 :             .into_owned();
     541            2 :         assert!(open(None, row(DocKind::Subscription), &mut stored.clone()).is_err());
     542            2 :         assert!(open(
     543            2 :             Some(&key(3)),
     544            2 :             row(DocKind::Subscription),
     545            2 :             &mut stored.clone()
     546            2 :         )
     547            2 :         .is_err());
     548            2 :     }
     549              : 
     550              :     #[test]
     551            2 :     fn a_damaged_value_is_unreadable() {
     552            2 :         let k = key(6);
     553            2 :         let aad = row(DocKind::Subscription).aad();
     554            2 :         let stored = k.seal("Bearer t", &aad).expect("seal");
     555            2 :         let body = stored.strip_prefix(PREFIX).expect("prefix");
     556            2 :         assert_eq!(k.open(body, &aad).expect("open"), "Bearer t");
     557            2 :         assert!(k.open(&B64.encode([1u8; 5]), &aad).is_err());
     558            2 :         assert!(k.open(&B64.encode([1u8; NONCE_LEN + 3]), &aad).is_err());
     559            2 :         let mut raw = B64.decode(body).expect("b64");
     560            2 :         if let Some(last) = raw.last_mut() {
     561            2 :             *last ^= 1;
     562            2 :         }
     563            2 :         assert!(k.open(&B64.encode(raw), &aad).is_err());
     564            2 :     }
     565              : 
     566              :     #[test]
     567            2 :     fn no_key_or_another_kind_stores_the_doc_as_it_came() {
     568            2 :         let doc = sub("Bearer t");
     569            2 :         assert_eq!(
     570            2 :             *sealed(None, row(DocKind::Subscription), &doc).expect("none"),
     571              :             doc
     572              :         );
     573            2 :         assert_eq!(
     574            2 :             *sealed(Some(&key(4)), row(DocKind::View), &doc).expect("view"),
     575              :             doc
     576              :         );
     577            2 :     }
     578              : 
     579              :     #[test]
     580            2 :     fn the_key_is_32_bytes_of_base64() {
     581            2 :         assert!(SecretKey::from_base64("not base64!").is_err());
     582            2 :         let short = SecretKey::from_base64(&B64.encode([0u8; 16])).expect_err("short");
     583            2 :         assert!(short.contains("got 16 bytes"), "{short}");
     584            2 :         assert!(SecretKey::from_base64(&format!(" {} \n", B64.encode([9u8; 32]))).is_ok());
     585            2 :         assert_eq!(format!("{:?}", key(5)), "SecretKey(..)");
     586              :         // 64 hex digits are the 32 bytes they spell, the same key as base64
     587            2 :         let hex = SecretKey::from_base64(&"07".repeat(32)).expect("hex");
     588            2 :         let stored = sealed(Some(&hex), row(DocKind::Subscription), &sub("Bearer t"))
     589            2 :             .expect("seal")
     590            2 :             .into_owned();
     591            2 :         assert!(open(
     592            2 :             Some(&key(7)),
     593            2 :             row(DocKind::Subscription),
     594            2 :             &mut stored.clone()
     595            2 :         )
     596            2 :         .is_ok());
     597            2 :     }
     598              : }
        

Generated by: LCOV version 2.0-1