hayson_binding_shared/
keys.rs1use 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#[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#[derive(Debug, Clone)]
51pub struct CypherKeyring {
52 options: CipherOptions,
53 source: String,
54}
55
56impl CypherKeyring {
57 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 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
89pub 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
104pub 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
122pub 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}