Skip to main content

coven_foundation/
code_envelope.rs

1//! Shared wire format for pasted coven codes: `prefix + base64url(json)`.
2//!
3//! Restore, device-pairing, and membership-operation codes are JSON payloads
4//! wrapped the same way — a recognizable prefix, then the payload
5//! base64url-encoded — so they share one implementation of those mechanics.
6
7use base64::engine::general_purpose::URL_SAFE_NO_PAD;
8use base64::Engine;
9use serde::de::DeserializeOwned;
10use serde::Serialize;
11
12/// Prefix on a pasted coven code, so a string from an unrelated format is
13/// rejected immediately with a clear "missing prefix" error rather than
14/// failing confusingly at base64 or JSON decode.
15pub const PREFIX: &str = "coven:";
16
17/// An envelope-level decode failure. Each caller maps this to its own
18/// user-facing error type.
19#[derive(Debug, thiserror::Error)]
20pub enum EnvelopeError {
21    #[error("missing expected code prefix {expected:?}")]
22    MissingPrefix { expected: String },
23    #[error("invalid base64url payload")]
24    InvalidBase64(#[source] base64::DecodeError),
25    #[error("invalid JSON payload")]
26    InvalidJson(#[source] serde_json::Error),
27}
28
29#[derive(Debug, thiserror::Error)]
30pub enum FixedHexError {
31    #[error("{label} is not hex")]
32    InvalidHex {
33        label: String,
34        #[source]
35        source: hex::FromHexError,
36    },
37    #[error("{label} must be {expected_len} bytes, got {actual_len}")]
38    InvalidLength {
39        label: String,
40        expected_len: usize,
41        actual_len: usize,
42    },
43}
44
45/// Encode `code` as `{prefix}{base64url(json)}`.
46pub fn encode_code<T: Serialize>(prefix: &str, code: &T) -> String {
47    let json = serde_json::to_vec(code).expect("code is always serializable");
48    let b64 = URL_SAFE_NO_PAD.encode(&json);
49    format!("{prefix}{b64}")
50}
51
52/// Decode `{prefix}{base64url(json)}` back into `T`. Trims surrounding
53/// whitespace first, so a pasted code with stray leading/trailing newlines
54/// still decodes.
55pub fn decode_code<T: DeserializeOwned>(prefix: &str, s: &str) -> Result<T, EnvelopeError> {
56    let trimmed = s.trim();
57    let payload = trimmed
58        .strip_prefix(prefix)
59        .ok_or_else(|| EnvelopeError::MissingPrefix {
60            expected: prefix.to_string(),
61        })?;
62    let bytes = URL_SAFE_NO_PAD
63        .decode(payload)
64        .map_err(EnvelopeError::InvalidBase64)?;
65    serde_json::from_slice(&bytes).map_err(EnvelopeError::InvalidJson)
66}
67
68/// Decode fixed-length hex material carried inside a pasted code.
69pub fn decode_fixed_hex(
70    label: &str,
71    value: &str,
72    expected_len: usize,
73) -> Result<Vec<u8>, FixedHexError> {
74    let bytes = hex::decode(value).map_err(|source| FixedHexError::InvalidHex {
75        label: label.to_string(),
76        source,
77    })?;
78    if bytes.len() != expected_len {
79        return Err(FixedHexError::InvalidLength {
80            label: label.to_string(),
81            expected_len,
82            actual_len: bytes.len(),
83        });
84    }
85    Ok(bytes)
86}