Skip to main content

hayson_binding_shared/
keys.rs

1//! Cypher key material a host process was started with — the file one environment variable names,
2//! or the document another one holds inline.
3//!
4//! The document is the `cypher` sub-object of the `open()` options document, parsed by
5//! [`crate::options::parse_cypher`] and nothing else, so a key file reads exactly the same here,
6//! in `hayson-browser` and in a `open({ cypher })` call from JS.
7//!
8//! Validation happens at load, not at the first sealed record: a key file that does not parse, or
9//! material that is not 32 bytes, is a startup failure with a message naming the variable it came
10//! from. The alternative — a process running with silently no keys — turns one bad path into a
11//! read that refuses hours later, somewhere else entirely.
12
13use std::fmt;
14use std::fs;
15use std::path::Path;
16
17use hayson_engine::crypto::{CipherOptions, FieldCipher};
18use serde_json::Value;
19
20use crate::options::{parse_cypher, OptionsError};
21
22/// The pair of environment variables one host reads its keys from — `<FILE>` naming a JSON file,
23/// `<INLINE>` holding the same document as text. By convention `HAYSON_<APP>_CYPHER_KEY_FILE` and
24/// `HAYSON_<APP>_CYPHER_KEYS`.
25#[derive(Debug, Clone, Copy)]
26pub struct CypherKeyVars {
27    pub file: &'static str,
28    pub inline: &'static str,
29}
30
31#[derive(Debug, PartialEq, Eq)]
32pub struct KeyringError(String);
33
34impl KeyringError {
35    pub fn message(&self) -> &str {
36        &self.0
37    }
38}
39
40impl fmt::Display for KeyringError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46impl std::error::Error for KeyringError {}
47
48/// Key material a process holds, and where it came from. The ids are readable back; the material
49/// is not — a host printing what it loaded prints [`CypherKeyring::key_ids`] and never more.
50#[derive(Debug, Clone)]
51pub struct CypherKeyring {
52    options: CipherOptions,
53    source: String,
54}
55
56impl CypherKeyring {
57    /// The ids on hand, sorted. Never the material.
58    pub fn key_ids(&self) -> Vec<String> {
59        let mut ids: Vec<String> = self.options.keys.keys().cloned().collect();
60        ids.sort();
61        ids
62    }
63
64    /// Which variable, and which file, these keys came from — for the line a host prints at start.
65    pub fn source(&self) -> &str {
66        &self.source
67    }
68
69    pub fn options(&self) -> &CipherOptions {
70        &self.options
71    }
72
73    pub fn into_options(self) -> CipherOptions {
74        self.options
75    }
76}
77
78fn refused(message: impl Into<String>) -> KeyringError {
79    KeyringError(message.into())
80}
81
82fn describe(source: &str, error: OptionsError) -> KeyringError {
83    let reason = match error {
84        OptionsError::Shape(message) | OptionsError::Key(message) => message,
85    };
86    refused(format!("{source} does not hold usable cypher options: {reason}"))
87}
88
89/// Reads a `cypher` document out of JSON text, naming `source` in every refusal.
90///
91/// The options are taken all the way to a [`FieldCipher`] and the cipher thrown away again:
92/// parsing only decodes the material, and it is the cipher that judges whether 32 bytes came out
93/// of it and whether `keyId` names a key that is actually there.
94pub fn from_json_text(text: &str, source: impl Into<String>) -> Result<CypherKeyring, KeyringError> {
95    let source = source.into();
96    let document: Value =
97        serde_json::from_str(text).map_err(|error| refused(format!("{source} is not valid JSON: {error}")))?;
98    let options = parse_cypher(&document).map_err(|error| describe(&source, error))?;
99    FieldCipher::new(options.clone())
100        .map_err(|error| refused(format!("{source} holds unusable cypher keys: {}", error.reason)))?;
101    Ok(CypherKeyring { options, source })
102}
103
104/// The keys `vars` names, or `None` when neither variable is set to anything. The file wins over
105/// the inline document, so a host can override a baked-in environment by pointing at a file.
106///
107/// `lookup` is the environment, passed in rather than read here so a test can describe one without
108/// touching the process it runs in.
109pub fn ambient(
110    vars: CypherKeyVars,
111    lookup: impl Fn(&str) -> Option<String>,
112) -> Result<Option<CypherKeyring>, KeyringError> {
113    if let Some(path) = lookup(vars.file).filter(|path| !path.is_empty()) {
114        return from_file(Path::new(&path), vars.file).map(Some);
115    }
116    match lookup(vars.inline).filter(|text| !text.is_empty()) {
117        Some(text) => from_json_text(&text, vars.inline).map(Some),
118        None => Ok(None),
119    }
120}
121
122/// The keys the JSON file at `path` holds. `named_by` is the flag or variable that pointed here,
123/// so a refusal says how the process was asked for this file rather than only which file it was.
124pub fn from_file(path: &Path, named_by: &str) -> Result<CypherKeyring, KeyringError> {
125    let shown = path.display();
126    let text = fs::read_to_string(path).map_err(|error| {
127        refused(format!(
128            "{named_by} names \"{shown}\", which could not be read: {error}"
129        ))
130    })?;
131    from_json_text(&text, format!("{named_by}=\"{shown}\""))
132}
133
134#[cfg(test)]
135mod tests {
136    use std::collections::HashMap;
137
138    use serde_json::json;
139
140    use super::*;
141
142    const VARS: CypherKeyVars = CypherKeyVars {
143        file: "HAYSON_TEST_CYPHER_KEY_FILE",
144        inline: "HAYSON_TEST_CYPHER_KEYS",
145    };
146
147    fn key_document() -> String {
148        json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex" }).to_string()
149    }
150
151    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
152        let map: HashMap<String, String> = pairs
153            .iter()
154            .map(|(name, value)| ((*name).to_string(), (*value).to_string()))
155            .collect();
156        move |name: &str| map.get(name).cloned()
157    }
158
159    #[test]
160    fn an_environment_naming_neither_variable_leaves_the_process_without_keys() {
161        let keyring = ambient(VARS, env(&[])).expect("an empty environment is not an error");
162
163        assert!(keyring.is_none());
164    }
165
166    #[test]
167    fn an_inline_document_is_read_and_only_its_ids_are_readable_back() {
168        let keyring = ambient(VARS, env(&[(VARS.inline, &key_document())]))
169            .expect("the document is usable")
170            .expect("the variable was set");
171
172        assert_eq!(keyring.key_ids(), vec!["k1".to_string()]);
173        assert_eq!(keyring.source(), VARS.inline);
174        assert_eq!(keyring.options().keys["k1"], vec![1u8; 32]);
175    }
176
177    #[test]
178    fn the_file_variable_wins_and_is_not_fallen_back_on() {
179        let error = ambient(
180            VARS,
181            env(&[(VARS.file, "/no/such/keys.json"), (VARS.inline, &key_document())]),
182        )
183        .expect_err("the file was named, so a usable inline document must not paper over it");
184
185        assert!(error.message().contains(VARS.file), "{error}");
186    }
187
188    #[test]
189    fn a_file_that_cannot_be_read_names_the_variable_that_pointed_at_it() {
190        let error = ambient(VARS, env(&[(VARS.file, "/no/such/keys.json")])).expect_err("the file is not there");
191
192        assert!(error.message().contains(VARS.file), "{error}");
193        assert!(error.message().contains("/no/such/keys.json"), "{error}");
194    }
195
196    #[test]
197    fn material_of_the_wrong_length_is_refused_at_load_not_at_the_first_read() {
198        let short = json!({ "keys": { "k1": "AAAA" } }).to_string();
199
200        let error = ambient(VARS, env(&[(VARS.inline, &short)])).expect_err("3 bytes cannot be an AES-256 key");
201
202        assert!(error.message().contains("32 bytes"), "{error}");
203    }
204
205    #[test]
206    fn a_key_id_naming_no_key_is_refused_at_load() {
207        let document = json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex", "keyId": "k9" }).to_string();
208
209        let error = ambient(VARS, env(&[(VARS.inline, &document)])).expect_err("k9 is not among the keys");
210
211        assert!(error.message().contains("k9"), "{error}");
212    }
213
214    #[test]
215    fn a_document_that_is_not_json_at_all_is_refused_by_source() {
216        let error = from_json_text("{ not json", "SOMEWHERE").expect_err("that is not JSON");
217
218        assert!(error.message().starts_with("SOMEWHERE is not valid JSON"), "{error}");
219    }
220
221    #[test]
222    fn the_on_missing_key_policy_is_carried_through_untouched() {
223        let document = json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex", "onMissingKey": "omit" });
224
225        let keyring = from_json_text(&document.to_string(), "SOMEWHERE").expect("the document is usable");
226
227        assert_eq!(
228            keyring.options().on_missing_key,
229            Some(hayson_engine::crypto::MissingKeyPolicy::Omit),
230            "a host that wants the engine's default must not have one forced on it here"
231        );
232    }
233}