diff --git a/Cargo.lock b/Cargo.lock index 5e16b9f..bfda6e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,7 +368,7 @@ dependencies = [ [[package]] name = "card-cli" -version = "1.8.6" +version = "1.9.0" dependencies = [ "authenticator", "base64 0.21.7", @@ -389,7 +389,7 @@ dependencies = [ "pem", "rand 0.8.5", "reqwest", - "ring", + "ring 0.17.8", "rust_util", "sequoia-openpgp", "serde", @@ -1360,7 +1360,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7660d28d24a831d690228a275d544654a30f3b167a8e491cf31af5fe5058b546" dependencies = [ - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2420,6 +2420,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi 0.3.9", +] + [[package]] name = "ring" version = "0.17.8" @@ -2431,7 +2446,7 @@ dependencies = [ "getrandom 0.2.14", "libc", "spin 0.9.8", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3480,6 +3495,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -3884,6 +3905,7 @@ dependencies = [ "lazy_static", "nom", "oid-registry", + "ring 0.16.20", "rusticata-macros", "thiserror", "time 0.3.36", diff --git a/Cargo.toml b/Cargo.toml index 9ed95a7..38285a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "card-cli" -version = "1.8.6" +version = "1.9.0" authors = ["Hatter Jiang "] edition = "2018" @@ -29,7 +29,7 @@ pem = "3.0" yubikey = { version = "0.8", features = ["untested"] } yubico_manager = "0.9" x509 = "0.2" -x509-parser = "0.15" +x509-parser = { version = "0.15", features = ["verify"] } ssh-agent = { version = "0.2", features = ["agent"] } p256 = { version = "0.13", features = ["pem", "ecdh"] } p384 = { version = "0.13", features = ["pem", "ecdh"] } diff --git a/src/argsutil.rs b/src/argsutil.rs index 33a2a12..d78c231 100644 --- a/src/argsutil.rs +++ b/src/argsutil.rs @@ -7,8 +7,14 @@ use rust_util::XResult; use crate::digest::{sha256, sha256_bytes}; + pub fn get_sha256_digest_or_hash(sub_arg_matches: &ArgMatches) -> XResult> { - if let Some(file) = sub_arg_matches.value_of("file") { + get_sha256_digest_or_hash_with_file_opt(sub_arg_matches, &None) +} + +pub fn get_sha256_digest_or_hash_with_file_opt(sub_arg_matches: &ArgMatches, file_opt: &Option) -> XResult> { + let file_opt = file_opt.as_ref().map(String::as_str); + if let Some(file) = sub_arg_matches.value_of("file").or(file_opt) { let metadata = opt_result!(fs::metadata(file), "Read file: {} metadata filed: {}", file); if !metadata.is_file() { return simple_error!("Not a file: {}", file); diff --git a/src/cmd_signfile.rs b/src/cmd_signfile.rs index 6fba948..da71db3 100644 --- a/src/cmd_signfile.rs +++ b/src/cmd_signfile.rs @@ -3,7 +3,7 @@ use std::time::SystemTime; use clap::{App, Arg, ArgMatches, SubCommand}; use rust_util::{util_msg, XResult}; use rust_util::util_clap::{Command, CommandError}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use spki::der::Encode; use x509_parser::nom::AsBytes; use yubikey::{Key, YubiKey}; @@ -11,118 +11,9 @@ use yubikey::piv::{sign_data, SlotId}; use crate::{argsutil, pivutil}; use crate::digest::sha256_bytes; +use crate::signfile::{CERTIFICATES_SEARCH_URL, HASH_ALGORITHM_SHA256, SIGNATURE_ALGORITHM_SHA256_WITH_ECDSA, SignFileRequest, SIMPLE_SIG_SCHEMA, SimpleSignFile, SimpleSignFileSignature}; use crate::util::base64_encode; -pub const SIMPLE_SIG_V1: &str = "v1"; -pub const SIMPLE_SIG_SCHEMA: &str = "https://openwebstandard.org/simple-sign-file/v1"; -pub const HASH_ALGORITHM_SHA256: &str = "sha256"; -pub const SIGNATURE_ALGORITHM_SHA256_WITH_ECDSA: &str = "SHA256withECDSA"; -pub const CERTIFICATES_SEARCH_URL: &str = "https://hatter.ink/ca/fetch_certificates.json?fingerprint="; - -pub struct SignFileRequest { - pub filename: Option, - pub digest: Vec, - pub timestamp: i64, - pub attributes: Option, - pub comment: Option, -} - -impl SignFileRequest { - pub fn get_tobe_signed(&self) -> Vec { - let mut tobe_signed = vec![]; - // "v1"||TLV(filename)||TLV(timestamp)||TLV(attributes)||TLV(comment)||TLV(digest) - debugging!("Tobe signed version: {}", SIMPLE_SIG_V1); - tobe_signed.extend_from_slice(SIMPLE_SIG_V1.as_bytes()); - let tobe_signed_filename = SignFileTlv::Filename(self.filename.clone()).to_byes(); - debugging!("Tobe signed filename: {} ({:?})", hex::encode(&tobe_signed_filename), &self.filename); - tobe_signed.extend_from_slice(&tobe_signed_filename); - let tobe_signed_timestamp = SignFileTlv::Timestamp(self.timestamp).to_byes(); - debugging!("Tobe signed timestamp: {} ({})", hex::encode(&tobe_signed_timestamp), &self.timestamp); - tobe_signed.extend_from_slice(&tobe_signed_timestamp); - let tobe_signed_attributes = SignFileTlv::Attributes(self.attributes.clone()).to_byes(); - debugging!("Tobe signed attributes: {} ({:?})", hex::encode(&tobe_signed_attributes), &self.attributes); - tobe_signed.extend_from_slice(&tobe_signed_attributes); - let tobe_signed_comment = SignFileTlv::Comment(self.comment.clone()).to_byes(); - debugging!("Tobe signed comment: {} ({:?})", hex::encode(&tobe_signed_comment), &self.comment); - tobe_signed.extend_from_slice(&tobe_signed_comment); - let tobe_signed_digest = SignFileTlv::Digest(self.digest.clone()).to_byes(); - debugging!("Tobe signed digest: {}", hex::encode(&tobe_signed_digest)); - tobe_signed.extend_from_slice(&tobe_signed_digest); - tobe_signed - } -} - -pub enum SignFileTlv { - Filename(Option), - Timestamp(i64), - Attributes(Option), - Comment(Option), - Digest(Vec), -} - -impl SignFileTlv { - pub fn tag(&self) -> u8 { - match self { - SignFileTlv::Filename(_) => 0, - SignFileTlv::Timestamp(_) => 1, - SignFileTlv::Attributes(_) => 2, - SignFileTlv::Comment(_) => 3, - SignFileTlv::Digest(_) => 254, - } - } - - pub fn to_byes(&self) -> Vec { - let mut bytes = vec![]; - bytes.push(self.tag()); - match self { - SignFileTlv::Timestamp(timestamp) => { - bytes.extend_from_slice(×tamp.to_be_bytes()); - } - SignFileTlv::Filename(value) - | SignFileTlv::Attributes(value) - | SignFileTlv::Comment(value) => { - Self::extends_bytes(&mut bytes, match value { - None => &[], - Some(value) => value.as_bytes(), - }); - } - SignFileTlv::Digest(digest) => { - Self::extends_bytes(&mut bytes, digest); - } - } - bytes - } - - fn extends_bytes(bytes: &mut Vec, b: &[u8]) { - if b.len() > u16::MAX as usize { - panic!("Cannot more than: {}", u16::MAX); - } - bytes.extend_from_slice(&(b.len() as u16).to_be_bytes()); - bytes.extend_from_slice(b); - } -} - -#[derive(Serialize)] -pub struct SimpleSignFileSignature { - pub algorithm: String, - pub signature: String, - pub certificates: Vec, -} - -#[derive(Serialize)] -pub struct SimpleSignFile { - pub schema: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub filename: Option, - pub digest: String, - pub timestamp: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub attributes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub comment: Option, - pub signatures: Vec, -} - pub struct CommandImpl; // Format: diff --git a/src/cmd_verifyfile.rs b/src/cmd_verifyfile.rs new file mode 100644 index 0000000..4dfd24e --- /dev/null +++ b/src/cmd_verifyfile.rs @@ -0,0 +1,129 @@ +use std::fs; +use std::ops::Add; +use std::time::{Duration, SystemTime}; + +use clap::{App, Arg, ArgMatches, SubCommand}; +use p384::ecdsa::signature::hazmat::PrehashVerifier; +use rust_util::util_clap::{Command, CommandError}; +use rust_util::util_msg; +use x509_parser::parse_x509_certificate; +use x509_parser::pem::parse_x509_pem; +use x509_parser::public_key::PublicKey; +use x509_parser::time::ASN1Time; + +use crate::argsutil; +use crate::digest::sha256_bytes; +use crate::signfile::{SignFileRequest, SIMPLE_SIG_SCHEMA, SimpleSignFile}; +use crate::util::base64_decode; + +pub struct CommandImpl; + +impl Command for CommandImpl { + fn name(&self) -> &str { "verify-file" } + + fn subcommand<'a>(&self) -> App<'a, 'a> { + SubCommand::with_name(self.name()).about("PIV Verify(with SHA256) subcommand") + .arg(Arg::with_name("file").short("f").long("file").takes_value(true).required(false).help("Input file")) + .arg(Arg::with_name("filename").short("n").long("filename").takes_value(true).help("Filename")) + } + + fn run(&self, _arg_matches: &ArgMatches, sub_arg_matches: &ArgMatches) -> CommandError { + util_msg::set_logger_std_out(false); + + let filename = sub_arg_matches.value_of("filename").map(ToString::to_string).unwrap(); + let file_content = opt_result!(fs::read_to_string(&filename), "Read file: {}, failed: {}", filename); + let simple_sign_file: SimpleSignFile = opt_result!(serde_json::from_str(&file_content), "Parse file: {}, failed: {}", filename); + + if SIMPLE_SIG_SCHEMA != simple_sign_file.schema { + return simple_error!("File: {} format error: bad schema", filename); + } + information!("File name: {}", simple_sign_file.filename.as_deref().unwrap_or("")); + information!("Digest: {}", &simple_sign_file.digest); + let sign_time = SystemTime::UNIX_EPOCH.add(Duration::from_millis(simple_sign_file.timestamp as u64)); + let format_time = simpledateformat::fmt("yyyy-MM-dd HH:mm:ssz").unwrap().format_local(sign_time); + information!("Timestamp: {}", format_time); + if let Some(attributes) = &simple_sign_file.attributes { + information!("Attributes: {}", attributes); + } + if let Some(comment) = &simple_sign_file.comment { + information!("Comment: {}", comment); + } + let file_digest = argsutil::get_sha256_digest_or_hash_with_file_opt(sub_arg_matches, &simple_sign_file.filename)?; + debugging!("File digest: {}", hex::encode(&file_digest)); + let file_digest_with_prefix = format!("sha256-{}", hex::encode(&file_digest)); + if file_digest_with_prefix != simple_sign_file.digest { + failure!("File digest mismatch\nexpected: {}\nactual : {}", simple_sign_file.digest, file_digest_with_prefix); + return simple_error!("File digest mismatch"); + } + + let sign_file_request = SignFileRequest { + filename: simple_sign_file.filename.clone(), + digest: file_digest.clone(), + timestamp: simple_sign_file.timestamp, + attributes: simple_sign_file.attributes.clone(), + comment: simple_sign_file.comment.clone(), + }; + let tobe_signed = sign_file_request.get_tobe_signed(); + debugging!("Tobe signed: {}", hex::encode(&tobe_signed)); + let tobe_signed_digest = sha256_bytes(&tobe_signed); + debugging!("Tobe signed digest: {}", hex::encode(&tobe_signed_digest)); + + if simple_sign_file.signatures.is_empty() { + failure!("No signatures found."); + return simple_error!("No signatures found"); + } + information!("Found {} signature(s)", simple_sign_file.signatures.len()); + for (i, signature) in simple_sign_file.signatures.iter().enumerate() { + // check tobe_signed_digest by signature_bytes + information!("Check signature #{} of {}", i, simple_sign_file.signatures.len()); + let signature_bytes = opt_result!(base64_decode(&signature.signature), "Parse signatures.signature failed: {}"); + debugging!("Signature #{}: {}", i, hex::encode(&signature_bytes)); + + let mut cert_pems = vec![]; + for certificate in &signature.certificates { + let (_, cert_pem) = opt_result!(parse_x509_pem(certificate.as_bytes()), "Parse certificate PEM failed: {}"); + cert_pems.push(cert_pem); + } + let mut certificates = vec![]; + for cert_pem in &cert_pems { + let (_, cert) = opt_result!(parse_x509_certificate(&cert_pem.contents), "Parse certificate failed: {}"); + debugging!("Found certificate, subject: {}, issuer : {}", cert.subject.to_string(), cert.issuer.to_string()); + let asn1_timestamp = opt_result!(ASN1Time::from_timestamp(simple_sign_file.timestamp/1000), "ASN1Time failed: {}"); + if !cert.validity.is_valid_at(asn1_timestamp) { + failure!("Certificate validity is out of cate: {:?}", cert.validity); + return simple_error!("Certificate is invalid: {}, out of date", cert.subject.to_string()); + } + certificates.push(cert); + } + let certificates_count = certificates.len(); + for i in 0..certificates.len() { + let cert1 = &certificates[i]; + let cert2_public_key = iff!(i < certificates_count -1, Some(certificates[i + 1].public_key()), None); + match cert1.verify_signature(cert2_public_key) { + Ok(_) => success!("Cert #{}: {} verify success", i, cert1.subject.to_string()), + Err(e) => failure!("Cert #{}: {} verify failed: {}", i, cert1.subject.to_string(), e), + } + } + + let leaf_certificate = &certificates[0]; + let leaf_public_key = opt_result!(leaf_certificate.public_key().parsed(), "Parse leaf certificate public key failed: {}"); + match leaf_public_key { + // PublicKey::RSA(_) => {} + PublicKey::EC(ec_point) => { + if ec_point.key_size() != 384 { + return simple_error!("Only support p384"); + } + let p384_verifying_key = opt_result!(p384::ecdsa::VerifyingKey::from_sec1_bytes(ec_point.data()), "Parse public key failed: {}"); + let sig = opt_result!(p384::ecdsa::DerSignature::from_bytes(&signature_bytes), "Parse signature failed: {}"); + match p384_verifying_key.verify_prehash(&tobe_signed_digest, &sig) { + Ok(_) => success!("Verify leaf certificate signature succeed."), + Err(e) => return simple_error!("Verify leaf certificate signature failed: {}", e), + } + } + _ => return simple_error!("Not supported public key: {:?}", leaf_public_key), + } + } + + Ok(None) + } +} diff --git a/src/main.rs b/src/main.rs index 1f0e5ba..0e93fcc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,6 +43,8 @@ mod cmd_sshagent; mod cmd_pgpageaddress; mod cmd_signjwt; mod cmd_signfile; +mod cmd_verifyfile; +mod signfile; pub struct DefaultCommandImpl; @@ -98,6 +100,7 @@ fn inner_main() -> CommandError { Box::new(cmd_pgpageaddress::CommandImpl), Box::new(cmd_signjwt::CommandImpl), Box::new(cmd_signfile::CommandImpl), + Box::new(cmd_verifyfile::CommandImpl), ]; let mut app = App::new(env!("CARGO_PKG_NAME")) .version(env!("CARGO_PKG_VERSION")) diff --git a/src/signfile.rs b/src/signfile.rs new file mode 100644 index 0000000..2d56efe --- /dev/null +++ b/src/signfile.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; + +pub const SIMPLE_SIG_V1: &str = "v1"; +pub const SIMPLE_SIG_SCHEMA: &str = "https://openwebstandard.org/simple-sign-file/v1"; +pub const HASH_ALGORITHM_SHA256: &str = "sha256"; +pub const SIGNATURE_ALGORITHM_SHA256_WITH_ECDSA: &str = "SHA256withECDSA"; +pub const CERTIFICATES_SEARCH_URL: &str = "https://hatter.ink/ca/fetch_certificates.json?fingerprint="; + +pub struct SignFileRequest { + pub filename: Option, + pub digest: Vec, + pub timestamp: i64, + pub attributes: Option, + pub comment: Option, +} + +impl SignFileRequest { + pub fn get_tobe_signed(&self) -> Vec { + let mut tobe_signed = vec![]; + // "v1"||TLV(filename)||TLV(timestamp)||TLV(attributes)||TLV(comment)||TLV(digest) + debugging!("Tobe signed version: {}", SIMPLE_SIG_V1); + tobe_signed.extend_from_slice(SIMPLE_SIG_V1.as_bytes()); + let tobe_signed_filename = SignFileTlv::Filename(self.filename.clone()).to_byes(); + debugging!("Tobe signed filename: {} ({:?})", hex::encode(&tobe_signed_filename), &self.filename); + tobe_signed.extend_from_slice(&tobe_signed_filename); + let tobe_signed_timestamp = SignFileTlv::Timestamp(self.timestamp).to_byes(); + debugging!("Tobe signed timestamp: {} ({})", hex::encode(&tobe_signed_timestamp), &self.timestamp); + tobe_signed.extend_from_slice(&tobe_signed_timestamp); + let tobe_signed_attributes = SignFileTlv::Attributes(self.attributes.clone()).to_byes(); + debugging!("Tobe signed attributes: {} ({:?})", hex::encode(&tobe_signed_attributes), &self.attributes); + tobe_signed.extend_from_slice(&tobe_signed_attributes); + let tobe_signed_comment = SignFileTlv::Comment(self.comment.clone()).to_byes(); + debugging!("Tobe signed comment: {} ({:?})", hex::encode(&tobe_signed_comment), &self.comment); + tobe_signed.extend_from_slice(&tobe_signed_comment); + let tobe_signed_digest = SignFileTlv::Digest(self.digest.clone()).to_byes(); + debugging!("Tobe signed file digest: {}", hex::encode(&tobe_signed_digest)); + tobe_signed.extend_from_slice(&tobe_signed_digest); + tobe_signed + } +} + +pub enum SignFileTlv { + Filename(Option), + Timestamp(i64), + Attributes(Option), + Comment(Option), + Digest(Vec), +} + +impl SignFileTlv { + pub fn tag(&self) -> u8 { + match self { + SignFileTlv::Filename(_) => 0, + SignFileTlv::Timestamp(_) => 1, + SignFileTlv::Attributes(_) => 2, + SignFileTlv::Comment(_) => 3, + SignFileTlv::Digest(_) => 254, + } + } + + pub fn to_byes(&self) -> Vec { + let mut bytes = vec![]; + bytes.push(self.tag()); + match self { + SignFileTlv::Timestamp(timestamp) => { + bytes.extend_from_slice(×tamp.to_be_bytes()); + } + SignFileTlv::Filename(value) + | SignFileTlv::Attributes(value) + | SignFileTlv::Comment(value) => { + Self::write_bytes(&mut bytes, match value { + None => &[], + Some(value) => value.as_bytes(), + }); + } + SignFileTlv::Digest(digest) => { + Self::write_bytes(&mut bytes, digest); + } + } + bytes + } + + fn write_bytes(bytes: &mut Vec, b: &[u8]) { + if b.len() > u16::MAX as usize { + panic!("Cannot more than: {}", u16::MAX); + } + bytes.extend_from_slice(&(b.len() as u16).to_be_bytes()); + bytes.extend_from_slice(b); + } +} + +#[derive(Serialize, Deserialize)] +pub struct SimpleSignFileSignature { + pub algorithm: String, + pub signature: String, + pub certificates: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct SimpleSignFile { + pub schema: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + pub digest: String, + pub timestamp: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub attributes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + pub signatures: Vec, +}