use std::fmt;
use std::fs;
use std::path::Path;
use hayson_engine::crypto::{CipherOptions, FieldCipher};
use serde_json::Value;
use crate::options::{parse_cypher, OptionsError};
#[derive(Debug, Clone, Copy)]
pub struct CypherKeyVars {
pub file: &'static str,
pub inline: &'static str,
}
#[derive(Debug, PartialEq, Eq)]
pub struct KeyringError(String);
impl KeyringError {
pub fn message(&self) -> &str {
&self.0
}
}
impl fmt::Display for KeyringError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for KeyringError {}
#[derive(Debug, Clone)]
pub struct CypherKeyring {
options: CipherOptions,
source: String,
}
impl CypherKeyring {
pub fn key_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = self.options.keys.keys().cloned().collect();
ids.sort();
ids
}
pub fn source(&self) -> &str {
&self.source
}
pub fn options(&self) -> &CipherOptions {
&self.options
}
pub fn into_options(self) -> CipherOptions {
self.options
}
}
fn refused(message: impl Into<String>) -> KeyringError {
KeyringError(message.into())
}
fn describe(source: &str, error: OptionsError) -> KeyringError {
let reason = match error {
OptionsError::Shape(message) | OptionsError::Key(message) => message,
};
refused(format!("{source} does not hold usable cypher options: {reason}"))
}
pub fn from_json_text(text: &str, source: impl Into<String>) -> Result<CypherKeyring, KeyringError> {
let source = source.into();
let document: Value =
serde_json::from_str(text).map_err(|error| refused(format!("{source} is not valid JSON: {error}")))?;
let options = parse_cypher(&document).map_err(|error| describe(&source, error))?;
FieldCipher::new(options.clone())
.map_err(|error| refused(format!("{source} holds unusable cypher keys: {}", error.reason)))?;
Ok(CypherKeyring { options, source })
}
pub fn ambient(
vars: CypherKeyVars,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Option<CypherKeyring>, KeyringError> {
if let Some(path) = lookup(vars.file).filter(|path| !path.is_empty()) {
return from_file(Path::new(&path), vars.file).map(Some);
}
match lookup(vars.inline).filter(|text| !text.is_empty()) {
Some(text) => from_json_text(&text, vars.inline).map(Some),
None => Ok(None),
}
}
pub fn from_file(path: &Path, named_by: &str) -> Result<CypherKeyring, KeyringError> {
let shown = path.display();
let text = fs::read_to_string(path).map_err(|error| {
refused(format!(
"{named_by} names \"{shown}\", which could not be read: {error}"
))
})?;
from_json_text(&text, format!("{named_by}=\"{shown}\""))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::*;
const VARS: CypherKeyVars = CypherKeyVars {
file: "HAYSON_TEST_CYPHER_KEY_FILE",
inline: "HAYSON_TEST_CYPHER_KEYS",
};
fn key_document() -> String {
json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex" }).to_string()
}
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
.collect();
move |name: &str| map.get(name).cloned()
}
#[test]
fn an_environment_naming_neither_variable_leaves_the_process_without_keys() {
let keyring = ambient(VARS, env(&[])).expect("an empty environment is not an error");
assert!(keyring.is_none());
}
#[test]
fn an_inline_document_is_read_and_only_its_ids_are_readable_back() {
let keyring = ambient(VARS, env(&[(VARS.inline, &key_document())]))
.expect("the document is usable")
.expect("the variable was set");
assert_eq!(keyring.key_ids(), vec!["k1".to_string()]);
assert_eq!(keyring.source(), VARS.inline);
assert_eq!(keyring.options().keys["k1"], vec![1u8; 32]);
}
#[test]
fn the_file_variable_wins_and_is_not_fallen_back_on() {
let error = ambient(
VARS,
env(&[(VARS.file, "/no/such/keys.json"), (VARS.inline, &key_document())]),
)
.expect_err("the file was named, so a usable inline document must not paper over it");
assert!(error.message().contains(VARS.file), "{error}");
}
#[test]
fn a_file_that_cannot_be_read_names_the_variable_that_pointed_at_it() {
let error = ambient(VARS, env(&[(VARS.file, "/no/such/keys.json")])).expect_err("the file is not there");
assert!(error.message().contains(VARS.file), "{error}");
assert!(error.message().contains("/no/such/keys.json"), "{error}");
}
#[test]
fn material_of_the_wrong_length_is_refused_at_load_not_at_the_first_read() {
let short = json!({ "keys": { "k1": "AAAA" } }).to_string();
let error = ambient(VARS, env(&[(VARS.inline, &short)])).expect_err("3 bytes cannot be an AES-256 key");
assert!(error.message().contains("32 bytes"), "{error}");
}
#[test]
fn a_key_id_naming_no_key_is_refused_at_load() {
let document = json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex", "keyId": "k9" }).to_string();
let error = ambient(VARS, env(&[(VARS.inline, &document)])).expect_err("k9 is not among the keys");
assert!(error.message().contains("k9"), "{error}");
}
#[test]
fn a_document_that_is_not_json_at_all_is_refused_by_source() {
let error = from_json_text("{ not json", "SOMEWHERE").expect_err("that is not JSON");
assert!(error.message().starts_with("SOMEWHERE is not valid JSON"), "{error}");
}
#[test]
fn the_on_missing_key_policy_is_carried_through_untouched() {
let document = json!({ "keys": { "k1": "01".repeat(32) }, "keyEncoding": "hex", "onMissingKey": "omit" });
let keyring = from_json_text(&document.to_string(), "SOMEWHERE").expect("the document is usable");
assert_eq!(
keyring.options().on_missing_key,
Some(hayson_engine::crypto::MissingKeyPolicy::Omit),
"a host that wants the engine's default must not have one forced on it here"
);
}
}