use std::collections::BTreeMap;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::model::{App, Credential, Group, Server, Target};
const FORMAT_VERSION: u32 = 3;
const KDF_MEMORY_KIB: u32 = 64 * 1024;
const KDF_ITERATIONS: u32 = 3;
const KDF_PARALLELISM: u32 = 4;
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
#[derive(Serialize, Deserialize)]
pub struct Snapshot {
pub version: u32,
pub exported_by: String,
#[serde(default)]
pub apps: Vec<App>,
#[serde(default)]
pub servers: Vec<Server>,
#[serde(default)]
pub credentials: Vec<Credential>,
#[serde(default)]
pub paths: Vec<crate::model::Path>,
#[serde(default)]
pub groups: Vec<Group>,
#[serde(default)]
pub targets: Vec<Target>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<SealedSecrets>,
}
#[derive(Serialize, Deserialize)]
pub struct SealedSecrets {
pub kdf: Kdf,
pub nonce: String,
pub ciphertext: String,
}
#[derive(Serialize, Deserialize)]
pub struct Kdf {
pub algorithm: String,
pub salt: String,
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Snapshot {
pub fn new(
apps: Vec<App>,
servers: Vec<Server>,
credentials: Vec<Credential>,
paths: Vec<crate::model::Path>,
groups: Vec<Group>,
targets: Vec<Target>,
) -> Self {
Self {
version: FORMAT_VERSION,
exported_by: format!("turnout {}", env!("CARGO_PKG_VERSION")),
apps,
servers,
credentials,
paths,
groups,
targets,
secrets: None,
}
}
pub fn check_version(&self) -> Result<()> {
if self.version > FORMAT_VERSION {
bail!(
"this export uses format version {} but this turnout understands up to {FORMAT_VERSION} - update turnout with `turnout self-update`",
self.version
);
}
if self.version < 2 {
bail!(
"this export was written by turnout 0.8 or older (format version {}), and v0.9.0 split logins and \
remote directories into credentials and paths.\n\
There is no automatic conversion - the new entities need names. Open the file and re-enter it with \
`turnout server add`, `turnout credential add` and `turnout path add`.\n\
See https://lacodda.github.io/turnout/guides/upgrading-to-0-9/",
self.version
);
}
Ok(())
}
pub fn adopt_v2_targets(&mut self, raw: &str) -> Result<usize> {
if self.version >= FORMAT_VERSION {
return Ok(0);
}
let parsed: serde_json::Value = serde_json::from_str(raw)?;
let Some(servers) = parsed.get("servers").and_then(|v| v.as_array()) else {
return Ok(0);
};
let before = self.targets.len();
for server in servers {
let (Some(server_name), Some(deploy)) = (server.get("name").and_then(|v| v.as_str()), server.get("deploy").and_then(|v| v.as_object())) else {
continue;
};
let Some(credential) = server.get("credential").and_then(|v| v.as_str()) else {
continue;
};
for (app, path) in deploy {
let Some(path) = path.as_str() else { continue };
let name = crate::model::unique_target_name(app, server_name, &self.targets);
self.targets.push(Target {
name,
app: app.clone(),
server: server_name.to_string(),
credential: credential.to_string(),
path: path.to_string(),
});
}
}
Ok(self.targets.len() - before)
}
}
pub fn seal(secrets: &BTreeMap<String, String>, passphrase: &str) -> Result<SealedSecrets> {
use chacha20poly1305::aead::{Aead, KeyInit};
let plaintext = serde_json::to_vec(secrets)?;
let salt = random_bytes(SALT_LEN);
let key = derive_key(passphrase, &salt)?;
let nonce_bytes = random_bytes(NONCE_LEN);
let nonce = chacha20poly1305::Nonce::try_from(nonce_bytes.as_slice()).map_err(|_| anyhow::anyhow!("cannot build a nonce"))?;
let cipher = chacha20poly1305::ChaCha20Poly1305::new((&key).into());
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_slice())
.map_err(|_| anyhow::anyhow!("cannot encrypt the secrets"))?;
Ok(SealedSecrets {
kdf: Kdf {
algorithm: "argon2id".to_string(),
salt: encode_base64(&salt),
memory_kib: KDF_MEMORY_KIB,
iterations: KDF_ITERATIONS,
parallelism: KDF_PARALLELISM,
},
nonce: encode_base64(&nonce_bytes),
ciphertext: encode_base64(&ciphertext),
})
}
pub fn open(sealed: &SealedSecrets, passphrase: &str) -> Result<BTreeMap<String, String>> {
use chacha20poly1305::aead::{Aead, KeyInit};
if sealed.kdf.algorithm != "argon2id" {
bail!("unsupported key derivation '{}' in this export", sealed.kdf.algorithm);
}
let salt = decode_base64(&sealed.kdf.salt).context("the export has a malformed salt")?;
let nonce_bytes = decode_base64(&sealed.nonce).context("the export has a malformed nonce")?;
if nonce_bytes.len() != NONCE_LEN {
bail!("the export has a malformed nonce");
}
let ciphertext = decode_base64(&sealed.ciphertext).context("the export has malformed ciphertext")?;
let nonce = chacha20poly1305::Nonce::try_from(nonce_bytes.as_slice()).map_err(|_| anyhow::anyhow!("the export has a malformed nonce"))?;
let key = derive_key_with(passphrase, &salt, sealed.kdf.memory_kib, sealed.kdf.iterations, sealed.kdf.parallelism)?;
let cipher = chacha20poly1305::ChaCha20Poly1305::new((&key).into());
let plaintext = cipher
.decrypt(&nonce, ciphertext.as_slice())
.map_err(|_| anyhow::anyhow!("cannot decrypt the secrets: wrong passphrase, or the file has been altered"))?;
serde_json::from_slice(&plaintext).context("the decrypted secrets are not valid JSON")
}
fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32]> {
derive_key_with(passphrase, salt, KDF_MEMORY_KIB, KDF_ITERATIONS, KDF_PARALLELISM)
}
fn derive_key_with(passphrase: &str, salt: &[u8], memory_kib: u32, iterations: u32, parallelism: u32) -> Result<[u8; 32]> {
let params =
argon2::Params::new(memory_kib, iterations, parallelism, Some(32)).map_err(|err| anyhow::anyhow!("invalid key derivation parameters: {err}"))?;
let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|err| anyhow::anyhow!("cannot derive a key from the passphrase: {err}"))?;
Ok(key)
}
fn random_bytes(len: usize) -> Vec<u8> {
use rand::Rng;
let mut bytes = vec![0u8; len];
rand::rng().fill_bytes(&mut bytes);
bytes
}
fn encode_base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
}
out
}
fn decode_base64(text: &str) -> Result<Vec<u8>> {
let value = |c: u8| -> Result<u32> {
Ok(match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => bail!("invalid base64"),
})
};
let bytes: Vec<u8> = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
if !bytes.len().is_multiple_of(4) {
bail!("invalid base64");
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
let padding = chunk.iter().filter(|&&c| c == b'=').count();
if padding > 2 || (padding > 0 && chunk[3] != b'=') {
bail!("invalid base64");
}
let mut n = 0u32;
for &c in chunk {
n = n << 6 | if c == b'=' { 0 } else { value(c)? };
}
out.push((n >> 16 & 255) as u8);
if padding < 2 {
out.push((n >> 8 & 255) as u8);
}
if padding < 1 {
out.push((n & 255) as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn secrets() -> BTreeMap<String, String> {
BTreeMap::from([
("staging/password".to_string(), "hunter2".to_string()),
("prod/token".to_string(), "ghp_example".to_string()),
])
}
#[test]
fn secrets_survive_a_round_trip() {
let sealed = seal(&secrets(), "correct horse battery staple").unwrap();
let opened = open(&sealed, "correct horse battery staple").unwrap();
assert_eq!(opened, secrets());
}
#[test]
fn a_wrong_passphrase_fails() {
let sealed = seal(&secrets(), "right").unwrap();
let err = open(&sealed, "wrong").unwrap_err().to_string();
assert!(err.contains("wrong passphrase"), "{err}");
}
#[test]
fn no_secret_value_appears_in_the_sealed_form() {
let sealed = seal(&secrets(), "passphrase").unwrap();
let json = serde_json::to_string(&sealed).unwrap();
assert!(!json.contains("hunter2"), "{json}");
assert!(!json.contains("ghp_example"), "{json}");
assert!(!json.contains("staging/password"), "even the account names must not leak: {json}");
}
#[test]
fn sealing_twice_gives_different_ciphertext() {
let first = seal(&secrets(), "passphrase").unwrap();
let second = seal(&secrets(), "passphrase").unwrap();
assert_ne!(first.ciphertext, second.ciphertext);
assert_ne!(first.nonce, second.nonce);
assert_ne!(first.kdf.salt, second.kdf.salt);
}
#[test]
fn a_tampered_file_is_rejected() {
let mut sealed = seal(&secrets(), "passphrase").unwrap();
let mut bytes = decode_base64(&sealed.ciphertext).unwrap();
bytes[0] ^= 0x01;
sealed.ciphertext = encode_base64(&bytes);
assert!(open(&sealed, "passphrase").is_err());
}
#[test]
fn an_unknown_kdf_is_refused() {
let mut sealed = seal(&secrets(), "passphrase").unwrap();
sealed.kdf.algorithm = "scrypt".to_string();
let err = open(&sealed, "passphrase").unwrap_err().to_string();
assert!(err.contains("unsupported key derivation"), "{err}");
}
#[test]
fn an_empty_secret_set_round_trips() {
let empty = BTreeMap::new();
let sealed = seal(&empty, "passphrase").unwrap();
assert_eq!(open(&sealed, "passphrase").unwrap(), empty);
}
#[test]
fn base64_round_trips_every_length() {
for len in 0..32 {
let bytes: Vec<u8> = (0..len).map(|i| (i * 7 + 3) as u8).collect();
let encoded = encode_base64(&bytes);
assert_eq!(encoded.len() % 4, 0, "encoding must be padded: {encoded}");
assert_eq!(decode_base64(&encoded).unwrap(), bytes, "failed at length {len}");
}
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(encode_base64(b""), "");
assert_eq!(encode_base64(b"f"), "Zg==");
assert_eq!(encode_base64(b"fo"), "Zm8=");
assert_eq!(encode_base64(b"foo"), "Zm9v");
assert_eq!(encode_base64(b"foob"), "Zm9vYg==");
assert_eq!(encode_base64(b"fooba"), "Zm9vYmE=");
assert_eq!(encode_base64(b"foobar"), "Zm9vYmFy");
assert_eq!(decode_base64("Zm9vYmFy").unwrap(), b"foobar");
assert_eq!(decode_base64("Zg==").unwrap(), b"f");
}
#[test]
fn malformed_base64_is_an_error() {
assert!(decode_base64("Zg=").is_err(), "length must be a multiple of 4");
assert!(decode_base64("Zm9v!!!!").is_err(), "invalid characters");
assert!(decode_base64("Z===").is_err(), "too much padding");
}
#[test]
fn a_future_format_is_refused() {
let mut snapshot = Snapshot::new(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
assert!(snapshot.check_version().is_ok());
snapshot.version = FORMAT_VERSION + 1;
let err = snapshot.check_version().unwrap_err().to_string();
assert!(err.contains("self-update"), "the error must say how to move forward: {err}");
}
#[test]
fn a_pre_split_format_is_refused_with_instructions() {
let mut snapshot = Snapshot::new(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
snapshot.version = 1;
let err = snapshot.check_version().unwrap_err().to_string();
assert!(err.contains("credential") && err.contains("path"), "it must name what changed: {err}");
assert!(err.contains("turnout credential add"), "and the way over: {err}");
}
}