1use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
20use serde::{Deserialize, Serialize};
21
22use super::SealError;
23
24pub const MAGIC: &[u8] = b"BASILBDL\x00";
26pub const FORMAT_VERSION: u16 = 1;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Suite {
33 pub container: ContainerId,
35 pub payload_aead: AeadId,
37 pub kek_wrap_aead: AeadId,
39}
40
41impl Suite {
42 #[must_use]
44 pub const fn v1() -> Self {
45 Self {
46 container: ContainerId::Json,
47 payload_aead: AeadId::Aes256Gcm,
48 kek_wrap_aead: AeadId::Aes256Gcm,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum ContainerId {
57 Json,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename = "aes-256-gcm")]
64pub enum AeadId {
65 #[serde(rename = "aes-256-gcm")]
67 Aes256Gcm,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct Header {
73 pub format_version: u16,
75 pub suite: Suite,
77 #[serde(with = "b64_array_16")]
79 pub bundle_id: [u8; 16],
80 pub created_unix: u64,
82 pub epoch: u64,
84}
85
86impl Header {
87 pub fn to_aad_bytes(&self) -> Result<Vec<u8>, SealError> {
92 serde_json::to_vec(self).map_err(|e| SealError::Format(format!("header serialize: {e}")))
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct B64Bytes(pub Vec<u8>);
99
100impl Serialize for B64Bytes {
101 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
102 s.serialize_str(&B64.encode(&self.0))
103 }
104}
105
106impl<'de> Deserialize<'de> for B64Bytes {
107 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
108 let s = String::deserialize(d)?;
109 B64.decode(s.as_bytes())
110 .map(Self)
111 .map_err(serde::de::Error::custom)
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Slot {
118 pub slot_id: u32,
120 pub method: MethodKind,
122 pub label: String,
124 pub created_unix: u64,
126 pub params: MethodParams,
128 pub wrap: KekWrap,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum MethodKind {
136 AgeYubikey,
138 Bip39,
140 Passphrase,
142 Tpm,
144}
145
146impl std::fmt::Display for MethodKind {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 let s = match self {
149 Self::AgeYubikey => "age-yubikey",
150 Self::Bip39 => "bip39",
151 Self::Passphrase => "passphrase",
152 Self::Tpm => "tpm",
153 };
154 f.write_str(s)
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "kebab-case")]
161pub enum MethodParams {
162 AgeYubikey {
164 recipient: String,
166 },
167 Bip39 {
169 salt: B64Bytes,
171 argon2: Argon2Params,
173 },
174 Passphrase {
176 salt: B64Bytes,
178 argon2: Argon2Params,
180 },
181 Tpm {
189 public: B64Bytes,
192 private: B64Bytes,
195 pcrs: TpmPcrSelection,
197 name_alg: String,
199 srk_template: String,
203 },
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct TpmPcrSelection {
213 pub bank: String,
215 pub pcrs: Vec<u32>,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221pub struct Argon2Params {
222 pub m_cost_kib: u32,
224 pub t_cost: u32,
226 pub p_cost: u32,
228}
229
230impl Argon2Params {
231 pub const PRODUCTION: Self = Self {
233 m_cost_kib: 64 * 1024,
234 t_cost: 3,
235 p_cost: 1,
236 };
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct KekWrap {
242 pub nonce: B64Bytes,
245 pub ciphertext: B64Bytes,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct SealedPayload {
252 pub nonce: B64Bytes,
254 pub ciphertext: B64Bytes,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct DepositRecord {
265 pub backend_id: String,
267 pub epoch: u64,
269 pub seq: u64,
271 pub contributor_key_id: String,
273 pub sealed_cred: DepositSealedCred,
275 pub signature: B64Bytes,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct DepositSealedCred {
282 pub encapsulated_key: B64Bytes,
284 pub nonce: B64Bytes,
286 pub ciphertext: B64Bytes,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct BundleBody {
293 pub header_b64: B64Bytes,
296 pub header: Header,
298 pub slots: Vec<Slot>,
300 pub payload: SealedPayload,
302 #[serde(default)]
304 pub deposits: Vec<DepositRecord>,
305}
306
307#[derive(Debug, Clone)]
309pub struct ParsedBundle {
310 pub body: BundleBody,
312}
313
314impl ParsedBundle {
315 #[must_use]
317 pub fn header_aad(&self) -> &[u8] {
318 &self.body.header_b64.0
319 }
320}
321
322pub fn encode(
330 header: &Header,
331 header_aad: &[u8],
332 slots: Vec<Slot>,
333 payload: SealedPayload,
334) -> Result<Vec<u8>, SealError> {
335 encode_with_deposits(header, header_aad, slots, payload, Vec::new())
336}
337
338pub fn encode_with_deposits(
343 header: &Header,
344 header_aad: &[u8],
345 slots: Vec<Slot>,
346 payload: SealedPayload,
347 deposits: Vec<DepositRecord>,
348) -> Result<Vec<u8>, SealError> {
349 let body = BundleBody {
350 header_b64: B64Bytes(header_aad.to_vec()),
351 header: header.clone(),
352 slots,
353 payload,
354 deposits,
355 };
356 let body_json =
357 serde_json::to_vec(&body).map_err(|e| SealError::Format(format!("body serialize: {e}")))?;
358
359 let mut out = Vec::with_capacity(MAGIC.len() + 2 + body_json.len());
360 out.extend_from_slice(MAGIC);
361 out.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
362 out.extend_from_slice(&body_json);
363 Ok(out)
364}
365
366pub fn deposit_signing_bytes(record: &DepositRecord) -> Result<Vec<u8>, SealError> {
376 #[derive(Serialize)]
377 struct Signed<'a> {
378 backend_id: &'a str,
379 epoch: u64,
380 seq: u64,
381 contributor_key_id: &'a str,
382 sealed_cred: &'a DepositSealedCred,
383 }
384
385 serde_json::to_vec(&Signed {
386 backend_id: &record.backend_id,
387 epoch: record.epoch,
388 seq: record.seq,
389 contributor_key_id: &record.contributor_key_id,
390 sealed_cred: &record.sealed_cred,
391 })
392 .map_err(|e| SealError::Format(format!("deposit canonical serialize: {e}")))
393}
394
395pub fn decode(bytes: &[u8]) -> Result<ParsedBundle, SealError> {
402 let rest = bytes
403 .strip_prefix(MAGIC)
404 .ok_or_else(|| SealError::Format("bad magic (not a sealed bundle)".into()))?;
405 let (ver_bytes, body_bytes) = rest
406 .split_at_checked(2)
407 .ok_or_else(|| SealError::Format("truncated version field".into()))?;
408 let version = u16::from_be_bytes(
409 <[u8; 2]>::try_from(ver_bytes)
410 .map_err(|_| SealError::Format("truncated version field".into()))?,
411 );
412 if version != FORMAT_VERSION {
413 return Err(SealError::Format(format!(
414 "unsupported format_version {version} (this build understands {FORMAT_VERSION})"
415 )));
416 }
417
418 let body: BundleBody = serde_json::from_slice(body_bytes)
419 .map_err(|e| SealError::Format(format!("body parse: {e}")))?;
420
421 let header_from_aad: Header = serde_json::from_slice(&body.header_b64.0)
426 .map_err(|e| SealError::Format(format!("header (aad) parse: {e}")))?;
427 if header_from_aad != body.header {
428 return Err(SealError::Format(
429 "header / header_b64 mismatch (tampered container)".into(),
430 ));
431 }
432 if body.header.format_version != FORMAT_VERSION {
433 return Err(SealError::Format(
434 "header.format_version disagrees with framing".into(),
435 ));
436 }
437 if body.header.suite != Suite::v1() {
438 return Err(SealError::Format("unknown suite ids".into()));
439 }
440
441 Ok(ParsedBundle { body })
442}
443
444mod b64_array_16 {
446 use super::B64;
447 use base64::Engine;
448 use serde::{Deserialize, Deserializer, Serializer};
449
450 pub fn serialize<S: Serializer>(v: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
451 s.serialize_str(&B64.encode(v))
452 }
453
454 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
455 let s = String::deserialize(d)?;
456 let v = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
457 <[u8; 16]>::try_from(v.as_slice())
458 .map_err(|_| serde::de::Error::custom("bundle_id must be 16 bytes"))
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 fn sample_header() -> Header {
467 Header {
468 format_version: FORMAT_VERSION,
469 suite: Suite::v1(),
470 bundle_id: [7u8; 16],
471 created_unix: 1_700_000_000,
472 epoch: 1,
473 }
474 }
475
476 #[test]
477 fn encode_decode_round_trip() {
478 let header = sample_header();
479 let aad = header.to_aad_bytes().unwrap();
480 let payload = SealedPayload {
481 nonce: B64Bytes(vec![1; 12]),
482 ciphertext: B64Bytes(vec![2; 48]),
483 };
484 let file = encode(&header, &aad, vec![], payload).unwrap();
485 assert!(file.starts_with(MAGIC));
486 let parsed = decode(&file).unwrap();
487 assert_eq!(parsed.body.header, header);
488 assert_eq!(parsed.header_aad(), aad.as_slice());
489 }
490
491 #[test]
492 fn bad_magic_fails() {
493 let err = decode(b"not a bundle at all").unwrap_err();
494 assert!(matches!(err, SealError::Format(_)));
495 }
496
497 #[test]
498 fn wrong_version_fails_before_parse() {
499 let mut file = Vec::new();
500 file.extend_from_slice(MAGIC);
501 file.extend_from_slice(&2u16.to_be_bytes());
502 file.extend_from_slice(b"{}");
503 let err = decode(&file).unwrap_err();
504 assert!(matches!(err, SealError::Format(m) if m.contains("unsupported format_version")));
505 }
506
507 #[test]
508 fn aead_id_serializes_to_spec_string() {
509 let v = serde_json::to_value(AeadId::Aes256Gcm).unwrap();
510 assert_eq!(v, serde_json::json!("aes-256-gcm"));
511 }
512}