1use base64::Engine;
15use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
16use serde::{Deserialize, Serialize};
17
18use super::SealError;
19
20pub const MAGIC: &[u8] = b"BASILBDL\x00";
22pub const FORMAT_VERSION: u16 = 1;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Suite {
29 pub container: ContainerId,
31 pub payload_aead: AeadId,
33 pub kek_wrap_aead: AeadId,
35}
36
37impl Suite {
38 #[must_use]
40 pub const fn v1() -> Self {
41 Self {
42 container: ContainerId::Json,
43 payload_aead: AeadId::Aes256Gcm,
44 kek_wrap_aead: AeadId::Aes256Gcm,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum ContainerId {
53 Json,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename = "aes-256-gcm")]
60pub enum AeadId {
61 #[serde(rename = "aes-256-gcm")]
63 Aes256Gcm,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Header {
69 pub format_version: u16,
71 pub suite: Suite,
73 #[serde(with = "b64_array_16")]
75 pub bundle_id: [u8; 16],
76 pub created_unix: u64,
78 pub epoch: u64,
80}
81
82impl Header {
83 pub fn to_aad_bytes(&self) -> Result<Vec<u8>, SealError> {
88 serde_json::to_vec(self).map_err(|e| SealError::Format(format!("header serialize: {e}")))
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct B64Bytes(pub Vec<u8>);
95
96impl Serialize for B64Bytes {
97 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
98 s.serialize_str(&B64.encode(&self.0))
99 }
100}
101
102impl<'de> Deserialize<'de> for B64Bytes {
103 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
104 let s = String::deserialize(d)?;
105 B64.decode(s.as_bytes())
106 .map(Self)
107 .map_err(serde::de::Error::custom)
108 }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct Slot {
114 pub slot_id: u32,
116 pub method: MethodKind,
118 pub label: String,
120 pub created_unix: u64,
122 pub params: MethodParams,
124 pub wrap: KekWrap,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "kebab-case")]
131pub enum MethodKind {
132 AgeYubikey,
134 Bip39,
136 Passphrase,
138 Tpm,
140}
141
142impl std::fmt::Display for MethodKind {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 let s = match self {
145 Self::AgeYubikey => "age-yubikey",
146 Self::Bip39 => "bip39",
147 Self::Passphrase => "passphrase",
148 Self::Tpm => "tpm",
149 };
150 f.write_str(s)
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(tag = "kind", rename_all = "kebab-case")]
157pub enum MethodParams {
158 AgeYubikey {
160 recipient: String,
162 },
163 Bip39 {
165 salt: B64Bytes,
167 argon2: Argon2Params,
169 },
170 Passphrase {
172 salt: B64Bytes,
174 argon2: Argon2Params,
176 },
177 Tpm {
185 public: B64Bytes,
188 private: B64Bytes,
191 pcrs: TpmPcrSelection,
193 name_alg: String,
195 srk_template: String,
199 },
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct TpmPcrSelection {
209 pub bank: String,
211 pub pcrs: Vec<u32>,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217pub struct Argon2Params {
218 pub m_cost_kib: u32,
220 pub t_cost: u32,
222 pub p_cost: u32,
224}
225
226impl Argon2Params {
227 pub const PRODUCTION: Self = Self {
229 m_cost_kib: 64 * 1024,
230 t_cost: 3,
231 p_cost: 1,
232 };
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct KekWrap {
238 pub nonce: B64Bytes,
241 pub ciphertext: B64Bytes,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct SealedPayload {
248 pub nonce: B64Bytes,
250 pub ciphertext: B64Bytes,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct DepositRecord {
261 pub backend_id: String,
263 pub epoch: u64,
265 pub seq: u64,
267 pub contributor_key_id: String,
269 pub sealed_cred: DepositSealedCred,
271 pub signature: B64Bytes,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct DepositSealedCred {
278 pub encapsulated_key: B64Bytes,
280 pub nonce: B64Bytes,
282 pub ciphertext: B64Bytes,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct BundleBody {
289 pub header_b64: B64Bytes,
292 pub header: Header,
294 pub slots: Vec<Slot>,
296 pub payload: SealedPayload,
298 #[serde(default)]
300 pub deposits: Vec<DepositRecord>,
301}
302
303#[derive(Debug, Clone)]
305pub struct ParsedBundle {
306 pub body: BundleBody,
308}
309
310impl ParsedBundle {
311 #[must_use]
313 pub fn header_aad(&self) -> &[u8] {
314 &self.body.header_b64.0
315 }
316}
317
318pub fn encode(
326 header: &Header,
327 header_aad: &[u8],
328 slots: Vec<Slot>,
329 payload: SealedPayload,
330) -> Result<Vec<u8>, SealError> {
331 encode_with_deposits(header, header_aad, slots, payload, Vec::new())
332}
333
334pub fn encode_with_deposits(
339 header: &Header,
340 header_aad: &[u8],
341 slots: Vec<Slot>,
342 payload: SealedPayload,
343 deposits: Vec<DepositRecord>,
344) -> Result<Vec<u8>, SealError> {
345 let body = BundleBody {
346 header_b64: B64Bytes(header_aad.to_vec()),
347 header: header.clone(),
348 slots,
349 payload,
350 deposits,
351 };
352 let body_json =
353 serde_json::to_vec(&body).map_err(|e| SealError::Format(format!("body serialize: {e}")))?;
354
355 let mut out = Vec::with_capacity(MAGIC.len() + 2 + body_json.len());
356 out.extend_from_slice(MAGIC);
357 out.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
358 out.extend_from_slice(&body_json);
359 Ok(out)
360}
361
362pub fn deposit_signing_bytes(record: &DepositRecord) -> Result<Vec<u8>, SealError> {
372 #[derive(Serialize)]
373 struct Signed<'a> {
374 backend_id: &'a str,
375 epoch: u64,
376 seq: u64,
377 contributor_key_id: &'a str,
378 sealed_cred: &'a DepositSealedCred,
379 }
380
381 serde_json::to_vec(&Signed {
382 backend_id: &record.backend_id,
383 epoch: record.epoch,
384 seq: record.seq,
385 contributor_key_id: &record.contributor_key_id,
386 sealed_cred: &record.sealed_cred,
387 })
388 .map_err(|e| SealError::Format(format!("deposit canonical serialize: {e}")))
389}
390
391pub fn decode(bytes: &[u8]) -> Result<ParsedBundle, SealError> {
398 let rest = bytes
399 .strip_prefix(MAGIC)
400 .ok_or_else(|| SealError::Format("bad magic (not a sealed bundle)".into()))?;
401 let (ver_bytes, body_bytes) = rest
402 .split_at_checked(2)
403 .ok_or_else(|| SealError::Format("truncated version field".into()))?;
404 let version = u16::from_be_bytes(
405 <[u8; 2]>::try_from(ver_bytes)
406 .map_err(|_| SealError::Format("truncated version field".into()))?,
407 );
408 if version != FORMAT_VERSION {
409 return Err(SealError::Format(format!(
410 "unsupported format_version {version} (this build understands {FORMAT_VERSION})"
411 )));
412 }
413
414 let body: BundleBody = serde_json::from_slice(body_bytes)
415 .map_err(|e| SealError::Format(format!("body parse: {e}")))?;
416
417 let header_from_aad: Header = serde_json::from_slice(&body.header_b64.0)
422 .map_err(|e| SealError::Format(format!("header (aad) parse: {e}")))?;
423 if header_from_aad != body.header {
424 return Err(SealError::Format(
425 "header / header_b64 mismatch (tampered container)".into(),
426 ));
427 }
428 if body.header.format_version != FORMAT_VERSION {
429 return Err(SealError::Format(
430 "header.format_version disagrees with framing".into(),
431 ));
432 }
433 if body.header.suite != Suite::v1() {
434 return Err(SealError::Format("unknown suite ids".into()));
435 }
436
437 Ok(ParsedBundle { body })
438}
439
440mod b64_array_16 {
442 use super::B64;
443 use base64::Engine;
444 use serde::{Deserialize, Deserializer, Serializer};
445
446 pub fn serialize<S: Serializer>(v: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
447 s.serialize_str(&B64.encode(v))
448 }
449
450 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
451 let s = String::deserialize(d)?;
452 let v = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
453 <[u8; 16]>::try_from(v.as_slice())
454 .map_err(|_| serde::de::Error::custom("bundle_id must be 16 bytes"))
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn sample_header() -> Header {
463 Header {
464 format_version: FORMAT_VERSION,
465 suite: Suite::v1(),
466 bundle_id: [7u8; 16],
467 created_unix: 1_700_000_000,
468 epoch: 1,
469 }
470 }
471
472 #[test]
473 fn encode_decode_round_trip() {
474 let header = sample_header();
475 let aad = header.to_aad_bytes().unwrap();
476 let payload = SealedPayload {
477 nonce: B64Bytes(vec![1; 12]),
478 ciphertext: B64Bytes(vec![2; 48]),
479 };
480 let file = encode(&header, &aad, vec![], payload).unwrap();
481 assert!(file.starts_with(MAGIC));
482 let parsed = decode(&file).unwrap();
483 assert_eq!(parsed.body.header, header);
484 assert_eq!(parsed.header_aad(), aad.as_slice());
485 }
486
487 #[test]
488 fn bad_magic_fails() {
489 let err = decode(b"not a bundle at all").unwrap_err();
490 assert!(matches!(err, SealError::Format(_)));
491 }
492
493 #[test]
494 fn wrong_version_fails_before_parse() {
495 let mut file = Vec::new();
496 file.extend_from_slice(MAGIC);
497 file.extend_from_slice(&2u16.to_be_bytes());
498 file.extend_from_slice(b"{}");
499 let err = decode(&file).unwrap_err();
500 assert!(matches!(err, SealError::Format(m) if m.contains("unsupported format_version")));
501 }
502
503 #[test]
504 fn aead_id_serializes_to_spec_string() {
505 let v = serde_json::to_value(AeadId::Aes256Gcm).unwrap();
506 assert_eq!(v, serde_json::json!("aes-256-gcm"));
507 }
508}