use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use serde::{Deserialize, Serialize};
use super::SealError;
pub const MAGIC: &[u8] = b"BASILBDL\x00";
pub const FORMAT_VERSION: u16 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Suite {
pub container: ContainerId,
pub payload_aead: AeadId,
pub kek_wrap_aead: AeadId,
}
impl Suite {
#[must_use]
pub const fn v1() -> Self {
Self {
container: ContainerId::Json,
payload_aead: AeadId::Aes256Gcm,
kek_wrap_aead: AeadId::Aes256Gcm,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ContainerId {
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename = "aes-256-gcm")]
pub enum AeadId {
#[serde(rename = "aes-256-gcm")]
Aes256Gcm,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Header {
pub format_version: u16,
pub suite: Suite,
#[serde(with = "b64_array_16")]
pub bundle_id: [u8; 16],
pub created_unix: u64,
pub epoch: u64,
}
impl Header {
pub fn to_aad_bytes(&self) -> Result<Vec<u8>, SealError> {
serde_json::to_vec(self).map_err(|e| SealError::Format(format!("header serialize: {e}")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct B64Bytes(pub Vec<u8>);
impl Serialize for B64Bytes {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&B64.encode(&self.0))
}
}
impl<'de> Deserialize<'de> for B64Bytes {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
B64.decode(s.as_bytes())
.map(Self)
.map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Slot {
pub slot_id: u32,
pub method: MethodKind,
pub label: String,
pub created_unix: u64,
pub params: MethodParams,
pub wrap: KekWrap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MethodKind {
AgeYubikey,
Bip39,
Passphrase,
Tpm,
}
impl std::fmt::Display for MethodKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::AgeYubikey => "age-yubikey",
Self::Bip39 => "bip39",
Self::Passphrase => "passphrase",
Self::Tpm => "tpm",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum MethodParams {
AgeYubikey {
recipient: String,
},
Bip39 {
salt: B64Bytes,
argon2: Argon2Params,
},
Passphrase {
salt: B64Bytes,
argon2: Argon2Params,
},
Tpm {
public: B64Bytes,
private: B64Bytes,
pcrs: TpmPcrSelection,
name_alg: String,
srk_template: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TpmPcrSelection {
pub bank: String,
pub pcrs: Vec<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Argon2Params {
pub m_cost_kib: u32,
pub t_cost: u32,
pub p_cost: u32,
}
impl Argon2Params {
pub const PRODUCTION: Self = Self {
m_cost_kib: 64 * 1024,
t_cost: 3,
p_cost: 1,
};
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KekWrap {
pub nonce: B64Bytes,
pub ciphertext: B64Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SealedPayload {
pub nonce: B64Bytes,
pub ciphertext: B64Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepositRecord {
pub backend_id: String,
pub epoch: u64,
pub seq: u64,
pub contributor_key_id: String,
pub sealed_cred: DepositSealedCred,
pub signature: B64Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepositSealedCred {
pub encapsulated_key: B64Bytes,
pub nonce: B64Bytes,
pub ciphertext: B64Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleBody {
pub header_b64: B64Bytes,
pub header: Header,
pub slots: Vec<Slot>,
pub payload: SealedPayload,
#[serde(default)]
pub deposits: Vec<DepositRecord>,
}
#[derive(Debug, Clone)]
pub struct ParsedBundle {
pub body: BundleBody,
}
impl ParsedBundle {
#[must_use]
pub fn header_aad(&self) -> &[u8] {
&self.body.header_b64.0
}
}
pub fn encode(
header: &Header,
header_aad: &[u8],
slots: Vec<Slot>,
payload: SealedPayload,
) -> Result<Vec<u8>, SealError> {
encode_with_deposits(header, header_aad, slots, payload, Vec::new())
}
pub fn encode_with_deposits(
header: &Header,
header_aad: &[u8],
slots: Vec<Slot>,
payload: SealedPayload,
deposits: Vec<DepositRecord>,
) -> Result<Vec<u8>, SealError> {
let body = BundleBody {
header_b64: B64Bytes(header_aad.to_vec()),
header: header.clone(),
slots,
payload,
deposits,
};
let body_json =
serde_json::to_vec(&body).map_err(|e| SealError::Format(format!("body serialize: {e}")))?;
let mut out = Vec::with_capacity(MAGIC.len() + 2 + body_json.len());
out.extend_from_slice(MAGIC);
out.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
out.extend_from_slice(&body_json);
Ok(out)
}
pub fn deposit_signing_bytes(record: &DepositRecord) -> Result<Vec<u8>, SealError> {
#[derive(Serialize)]
struct Signed<'a> {
backend_id: &'a str,
epoch: u64,
seq: u64,
contributor_key_id: &'a str,
sealed_cred: &'a DepositSealedCred,
}
serde_json::to_vec(&Signed {
backend_id: &record.backend_id,
epoch: record.epoch,
seq: record.seq,
contributor_key_id: &record.contributor_key_id,
sealed_cred: &record.sealed_cred,
})
.map_err(|e| SealError::Format(format!("deposit canonical serialize: {e}")))
}
pub fn decode(bytes: &[u8]) -> Result<ParsedBundle, SealError> {
let rest = bytes
.strip_prefix(MAGIC)
.ok_or_else(|| SealError::Format("bad magic (not a sealed bundle)".into()))?;
let (ver_bytes, body_bytes) = rest
.split_at_checked(2)
.ok_or_else(|| SealError::Format("truncated version field".into()))?;
let version = u16::from_be_bytes(
<[u8; 2]>::try_from(ver_bytes)
.map_err(|_| SealError::Format("truncated version field".into()))?,
);
if version != FORMAT_VERSION {
return Err(SealError::Format(format!(
"unsupported format_version {version} (this build understands {FORMAT_VERSION})"
)));
}
let body: BundleBody = serde_json::from_slice(body_bytes)
.map_err(|e| SealError::Format(format!("body parse: {e}")))?;
let header_from_aad: Header = serde_json::from_slice(&body.header_b64.0)
.map_err(|e| SealError::Format(format!("header (aad) parse: {e}")))?;
if header_from_aad != body.header {
return Err(SealError::Format(
"header / header_b64 mismatch (tampered container)".into(),
));
}
if body.header.format_version != FORMAT_VERSION {
return Err(SealError::Format(
"header.format_version disagrees with framing".into(),
));
}
if body.header.suite != Suite::v1() {
return Err(SealError::Format("unknown suite ids".into()));
}
Ok(ParsedBundle { body })
}
mod b64_array_16 {
use super::B64;
use base64::Engine;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&B64.encode(v))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
let s = String::deserialize(d)?;
let v = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
<[u8; 16]>::try_from(v.as_slice())
.map_err(|_| serde::de::Error::custom("bundle_id must be 16 bytes"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_header() -> Header {
Header {
format_version: FORMAT_VERSION,
suite: Suite::v1(),
bundle_id: [7u8; 16],
created_unix: 1_700_000_000,
epoch: 1,
}
}
#[test]
fn encode_decode_round_trip() {
let header = sample_header();
let aad = header.to_aad_bytes().unwrap();
let payload = SealedPayload {
nonce: B64Bytes(vec![1; 12]),
ciphertext: B64Bytes(vec![2; 48]),
};
let file = encode(&header, &aad, vec![], payload).unwrap();
assert!(file.starts_with(MAGIC));
let parsed = decode(&file).unwrap();
assert_eq!(parsed.body.header, header);
assert_eq!(parsed.header_aad(), aad.as_slice());
}
#[test]
fn bad_magic_fails() {
let err = decode(b"not a bundle at all").unwrap_err();
assert!(matches!(err, SealError::Format(_)));
}
#[test]
fn wrong_version_fails_before_parse() {
let mut file = Vec::new();
file.extend_from_slice(MAGIC);
file.extend_from_slice(&2u16.to_be_bytes());
file.extend_from_slice(b"{}");
let err = decode(&file).unwrap_err();
assert!(matches!(err, SealError::Format(m) if m.contains("unsupported format_version")));
}
#[test]
fn aead_id_serializes_to_spec_string() {
let v = serde_json::to_value(AeadId::Aes256Gcm).unwrap();
assert_eq!(v, serde_json::json!("aes-256-gcm"));
}
}