use crate::keys::NodeKey;
use crate::proto::codec::FormatError;
use crate::proto::path::EnvPath;
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, Zeroizing};
use crate::error::{Error, code};
pub const KIT_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KitKind {
Recovery,
Delegation,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Kit {
v: u32,
kind: KitKind,
path: EnvPath,
server: String,
key: String,
}
impl std::fmt::Debug for Kit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Kit")
.field("kind", &self.kind)
.field("path", &self.path)
.field("server", &self.server)
.field("key", &"<redacted>")
.finish()
}
}
impl Drop for Kit {
fn drop(&mut self) {
self.key.zeroize();
}
}
fn invalid(message: impl Into<String>) -> Error {
Error::local(code::INVALID_KIT, message)
}
#[derive(Debug)]
pub(crate) enum KitFault {
Syntax,
Version(u32),
Server(String),
Key(FormatError),
NotAProject(String),
}
impl KitFault {
fn into_error(self) -> Error {
match self {
KitFault::Syntax => invalid("not a valid kit file"),
KitFault::Key(FormatError::UnknownVersion { found }) => invalid(format!(
"the kit's key carries version {found}, which this build does not know"
)),
KitFault::Version(v) => invalid(format!("kit version {v} is not supported")),
KitFault::Server(e) => Error::local(code::INVALID_SERVER, e),
KitFault::Key(_) => invalid("the kit's key is damaged (its checksum does not match)"),
KitFault::NotAProject(path) => {
invalid(format!("a recovery kit is for a project, not {path}"))
}
}
}
}
impl Kit {
pub(crate) fn new(kind: KitKind, path: EnvPath, server: &str, key: &NodeKey) -> Kit {
Kit {
v: KIT_VERSION,
kind,
path,
server: server.to_owned(),
key: key.encode().to_string(),
}
}
pub fn kind(&self) -> KitKind {
self.kind
}
pub fn path(&self) -> &EnvPath {
&self.path
}
pub fn server(&self) -> &str {
&self.server
}
pub(crate) fn key(&self) -> Result<NodeKey, Error> {
NodeKey::parse(&self.key).map_err(|e| match e {
FormatError::UnknownVersion { found } => invalid(format!(
"the kit's key carries version {found}, which this build does not know"
)),
_ => invalid("the kit's key is damaged (its checksum does not match)"),
})
}
pub fn render(&self) -> Result<Zeroizing<String>, Error> {
self.render_with_header("galata-vault")
}
pub fn render_with_header(&self, header: &str) -> Result<Zeroizing<String>, Error> {
let what = match self.kind {
KitKind::Recovery => format!(
"the project {:?} and every environment in it",
self.path.to_string()
),
KitKind::Delegation => format!("{} and everything beneath it", self.path),
};
let body = Zeroizing::new(
toml::to_string(self).map_err(|_| Error::other("the kit could not be encoded"))?,
);
Ok(Zeroizing::new(format!(
"# {header} {} kit (v{KIT_VERSION})\n\
#\n\
# Whoever holds this file owns {what}.\n\
# There is no account and no reset: lose every copy of this key and\n\
# the secrets are gone. Keep it offline or in a password manager,\n\
# and never commit it.\n\
{}",
match self.kind {
KitKind::Recovery => "recovery",
KitKind::Delegation => "delegation",
},
body.as_str()
)))
}
pub fn parse(text: &str) -> Result<Kit, Error> {
Kit::check(text).map_err(KitFault::into_error)
}
pub(crate) fn check(text: &str) -> Result<Kit, KitFault> {
let kit: Kit = toml::from_str(text).map_err(|_| KitFault::Syntax)?;
if kit.v != KIT_VERSION {
return Err(KitFault::Version(kit.v));
}
crate::proto::url::validate_server_url(&kit.server).map_err(KitFault::Server)?;
NodeKey::parse(&kit.key).map_err(KitFault::Key)?;
if kit.kind == KitKind::Recovery && !kit.path.is_project() {
return Err(KitFault::NotAProject(kit.path.to_string()));
}
Ok(kit)
}
pub fn file_name(&self) -> String {
default_file_name(&self.path, self.kind)
}
}
pub fn default_file_name(path: &EnvPath, kind: KitKind) -> String {
let stem = path.to_string().replace('/', "-");
match kind {
KitKind::Recovery => format!("{stem}-recovery.gvkit"),
KitKind::Delegation => format!("{stem}-delegation.gvkit"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kits_roundtrip_and_never_echo_keys() {
let key = NodeKey::generate();
let kit = Kit::new(
KitKind::Recovery,
"acme".parse().unwrap(),
"https://vault.example",
&key,
);
assert_eq!(kit.file_name(), "acme-recovery.gvkit");
let text = kit.render().unwrap();
assert!(text.starts_with("# galata-vault recovery kit (v1)"));
assert!(text.contains("gvk1_"));
let back = Kit::parse(&text).unwrap();
assert_eq!(back.key().unwrap().encode(), key.encode());
assert!(!format!("{back:?}").contains("gvk1_"));
let branded = kit.render_with_header("acme-cloud").unwrap();
assert!(branded.starts_with("# acme-cloud recovery kit (v1)"));
assert!(Kit::parse(&branded).is_ok());
let damaged = text.replace("gvk1_", "gvk1_x");
let err = Kit::parse(&damaged).unwrap_err().to_string();
assert!(!err.contains("gvk1_"), "{err}");
let bad_server = text.replace("https://vault.example", "http://evil.example");
assert!(Kit::parse(&bad_server).is_err());
let deep = text.replace("path = \"acme\"", "path = \"acme/dev\"");
assert!(
Kit::parse(&deep).is_err(),
"a recovery kit is for a project"
);
}
#[test]
fn a_key_of_another_version_is_refused() {
let other = crate::proto::codec::encode_checked("gvk2_", &[5u8; 32]);
let text = format!(
"v = 1\nkind = \"recovery\"\npath = \"acme\"\nserver = \"https://vault.example\"\nkey = \"{}\"\n",
other.as_str()
);
let e = Kit::parse(&text).unwrap_err();
assert_eq!(e.code(), code::INVALID_KIT);
let err = e.to_string();
assert!(err.contains("version 2"), "{err}");
assert!(!err.contains(&other[5..]), "{err}");
let relabelled = text.replace("v = 1", "v = 2");
let err = Kit::parse(&relabelled).unwrap_err().to_string();
assert!(err.contains("kit version 2"), "{err}");
}
}