Skip to main content

offline_protocol/file_store/
key.rs

1//! Where the sealed MLS file store gets its key.
2
3use base64::Engine as _;
4use zeroize::Zeroizing;
5
6/// Length of a store key.
7pub const STORE_KEY_BYTES: usize = 32;
8
9/// Why a [`StoreKeyProvider`] produced no key.
10#[non_exhaustive]
11#[derive(Debug, thiserror::Error)]
12pub enum StoreKeyError {
13    /// No key is configured where the provider looks.
14    #[error("no store key is configured: {0}")]
15    Missing(String),
16    /// Something is configured, but it is not a usable key.
17    #[error("the configured store key is not usable: {0}")]
18    Malformed(String),
19    /// The provider's source failed.
20    #[error("the store key source failed: {0}")]
21    Unavailable(String),
22}
23
24/// Supplies the store key that [`super::SealedFileMlsStorage`] seals under.
25///
26/// The store key protects the device's identity keys, its group state and
27/// the record key that seals protocol state, so it is the one secret a
28/// headless host has to keep. The SDK asks for it once, when the store opens,
29/// derives its working keys from it and keeps nothing else.
30///
31/// A host with a keystore (a TPM, a secrets manager, a service manager's
32/// credential store) implements this trait over it. Without one, the key
33/// arrives in the clear from wherever the operator put it; the threat model
34/// records that as residual risk R18.
35pub trait StoreKeyProvider {
36    /// Returns the 32-byte store key.
37    fn store_key(&self) -> Result<Zeroizing<[u8; STORE_KEY_BYTES]>, StoreKeyError>;
38}
39
40/// A store key the host already holds.
41pub struct StaticStoreKey(Zeroizing<[u8; STORE_KEY_BYTES]>);
42
43impl StaticStoreKey {
44    /// Wraps a key.
45    pub fn new(key: [u8; STORE_KEY_BYTES]) -> Self {
46        Self(Zeroizing::new(key))
47    }
48
49    /// Wraps a key given as a slice, refusing anything but 32 bytes or an
50    /// all-zero key (the value an unset buffer has, never a real key).
51    pub fn from_slice(key: &[u8]) -> Result<Self, StoreKeyError> {
52        let key: [u8; STORE_KEY_BYTES] = key.try_into().map_err(|_| {
53            StoreKeyError::Malformed(format!(
54                "a store key is {STORE_KEY_BYTES} bytes, got {}",
55                key.len()
56            ))
57        })?;
58        let key = Zeroizing::new(key);
59        refuse_all_zero(&key)?;
60        Ok(Self(key))
61    }
62}
63
64impl std::fmt::Debug for StaticStoreKey {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str("StaticStoreKey(<redacted>)")
67    }
68}
69
70impl StoreKeyProvider for StaticStoreKey {
71    fn store_key(&self) -> Result<Zeroizing<[u8; STORE_KEY_BYTES]>, StoreKeyError> {
72        refuse_all_zero(&self.0)?;
73        Ok(self.0.clone())
74    }
75}
76
77/// A store key read from an environment variable, as 64 hex digits or as
78/// standard base64 (padded or not).
79///
80/// This is how a service manager usually hands a process a secret. The
81/// variable is read once, when the store opens. The SDK cannot scrub the
82/// process environment, so the host should unset the variable after the
83/// store is open if nothing else needs it.
84#[derive(Debug, Clone)]
85pub struct EnvStoreKey {
86    variable: String,
87}
88
89impl EnvStoreKey {
90    /// The variable [`EnvStoreKey::default`] reads.
91    pub const DEFAULT_VARIABLE: &'static str = "OFFLINE_PROTOCOL_STORE_KEY";
92
93    /// Reads the key from `variable`.
94    pub fn new(variable: impl Into<String>) -> Self {
95        Self {
96            variable: variable.into(),
97        }
98    }
99}
100
101impl Default for EnvStoreKey {
102    fn default() -> Self {
103        Self::new(Self::DEFAULT_VARIABLE)
104    }
105}
106
107impl StoreKeyProvider for EnvStoreKey {
108    fn store_key(&self) -> Result<Zeroizing<[u8; STORE_KEY_BYTES]>, StoreKeyError> {
109        key_from_variable(&self.variable, std::env::var(&self.variable))
110    }
111}
112
113/// Turns what the environment holds for `variable` into a key. Separate from
114/// the read so tests exercise it without mutating the process environment,
115/// which is unsound to do while other test threads read it.
116fn key_from_variable(
117    variable: &str,
118    value: Result<String, std::env::VarError>,
119) -> Result<Zeroizing<[u8; STORE_KEY_BYTES]>, StoreKeyError> {
120    let raw = match value {
121        Ok(raw) => Zeroizing::new(raw),
122        Err(std::env::VarError::NotPresent) => {
123            return Err(StoreKeyError::Missing(format!(
124                "the environment variable {variable} is not set"
125            )))
126        }
127        Err(std::env::VarError::NotUnicode(_)) => {
128            return Err(StoreKeyError::Malformed(format!(
129                "the environment variable {variable} is not valid text"
130            )))
131        }
132    };
133    let decoded = decode_key_text(raw.trim()).ok_or_else(|| {
134        StoreKeyError::Malformed(format!(
135            "{variable} must be {STORE_KEY_BYTES} bytes as 64 hex digits or as base64"
136        ))
137    })?;
138    StaticStoreKey::from_slice(&decoded)?.store_key()
139}
140
141fn decode_key_text(text: &str) -> Option<Zeroizing<Vec<u8>>> {
142    if text.len() == 2 * STORE_KEY_BYTES && text.bytes().all(|b| b.is_ascii_hexdigit()) {
143        return hex::decode(text).ok().map(Zeroizing::new);
144    }
145    let engines = [
146        base64::engine::general_purpose::STANDARD,
147        base64::engine::general_purpose::STANDARD_NO_PAD,
148    ];
149    engines
150        .iter()
151        .find_map(|engine| engine.decode(text).ok())
152        .filter(|bytes| bytes.len() == STORE_KEY_BYTES)
153        .map(Zeroizing::new)
154}
155
156fn refuse_all_zero(key: &[u8; STORE_KEY_BYTES]) -> Result<(), StoreKeyError> {
157    if key.iter().all(|&b| b == 0) {
158        return Err(StoreKeyError::Malformed(
159            "an all-zero store key is an unset buffer, not a key".to_string(),
160        ));
161    }
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// What the provider does with `value` found in the environment, without
170    /// touching the environment: `set_var` races every other test thread's
171    /// `getenv`.
172    fn from_env(
173        variable: &str,
174        value: Option<&str>,
175    ) -> Result<Zeroizing<[u8; STORE_KEY_BYTES]>, StoreKeyError> {
176        key_from_variable(
177            variable,
178            value
179                .map(str::to_string)
180                .ok_or(std::env::VarError::NotPresent),
181        )
182    }
183
184    #[test]
185    fn hex_and_base64_both_decode_to_the_same_key() {
186        let key: Vec<u8> = (1u8..=32).collect();
187        let hex_text = hex::encode(&key);
188        let b64 = base64::engine::general_purpose::STANDARD.encode(&key);
189        let b64_no_pad = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&key);
190
191        for (variable, text) in [
192            ("OP_TEST_STORE_KEY_HEX", hex_text.clone()),
193            ("OP_TEST_STORE_KEY_HEX_UPPER", hex_text.to_uppercase()),
194            ("OP_TEST_STORE_KEY_B64", b64),
195            ("OP_TEST_STORE_KEY_B64_NOPAD", b64_no_pad),
196            ("OP_TEST_STORE_KEY_PADDED", format!("  {hex_text}\n")),
197        ] {
198            let got =
199                from_env(variable, Some(&text)).unwrap_or_else(|err| panic!("{variable}: {err}"));
200            assert_eq!(got.as_slice(), key.as_slice(), "{variable}");
201        }
202    }
203
204    #[test]
205    fn an_absent_short_long_or_zero_key_is_refused() {
206        assert!(matches!(
207            from_env("OP_TEST_STORE_KEY_ABSENT", None),
208            Err(StoreKeyError::Missing(_))
209        ));
210        for (variable, text) in [
211            ("OP_TEST_STORE_KEY_SHORT", hex::encode([7u8; 31])),
212            ("OP_TEST_STORE_KEY_LONG", hex::encode([7u8; 33])),
213            ("OP_TEST_STORE_KEY_ZERO", hex::encode([0u8; 32])),
214            ("OP_TEST_STORE_KEY_EMPTY", String::new()),
215            (
216                "OP_TEST_STORE_KEY_WORDS",
217                "correct horse battery staple".to_string(),
218            ),
219        ] {
220            assert!(
221                matches!(
222                    from_env(variable, Some(&text)),
223                    Err(StoreKeyError::Malformed(_))
224                ),
225                "{variable} accepted"
226            );
227        }
228    }
229
230    /// The one read of the real environment: a variable nobody sets.
231    #[test]
232    fn an_unset_variable_is_missing() {
233        assert!(matches!(
234            EnvStoreKey::new("OFFLINE_PROTOCOL_TEST_STORE_KEY_NEVER_SET").store_key(),
235            Err(StoreKeyError::Missing(_))
236        ));
237    }
238
239    #[test]
240    fn a_static_key_is_length_checked_and_redacted() {
241        assert!(StaticStoreKey::from_slice(&[1u8; 31]).is_err());
242        assert!(StaticStoreKey::from_slice(&[0u8; 32]).is_err());
243        assert!(StaticStoreKey::new([0u8; 32]).store_key().is_err());
244        let key = StaticStoreKey::from_slice(&[0xabu8; 32]).expect("key");
245        assert_eq!(format!("{key:?}"), "StaticStoreKey(<redacted>)");
246        assert_eq!(*key.store_key().expect("key"), [0xabu8; 32]);
247    }
248}