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 : }
|