icepick workflow: improve miniquorum embeddedness

This commit is contained in:
Ryan Heywood 2025-02-05 03:47:02 -05:00
parent 5c9b5533d6
commit 7d2909bada
Signed by: ryan
GPG Key ID: 8E401478A3FBEF72
5 changed files with 406 additions and 33 deletions

147
Cargo.lock generated
View File

@ -48,6 +48,20 @@ dependencies = [
"cpufeatures", "cpufeatures",
] ]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "aes-gcm-siv" name = "aes-gcm-siv"
version = "0.11.1" version = "0.11.1"
@ -99,6 +113,12 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -413,7 +433,7 @@ dependencies = [
"bitflags 2.6.0", "bitflags 2.6.0",
"cexpr", "cexpr",
"clang-sys", "clang-sys",
"itertools 0.12.1", "itertools 0.10.5",
"proc-macro2", "proc-macro2",
"quote", "quote",
"regex", "regex",
@ -468,6 +488,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "blahaj"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5106bf2680d585dc5f29711b8aa5dde353180b8e14af89b7f0424f760c84e7ce"
dependencies = [
"hashbrown 0.15.2",
"rand 0.8.5",
"zeroize",
]
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.5.5" version = "1.5.5"
@ -1492,6 +1523,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@ -1653,6 +1690,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -1718,6 +1765,11 @@ name = "hashbrown"
version = "0.15.2" version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heck" name = "heck"
@ -1737,6 +1789,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5491a308e0214554f07a81d8944abe45f552871c12e3c3c6e7e5d354039a6c4c" checksum = "5491a308e0214554f07a81d8944abe45f552871c12e3c3c6e7e5d354039a6c4c"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.8.1" version = "0.8.1"
@ -1871,6 +1932,8 @@ dependencies = [
"icepick-module", "icepick-module",
"icepick-workflow", "icepick-workflow",
"keyfork-derive-util", "keyfork-derive-util",
"keyfork-prompt",
"keyfork-shard",
"keyforkd-client", "keyforkd-client",
"keyforkd-models", "keyforkd-models",
"miniquorum", "miniquorum",
@ -2247,6 +2310,31 @@ dependencies = [
"signal-hook-mio", "signal-hook-mio",
] ]
[[package]]
name = "keyfork-derive-openpgp"
version = "0.1.4"
source = "registry+https://git.distrust.co/public/_cargo-index.git"
checksum = "da78a1c0b9dc65463bf6adc4e04efb1a733cd49172416a8dcdf59749668e26d0"
dependencies = [
"anyhow",
"ed25519-dalek 2.1.1",
"keyfork-derive-path-data",
"keyfork-derive-util",
"keyforkd-client",
"sequoia-openpgp",
"thiserror 1.0.69",
]
[[package]]
name = "keyfork-derive-path-data"
version = "0.1.3"
source = "registry+https://git.distrust.co/public/_cargo-index.git"
checksum = "f3446ddf10e1ffc1394409c856e1c45da37fcdf8302e53ea51a0f7418bd14382"
dependencies = [
"keyfork-derive-util",
"once_cell",
]
[[package]] [[package]]
name = "keyfork-derive-util" name = "keyfork-derive-util"
version = "0.2.2" version = "0.2.2"
@ -2289,15 +2377,42 @@ dependencies = [
[[package]] [[package]]
name = "keyfork-prompt" name = "keyfork-prompt"
version = "0.2.0" version = "0.2.1"
source = "registry+https://git.distrust.co/public/_cargo-index.git" source = "registry+https://git.distrust.co/public/_cargo-index.git"
checksum = "4ab2d75e36a647f50a2950671cf388251c585b29604925995189bb066c0747eb" checksum = "8df91df98bc6faa0cbc4f08e33797b832e384e33f8dbe066bffcb8ebb93216e6"
dependencies = [ dependencies = [
"keyfork-bug", "keyfork-bug",
"keyfork-crossterm", "keyfork-crossterm",
"keyfork-mnemonic",
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "keyfork-shard"
version = "0.3.1"
source = "registry+https://git.distrust.co/public/_cargo-index.git"
checksum = "a208781e8731184f165d32ac11d957720bebc5aba146a883ef325168e3ff91d9"
dependencies = [
"aes-gcm",
"anyhow",
"base64 0.22.1",
"blahaj",
"card-backend",
"card-backend-pcsc",
"hkdf",
"keyfork-bug",
"keyfork-derive-openpgp",
"keyfork-mnemonic",
"keyfork-prompt",
"openpgp-card",
"openpgp-card-sequoia",
"sequoia-openpgp",
"sha2 0.10.8",
"smex",
"thiserror 1.0.69",
"x25519-dalek",
]
[[package]] [[package]]
name = "keyforkd-client" name = "keyforkd-client"
version = "0.2.1" version = "0.2.1"
@ -2376,7 +2491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-targets 0.52.6", "windows-targets 0.48.5",
] ]
[[package]] [[package]]
@ -3149,7 +3264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3" checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.12.1", "itertools 0.10.5",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.92", "syn 2.0.92",
@ -3586,7 +3701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e858e4e9e48ff079cede92e1b45c942a5466ce9a4e3cc0c2a7e66586a718ef59" checksum = "e858e4e9e48ff079cede92e1b45c942a5466ce9a4e3cc0c2a7e66586a718ef59"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.21.7",
"buffered-reader", "buffered-reader",
"bzip2", "bzip2",
"chrono", "chrono",
@ -3834,6 +3949,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "smex"
version = "0.1.0"
source = "registry+https://git.distrust.co/public/_cargo-index.git"
checksum = "fec02cb08322118cdaff9f30b2eccad19e2c9906c6ade894593b00782b31a211"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.8" version = "0.5.8"
@ -6142,7 +6263,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@ -6339,6 +6460,18 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek 4.1.3",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]] [[package]]
name = "xxhash-rust" name = "xxhash-rust"
version = "0.8.15" version = "0.8.15"

View File

@ -9,6 +9,8 @@ clap = { version = "4.5.20", features = ["cargo", "derive", "string"] }
icepick-module = { version = "0.1.0", path = "../icepick-module" } icepick-module = { version = "0.1.0", path = "../icepick-module" }
icepick-workflow = { version = "0.1.0", path = "../icepick-workflow" } icepick-workflow = { version = "0.1.0", path = "../icepick-workflow" }
keyfork-derive-util = { version = "0.2.1", registry = "distrust" } keyfork-derive-util = { version = "0.2.1", registry = "distrust" }
keyfork-prompt = { version = "0.2.1", registry = "distrust", default-features = false }
keyfork-shard = { version = "0.3.0", registry = "distrust", default-features = false, features = ["openpgp", "openpgp-card"] }
keyforkd-client = { version = "0.2.1", registry = "distrust" } keyforkd-client = { version = "0.2.1", registry = "distrust" }
keyforkd-models = { version = "0.2.0", registry = "distrust" } keyforkd-models = { version = "0.2.0", registry = "distrust" }
miniquorum = { version = "0.1.0", path = "../miniquorum", default-features = false } miniquorum = { version = "0.1.0", path = "../miniquorum", default-features = false }

View File

@ -1,10 +1,11 @@
use clap::command; use clap::{builder::ArgPredicate, command, value_parser};
use icepick_module::help::*; use icepick_module::help::*;
use keyfork_derive_util::{request::DerivationAlgorithm, DerivationIndex, DerivationPath}; use keyfork_derive_util::{request::DerivationAlgorithm, DerivationIndex, DerivationPath};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap, collections::HashMap,
io::{IsTerminal, Write}, io::{IsTerminal, Write},
path::PathBuf,
process::{Command, Stdio}, process::{Command, Stdio},
}; };
@ -186,13 +187,84 @@ pub fn do_cli_thing() {
} }
let workflows = workflows.leak(); let workflows = workflows.leak();
let mut workflow_command = clap::Command::new("workflow") let mut workflow_command = clap::Command::new("workflow")
.about("Run a pre-defined Icepick workflow") .about("Run a pre-defined Icepick workflow.")
.arg(clap::arg!(--"simulate-workflow").global(true))
.arg(clap::arg!(--"export-for-quorum").global(true))
.arg( .arg(
clap::arg!(--"sign") clap::arg!(--"run-quorum" <FILE> "Run a workflow signed by a quorum of approvers.")
.long_help(concat!(
"Run a workflow signed by a quorum of approvers. ",
"This command requires either `--shardfile` or `--keyring`. If given a ",
"Shardfile, the certificates stored within the Shardfile will be used to ",
"verify the quorum payload. If given an OpenPGP keyring, the ",
"certificates within the keyring will be used to verify the quorum ",
"payload. Both formats require all keys to be signed by the key matching a ",
"currently plugged-in OpenPGP smartcard."
))
.value_parser(value_parser!(PathBuf))
.conflicts_with_all([
"simulate-workflow",
"export-for-quorum",
"add-signature-to-quorum",
]),
)
.arg(
clap::arg!(--"add-signature-to-quorum" <FILE> "Add a signature to a workflow quorum.")
.long_help(concat!(
"Add a signature to a workflow quorum. ",
"Any existing signatures will be verified. ",
"This command requires either `--shardfile` or `--keyring`. If given a ",
"Shardfile, the certificates stored within the Shardfile will be used to ",
"verify the quorum payload. If given an OpenPGP keyring, the ",
"certificates within the keyring will be used to verify the quorum ",
"payload. Both formats require all keys to be signed by the key matching a ",
"currently plugged-in OpenPGP smartcard."
))
.value_parser(value_parser!(PathBuf)),
)
.arg(
clap::arg!(--"keyring" <FILE> "OpenPGP Keyring file for verifying quorum.")
.value_parser(value_parser!(PathBuf))
.requires_ifs([
(ArgPredicate::IsPresent, "run-quorum"),
(ArgPredicate::IsPresent, "add-signature-to-quorum"),
]),
)
.arg(
clap::arg!(--"quorum-threshold" <THRESHOLD> "Quorum of signatures required to run.")
.long_help(concat!(
"Quorum of signatures required to run. ",
"When not present, the default behavior is to require a signature from ",
"every certificate present."
))
.value_parser(value_parser!(u8))
.requires("run-quorum")
.conflicts_with("shardfile"), // Shardfile contains its own threshold.
)
.arg(
clap::arg!(--"shardfile" <FILE> "OpenPGP Shardfile for verifying quorum.")
.long_help(concat!(
"OpenPGP Shardfile for verifying quorum. ",
"An OpenPGP Smartcard will be required to decrypt the quorum threshold and ",
"OpenPGP certificates used for verifying the payload.",
))
.value_parser(value_parser!(PathBuf))
.requires_ifs([
(ArgPredicate::IsPresent, "run-quorum"),
(ArgPredicate::IsPresent, "add-signature-to-quorum"),
])
.conflicts_with("keyring"),
)
.arg(clap::arg!(--"simulate-workflow" "Simulate an Icepick Workflow.").global(true))
.arg(
clap::arg!(
--"export-for-quorum"
"Export the given inputs as a quorum file."
)
.global(true),
)
.arg(
clap::arg!(--"sign" "Sign the exported workflow values.")
.global(true) .global(true)
.requires_if(clap::builder::ArgPredicate::IsPresent, "export-for-quorum"), .requires_if(ArgPredicate::IsPresent, "export-for-quorum"),
); );
for module in workflows.iter() { for module in workflows.iter() {
let mut module_subcommand = clap::Command::new(module.0.as_str()); let mut module_subcommand = clap::Command::new(module.0.as_str());
@ -231,9 +303,7 @@ pub fn do_cli_thing() {
// If we have a Workflow command, run the workflow and exit. // If we have a Workflow command, run the workflow and exit.
if let Some(("workflow", matches)) = matches.subcommand() { if let Some(("workflow", matches)) = matches.subcommand() {
let (module_name, matches) = matches if let Some((module_name, matches)) = matches.subcommand() {
.subcommand()
.expect("icepick workflow: missing module");
let (workflow_name, matches) = matches let (workflow_name, matches) = matches
.subcommand() .subcommand()
.expect("icepick workflow: missing workflow"); .expect("icepick workflow: missing workflow");
@ -243,6 +313,59 @@ pub fn do_cli_thing() {
.and_then(|(_, workflows)| workflows.iter().find(|x| x.name == workflow_name)) .and_then(|(_, workflows)| workflows.iter().find(|x| x.name == workflow_name))
.expect("workflow from CLI should match config"); .expect("workflow from CLI should match config");
workflow::handle(workflow, module_name, matches, commands, &config.modules); workflow::handle(workflow, module_name, matches, commands, &config.modules);
} else if let Some(payload_file) = matches.get_one::<PathBuf>("add-signature-to-quorum") {
let purpose = workflow::Purpose::AddSignature;
let mut payload = {
if let Some(keyring_file) = matches.get_one::<PathBuf>("keyring") {
workflow::parse_quorum_file(
payload_file,
keyring_file,
matches.get_one::<u8>("quorum-threshold").copied(),
purpose,
)
} else if let Some(shardfile) = matches.get_one::<PathBuf>("shardfile") {
workflow::parse_quorum_with_shardfile(payload_file, shardfile, purpose)
} else {
panic!("neither --keyring nor --shardfile were given, no keys to verify")
}
};
payload.add_signature().unwrap();
let output_file = payload_file.with_extension("tmp");
let mut file = std::fs::File::create_new(&output_file).unwrap();
serde_json::to_writer_pretty(&mut file, &payload).unwrap();
drop(file);
std::fs::copy(&output_file, payload_file).unwrap();
std::fs::remove_file(output_file).unwrap();
} else if let Some(payload_file) = matches.get_one::<PathBuf>("run-quorum") {
let purpose = workflow::Purpose::RunQuorum;
let (module_name, workflow_name, inputs) = {
if let Some(keyring_file) = matches.get_one::<PathBuf>("keyring") {
workflow::parse_quorum_file(
payload_file,
keyring_file,
matches.get_one::<u8>("quorum-threshold").copied(),
purpose,
)
.into_values()
} else if let Some(shardfile) = matches.get_one::<PathBuf>("shardfile") {
workflow::parse_quorum_with_shardfile(payload_file, shardfile, purpose)
.into_values()
} else {
panic!("neither --keyring nor --shardfile were given, no keys to verify")
}
};
let inputs: HashMap<String, serde_json::Value> =
serde_json::from_value(inputs).unwrap();
let workflow = workflows
.iter()
.find(|(module, _)| *module == module_name)
.and_then(|(_, workflows)| workflows.iter().find(|x| x.name == workflow_name))
.expect("workflow from CLI should match config");
workflow::handle_payload(workflow, inputs, commands, &config.modules);
}
return; return;
} }

View File

@ -1,5 +1,7 @@
use icepick_workflow::{InvocableOperation, OperationResult, Workflow}; use icepick_workflow::{InvocableOperation, OperationResult, Workflow};
use keyfork_derive_util::{request::DerivationAlgorithm, DerivationPath}; use keyfork_derive_util::{request::DerivationAlgorithm, DerivationPath};
use keyfork_shard::{openpgp::OpenPGP, Format};
use miniquorum::{Payload, PayloadVerification};
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
collections::HashMap, collections::HashMap,
@ -9,6 +11,15 @@ use std::{
use super::{derive_keys, get_command, Commands, ModuleConfig, Operation}; use super::{derive_keys, get_command, Commands, ModuleConfig, Operation};
/// The purpose for interacting with a payload.
pub enum Purpose {
/// Adding a signature.
AddSignature,
/// Running a quorum-signed payload.
RunQuorum,
}
pub type StringMap = std::collections::HashMap<String, String>; pub type StringMap = std::collections::HashMap<String, String>;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -170,6 +181,72 @@ fn load_operations(commands: Commands, config: &[ModuleConfig]) -> Vec<CLIOperat
operations operations
} }
pub fn parse_quorum_file(
quorum_path: impl AsRef<std::path::Path>,
cert_path: impl AsRef<std::path::Path>,
threshold: Option<u8>,
purpose: Purpose,
) -> Payload {
let (payload, certs) = Payload::load(quorum_path, cert_path).unwrap();
let threshold = threshold.unwrap_or(u8::try_from(certs.len()).expect("too many certs!"));
let policy = match purpose {
Purpose::AddSignature => {
// All signatures must be valid, but we don't require a minimum.
PayloadVerification::new().with_threshold(0)
}
Purpose::RunQuorum => {
PayloadVerification::new().with_threshold(threshold)
},
};
payload.verify_signatures(&certs, &policy, None).unwrap();
payload
}
pub fn parse_quorum_with_shardfile(
quorum_path: impl AsRef<std::path::Path>,
shardfile_path: impl AsRef<std::path::Path>,
purpose: Purpose,
) -> Payload {
let payload_file = std::fs::File::open(quorum_path).unwrap();
let payload: Payload = serde_json::from_reader(payload_file).unwrap();
let opgp = OpenPGP;
let (threshold, certs) = opgp.decrypt_metadata_from_file(
None::<&std::path::Path>,
std::fs::File::open(shardfile_path).unwrap(),
keyfork_prompt::default_handler().unwrap(),
).unwrap();
let policy = match purpose {
Purpose::AddSignature => {
// All signatures must be valid, but we don't require a minimum.
PayloadVerification::new().with_threshold(0)
}
Purpose::RunQuorum => {
PayloadVerification::new().with_threshold(threshold)
},
};
payload.verify_signatures(&certs, &policy, None).unwrap();
payload
}
pub fn handle_payload(
workflow: &Workflow,
inputs: HashMap<String, Value>,
modules: Commands,
config: &[ModuleConfig],
) {
let operations = load_operations(modules, config);
let result = workflow
.run_workflow(inputs, &operations, &derive_keys)
.expect("Invocation failure");
println!("{}", serde_json::to_string(&result).expect("valid JSON"));
}
pub fn handle( pub fn handle(
workflow: &Workflow, workflow: &Workflow,
module_name: &str, module_name: &str,
@ -196,7 +273,7 @@ pub fn handle(
} }
if matches.get_flag("export-for-quorum") { if matches.get_flag("export-for-quorum") {
let mut payload = miniquorum::Payload::new( let mut payload = Payload::new(
serde_json::to_value(data).unwrap(), serde_json::to_value(data).unwrap(),
module_name, module_name,
&workflow.name, &workflow.name,

View File

@ -1,3 +1,4 @@
use chrono::prelude::*;
use keyfork_prompt::{ use keyfork_prompt::{
default_handler, prompt_validated_passphrase, default_handler, prompt_validated_passphrase,
validators::{PinValidator, Validator}, validators::{PinValidator, Validator},
@ -11,10 +12,9 @@ use sequoia_openpgp::{
packet::{signature::SignatureBuilder, Packet}, packet::{signature::SignatureBuilder, Packet},
parse::Parse, parse::Parse,
serialize::Serialize as _, serialize::Serialize as _,
types::SignatureType, types::{HashAlgorithm, SignatureType},
Cert, Fingerprint, Cert, Fingerprint,
}; };
use chrono::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::{collections::BTreeMap, fs::File, io::Read, path::Path}; use std::{collections::BTreeMap, fs::File, io::Read, path::Path};
@ -27,6 +27,7 @@ pub struct Error {
policy: PayloadVerification, policy: PayloadVerification,
} }
#[non_exhaustive]
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum BaseError { pub enum BaseError {
/// In the given certificate keyring, the provided fingerprint was not found. /// In the given certificate keyring, the provided fingerprint was not found.
@ -103,7 +104,7 @@ fn unhashed(value: Value) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
fn hash(value: Value) -> Result<Box<dyn Digest>, Box<dyn std::error::Error>> { fn hash(value: Value) -> Result<Box<dyn Digest>, Box<dyn std::error::Error>> {
let bincoded = unhashed(value)?; let bincoded = unhashed(value)?;
let mut digest = openpgp::types::HashAlgorithm::SHA512.context()?; let mut digest = HashAlgorithm::SHA512.context()?;
digest.update(&bincoded); digest.update(&bincoded);
Ok(digest) Ok(digest)
@ -143,14 +144,17 @@ impl PayloadVerification {
Default::default() Default::default()
} }
/// Require a signature per key, regardless of any given threshold.
pub fn with_one_per_key(self, one_each: bool) -> Self { pub fn with_one_per_key(self, one_each: bool) -> Self {
Self { one_each, ..self } Self { one_each, ..self }
} }
/// Set a threshold for required signatures.
pub fn with_threshold(self, threshold: u8) -> Self { pub fn with_threshold(self, threshold: u8) -> Self {
Self { threshold, ..self } Self { one_each: false, threshold, ..self }
} }
/// Require a single valid signature; other signatures may be invalid.
pub fn with_any_valid(self) -> Self { pub fn with_any_valid(self) -> Self {
Self { Self {
threshold: 1, threshold: 1,
@ -159,6 +163,7 @@ impl PayloadVerification {
} }
} }
/// Require a threshold of signatures to be valid, allowing no invalid signatures.
pub fn with_all_valid(self, threshold: u8) -> Self { pub fn with_all_valid(self, threshold: u8) -> Self {
Self { Self {
threshold, threshold,
@ -167,6 +172,10 @@ impl PayloadVerification {
} }
} }
/// Ignore invalid signatures. A threshold of valid signatures is still required.
///
/// The default behavior is to error when encountering an invalid signature, even if a quorum
/// is reached.
pub fn ignoring_invalid_signatures(self) -> Self { pub fn ignoring_invalid_signatures(self) -> Self {
Self { Self {
error_on_invalid: false, error_on_invalid: false,
@ -174,6 +183,9 @@ impl PayloadVerification {
} }
} }
/// Ignoring signatures signed by unknown keys.
///
/// The default behavior is to error when encountering an unknown signature.
pub fn ignoring_missing_keys(self) -> Self { pub fn ignoring_missing_keys(self) -> Self {
Self { Self {
error_on_missing_key: true, error_on_missing_key: true,
@ -196,9 +208,16 @@ fn format_name(input: impl AsRef<str>) -> String {
impl Payload { impl Payload {
/// Create a new Payload, using the current system's time, in UTC. /// Create a new Payload, using the current system's time, in UTC.
pub fn new(values: serde_json::Value, module_name: impl AsRef<str>, workflow_name: impl AsRef<str>) -> Self { pub fn new(
values: serde_json::Value,
module_name: impl AsRef<str>,
workflow_name: impl AsRef<str>,
) -> Self {
Self { Self {
workflow: [module_name.as_ref().to_string(), workflow_name.as_ref().to_string()], workflow: [
module_name.as_ref().to_string(),
workflow_name.as_ref().to_string(),
],
values, values,
datetime: Utc::now(), datetime: Utc::now(),
signatures: vec![], signatures: vec![],
@ -230,6 +249,10 @@ impl Payload {
Ok((payload, certs)) Ok((payload, certs))
} }
pub fn signature_count(&self) -> usize {
self.signatures.len()
}
/// Attach a signature from an OpenPGP card. /// Attach a signature from an OpenPGP card.
/// ///
/// # Errors /// # Errors
@ -237,7 +260,8 @@ impl Payload {
/// The method may error if a signature could not be created. /// The method may error if a signature could not be created.
pub fn add_signature(&mut self) -> Result<(), Box<dyn std::error::Error>> { pub fn add_signature(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let unhashed = unhashed(serde_json::to_value(&self)?)?; let unhashed = unhashed(serde_json::to_value(&self)?)?;
let builder = SignatureBuilder::new(SignatureType::Binary); let builder =
SignatureBuilder::new(SignatureType::Binary).set_hash_algo(HashAlgorithm::SHA512);
let mut prompt_handler = default_handler()?; let mut prompt_handler = default_handler()?;
let pin_validator = PinValidator { let pin_validator = PinValidator {
min_length: Some(6), min_length: Some(6),
@ -396,6 +420,14 @@ impl Payload {
Ok(&self.values) Ok(&self.values)
} }
pub fn into_values(self) -> (String, String, serde_json::Value) {
let Payload {
workflow, values, ..
} = self;
let [module, workflow] = workflow;
(module, workflow, values)
}
} }
fn find_matching_certificate( fn find_matching_certificate(
@ -420,6 +452,12 @@ fn find_matching_certificate(
.expect("smartcard signing key is unavailable"); .expect("smartcard signing key is unavailable");
for cert in certs { for cert in certs {
let valid_cert = cert.with_policy(policy, None)?; let valid_cert = cert.with_policy(policy, None)?;
// NOTE: We must verify that it is for_signing because back signatures
// mean that the signing key verifies the certificate.
//
// We don't want a certificate to be able to adopt, for example, an encryption key
// because that means there is no back signature and the encryption key can be
// adopted onto a malicious certificate.
for key in valid_cert.keys().alive().for_signing() { for key in valid_cert.keys().alive().for_signing() {
let fpr = key.fingerprint(); let fpr = key.fingerprint();
if fpr.as_bytes() == signing_fingerprint.as_bytes() { if fpr.as_bytes() == signing_fingerprint.as_bytes() {