diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b9f2c1..93a4855 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -58,6 +58,7 @@ cargo-clippy: - apt clean script: - rustup component add clippy + - rm tools/tests/opgpcard.rs # otherwise build fails - cargo clippy --verbose --tests -- -D warnings cache: # inherit all general cache settings @@ -76,18 +77,22 @@ udeps: - curl --location --output /tmp/cargo-udeps.tar.gz https://github.com/est31/cargo-udeps/releases/download/v0.1.26/cargo-udeps-v0.1.26-x86_64-unknown-linux-gnu.tar.gz - tar --extract --verbose --gzip --file /tmp/cargo-udeps.tar.gz --directory /usr/local/bin/ --strip-components=2 ./cargo-udeps-v0.1.26-x86_64-unknown-linux-gnu/cargo-udeps script: + - rm tools/tests/opgpcard.rs # otherwise build fails - cargo udeps --workspace --all-features --all-targets cache: [ ] cargo-test: stage: test - image: rust:latest + image: registry.gitlab.com/openpgp-card/virtual-cards/smartpgp-builddeps before_script: - mkdir -p /run/user/$UID - apt update -y -qq - apt install -y -qq --no-install-recommends git clang make pkg-config nettle-dev libssl-dev capnproto ca-certificates libpcsclite-dev - apt clean + - /etc/init.d/pcscd start + - su - -c "sh /home/jcardsim/run-card.sh >/dev/null" jcardsim script: + - touch tools/virtual-card-available - cargo test cache: # inherit all general cache settings @@ -97,13 +102,16 @@ cargo-test: cargo-test-debian-bookworm: stage: test - image: debian:bookworm-slim + image: registry.gitlab.com/openpgp-card/virtual-cards/smartpgp-builddeps before_script: - mkdir -p /run/user/$UID - apt update -y -qq - apt install -y -qq --no-install-recommends git rustc cargo clang make pkg-config nettle-dev libssl-dev capnproto ca-certificates libpcsclite-dev - apt clean + - /etc/init.d/pcscd start + - su - -c "sh /home/jcardsim/run-card.sh >/dev/null" jcardsim script: + - touch tools/virtual-card-available - cargo test cache: # inherit all general cache settings diff --git a/deny.toml b/deny.toml index 11cc0fe..3e7ccc7 100644 --- a/deny.toml +++ b/deny.toml @@ -16,6 +16,7 @@ ignore = [ unlicensed = "deny" allow = [ "MIT", + "MIT-0", "Apache-2.0", "BSD-3-Clause", "BSD-2-Clause", diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 44af197..073ee7d 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -23,3 +23,18 @@ anyhow = "1" clap = { version = "3", features = ["derive"] } env_logger = "0.9" log = "0.4" +serde_json = "1.0.86" +serde = { version = "1.0.145", features = ["derive"] } +semver = "1.0.14" +serde_yaml = "0.9.13" +thiserror = "1.0.37" + +[build-dependencies] +subplot-build = "0.5.0" + +[dev-dependencies] +fehler = "1.0.0" +subplotlib = "0.5.0" + +[package.metadata.cargo-udeps.ignore] +development = ["fehler", "subplotlib"] diff --git a/tools/build.rs b/tools/build.rs new file mode 100644 index 0000000..608f687 --- /dev/null +++ b/tools/build.rs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::fs::File; +use std::path::Path; + +fn main() { + // Only generate test code from the subplot, if a virtual smart + // card is available. This is a kludge until Subplot can do + // conditional scenarios + // (https://gitlab.com/subplot/subplot/-/issues/20). + let flagfile = Path::new("virtual-card-available"); + if flagfile.exists() { + subplot_build::codegen("subplot/opgpcard.subplot") + .expect("failed to generate code with Subplot"); + } else { + // If we're not generating code from the subplot, we should at + // least create an empty file so that the tests/opgpcard.rs + // file can include it. Otherwise the build will fail. + let out_dir = std::env::var("OUT_DIR").unwrap(); + let include = Path::new(&out_dir).join("opgpcard.rs"); + eprintln!("build.rs: include={}", include.display()); + if !include.exists() { + File::create(include).unwrap(); + } + } +} diff --git a/tools/cargo-test-in-docker b/tools/cargo-test-in-docker new file mode 100755 index 0000000..945294f --- /dev/null +++ b/tools/cargo-test-in-docker @@ -0,0 +1,23 @@ +#!/bin/bash +# +# Run this to run opgpcard (tools directory) test suite inside a +# Docker container with a virtual smartcard running. The test suite +# contains tests that run the opgpcard binary and rely on a virtual +# smart card to be available. +# +# SPDX-FileCopyrightText: 2022 Lars Wirzenius +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -euo pipefail + +docker run --rm -it \ + -v root:/root \ + -v cargo:/cargo \ + -v $(pwd):/src \ + registry.gitlab.com/openpgp-card/virtual-cards/smartpgp-builddeps sh -c ' +sed -i "s/timeout=20/timeout=60/" /home/jcardsim/run-card.sh && +/etc/init.d/pcscd start && +su - -c "sh /home/jcardsim/run-card.sh >/dev/null" jcardsim && +cd /src/tools && +if ! [ -e virtual-card-available ]; then rm -f tests/opgpcard.rs; fi && +CARGO_TARGET_DIR=/cargo/ cargo test' diff --git a/tools/scripting.md b/tools/scripting.md new file mode 100644 index 0000000..7f75f24 --- /dev/null +++ b/tools/scripting.md @@ -0,0 +1,62 @@ +# Scripting around opgpcard + +The `opgpcard` tool can manipulate an OpenPGP smart card (also known +as hardware token). There are various manufacturers of these, but +Yubikey by Yubico is well known. The tool is meant to work with any +card that implements the OpenPGP smart card interface. + +`opgpcard` supports structured output as JSON and YAML. The default is +human readable text. The structured output it meant to be consumed by +other programs, and is versioned. + +For example, to list all the OpenPGP cards connected to a system: + +~~~sh +$ opgpcard --output-format=json list +{ + "schema_version": "1.0.0", + "idents": [ + "0006:11339805" + ] +} +$ +~~~ + +The structured output is versioned (text output is not), using the +field name `schema_version`. The version numbering follows [semantic +versionin](https://semver.org/): + +* if a field is added, the minor level is incremented, and patch level + is set to zero +* if a field is removed, the major level is incremented, and minor and + patch level are set to zero +* if there are changed with no semantic impatc, the patch level is + incremented + +Each version of `opgpcard` supports only the latest minor version for +each major version. Consumers of the output have to be OK with added +fields. + +Thus, for example, if the `opgpcard list` command would add a new +field for when the command was run, the output might look like this: + +~~~sh +$ opgpcard --output-format=json list +{ + "schema_version": "1.1.0", + "date": "Tue, 18 Oct 2022 18:07:41 +0300", + "idents": [ + "0006:11339805" + ] +} +$ +~~~ + +A new field means the minor level in the schema version is +incremented. + + +# Legalese + +SPDX-FileCopyrightText: 2022 Lars Wirzenius +SPDX-License-Identifier: MIT OR Apache-2.0 diff --git a/tools/src/bin/opgpcard/cli.rs b/tools/src/bin/opgpcard/cli.rs index df53ea2..cf594ea 100644 --- a/tools/src/bin/opgpcard/cli.rs +++ b/tools/src/bin/opgpcard/cli.rs @@ -4,6 +4,11 @@ use clap::{AppSettings, Parser}; use std::path::PathBuf; +use crate::{OutputFormat, OutputVersion}; + +pub const DEFAULT_OUTPUT_VERSION: &str = "0.9.0"; +pub const OUTPUT_VERSIONS: &[OutputVersion] = &[OutputVersion::new(0, 9, 0)]; + #[derive(Parser, Debug)] #[clap( name = "opgpcard", @@ -13,12 +18,24 @@ use std::path::PathBuf; about = "A tool for inspecting and configuring OpenPGP cards." )] pub struct Cli { + /// Produce output in the chosen format. + #[clap(long, value_enum, default_value = "text")] + pub output_format: OutputFormat, + + /// Pick output version to use, for non-textual formats. + #[clap(long, default_value = DEFAULT_OUTPUT_VERSION)] + pub output_version: OutputVersion, + #[clap(subcommand)] pub cmd: Command, } #[derive(Parser, Debug)] pub enum Command { + /// Show all output versions that are supported. Mark the + /// currently chosen one with a star. + OutputVersions {}, + /// Enumerate available OpenPGP cards List {}, diff --git a/tools/src/bin/opgpcard/main.rs b/tools/src/bin/opgpcard/main.rs index 6a866be..08006dd 100644 --- a/tools/src/bin/opgpcard/main.rs +++ b/tools/src/bin/opgpcard/main.rs @@ -16,7 +16,7 @@ use sequoia_openpgp::types::{HashAlgorithm, SymmetricAlgorithm}; use sequoia_openpgp::Cert; use openpgp_card::algorithm::AlgoSimple; -use openpgp_card::card_do::{Sex, TouchPolicy}; +use openpgp_card::card_do::TouchPolicy; use openpgp_card::{CardBackend, KeyType, OpenPgp}; use openpgp_card_sequoia::card::{Admin, Card, Open}; use openpgp_card_sequoia::util::{ @@ -28,7 +28,12 @@ use crate::util::{load_pin, print_gnuk_note}; use std::io::Write; mod cli; +mod output; mod util; +mod versioned_output; + +use cli::OUTPUT_VERSIONS; +use versioned_output::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; const ENTER_USER_PIN: &str = "Enter User PIN:"; const ENTER_ADMIN_PIN: &str = "Enter Admin PIN:"; @@ -39,29 +44,37 @@ fn main() -> Result<(), Box> { let cli = cli::Cli::parse(); match cli.cmd { + cli::Command::OutputVersions {} => { + output_versions(cli.output_version); + } cli::Command::List {} => { - println!("Available OpenPGP cards:"); - list_cards()?; + list_cards(cli.output_format, cli.output_version)?; } cli::Command::Status { ident, verbose, pkm, } => { - print_status(ident, verbose, pkm)?; + print_status(cli.output_format, cli.output_version, ident, verbose, pkm)?; } cli::Command::Info { ident } => { - print_info(ident)?; + print_info(cli.output_format, cli.output_version, ident)?; } cli::Command::Ssh { ident } => { - print_ssh(ident)?; + print_ssh(cli.output_format, cli.output_version, ident)?; } cli::Command::Pubkey { ident, user_pin, user_id, } => { - print_pubkey(ident, user_pin, user_id)?; + print_pubkey( + cli.output_format, + cli.output_version, + ident, + user_pin, + user_id, + )?; } cli::Command::SetIdentity { ident, id } => { set_identity(&ident, id)?; @@ -89,15 +102,20 @@ fn main() -> Result<(), Box> { } cli::Command::Attestation { cmd } => match cmd { cli::AttCommand::Cert { ident } => { + let mut output = output::AttestationCert::default(); + let card = pick_card_for_reading(ident)?; let mut pgp = OpenPgp::new(card); let mut open = Open::new(pgp.transaction()?)?; + output.ident(open.application_identifier()?.ident()); if let Ok(ac) = open.attestation_certificate() { let pem = util::pem_encode(ac); - println!("{}", pem); + output.attestation_cert(pem); } + + println!("{}", output.print(cli.output_format, cli.output_version)?); } cli::AttCommand::Generate { ident, @@ -309,6 +327,8 @@ fn main() -> Result<(), Box> { let user_pin = util::get_pin(&mut open, user_pin, ENTER_USER_PIN); generate_keys( + cli.output_format, + cli.output_version, open, admin_pin.as_deref(), user_pin.as_deref(), @@ -558,17 +578,27 @@ fn main() -> Result<(), Box> { Ok(()) } -fn list_cards() -> Result<()> { +fn output_versions(chosen: OutputVersion) { + for v in OUTPUT_VERSIONS.iter() { + if v == &chosen { + println!("* {}", v); + } else { + println!(" {}", v); + } + } +} + +fn list_cards(format: OutputFormat, output_version: OutputVersion) -> Result<()> { let cards = util::cards()?; + let mut output = output::List::default(); if !cards.is_empty() { for card in cards { let mut pgp = OpenPgp::new(card); let open = Open::new(pgp.transaction()?)?; - println!(" {}", open.application_identifier()?.ident()); + output.push(open.application_identifier()?.ident()); } - } else { - println!("No OpenPGP cards found."); } + println!("{}", output.print(format, output_version)?); Ok(()) } @@ -595,7 +625,7 @@ fn pick_card_for_reading(ident: Option) -> Result'"); @@ -606,7 +636,16 @@ fn pick_card_for_reading(ident: Option) -> Result, verbose: bool, pkm: bool) -> Result<()> { +fn print_status( + format: OutputFormat, + output_version: OutputVersion, + ident: Option, + verbose: bool, + pkm: bool, +) -> Result<()> { + let mut output = output::Status::default(); + output.verbose(verbose); + let card = pick_card_for_reading(ident)?; let mut pgp = OpenPgp::new(card); @@ -615,61 +654,44 @@ fn print_status(ident: Option, verbose: bool, pkm: bool) -> Result<()> { let ard = pgpt.application_related_data()?; let mut open = Open::new(pgpt)?; - - print!("OpenPGP card {}", open.application_identifier()?.ident()); + output.ident(open.application_identifier()?.ident()); let ai = open.application_identifier()?; let version = ai.version().to_be_bytes(); - println!(" (card version {}.{})\n", version[0], version[1]); + output.card_version(format!("{}.{}", version[0], version[1])); // card / cardholder metadata let crd = open.cardholder_related_data()?; - // Remember if any cardholder information is printed (if so, we print a newline later) - let mut card_holder_output = false; - if let Some(name) = crd.name() { // FIXME: decoding as utf8 is wrong (the spec defines this field as latin1 encoded) let name = String::from_utf8_lossy(name).to_string(); - print!("Cardholder: "); - - // This field is silly, maybe ignore it?! - if let Some(sex) = crd.sex() { - if sex == Sex::Male { - print!("Mr. "); - } else if sex == Sex::Female { - print!("Mrs. "); - } - } + // // This field is silly, maybe ignore it?! + // if let Some(sex) = crd.sex() { + // if sex == Sex::Male { + // print!("Mr. "); + // } else if sex == Sex::Female { + // print!("Mrs. "); + // } + // } // re-format name ("last< = name.split("<<").collect(); let name = name.iter().cloned().rev().collect::>().join(" "); - println!("{}", name); - - card_holder_output = true; + output.card_holder(name); } let url = open.url()?; if !url.is_empty() { - println!("URL: {}", url); - card_holder_output = true; + output.url(url); } if let Some(lang) = crd.lang() { - let l = lang - .iter() - .map(|l| format!("{}", l)) - .collect::>() - .join(", "); - println!("Language preferences: '{}'", l); - card_holder_output = true; - } - - if card_holder_output { - println!(); + for lang in lang { + output.language_preference(format!("{}", lang)); + } } // key information (imported vs. generated on card) @@ -682,152 +704,131 @@ fn print_status(ident: Option, verbose: bool, pkm: bool) -> Result<()> { let fps = open.fingerprints()?; let kgt = open.key_generation_times()?; - println!("Signature key"); + let mut signature_key = output::KeySlotInfo::default(); if let Some(fp) = fps.signature() { - println!(" Fingerprint: {}", fp.to_spaced_hex()); + signature_key.fingerprint(fp.to_spaced_hex()); } - println! {" Algorithm: {}", open.algorithm_attributes(KeyType::Signing)?}; + signature_key.algorithm(format!("{}", open.algorithm_attributes(KeyType::Signing)?)); if let Some(kgt) = kgt.signature() { - println! {" Created: {}", kgt.to_datetime()}; + signature_key.created(format!("{}", kgt.to_datetime())); } - if verbose { - if let Some(uif) = ard.uif_pso_cds()? { - println!( - " Touch policy: {} [Features: {}]", - uif.touch_policy(), - uif.features() - ); - } - if let Some(ks) = ki.as_ref().map(|ki| ki.sig_status()) { - println!(" Key Status: {}", ks); - } + if let Some(uif) = ard.uif_pso_cds()? { + signature_key.touch_policy(format!("{}", uif.touch_policy())); + signature_key.touch_features(format!("{}", uif.features())); + } + if let Some(ks) = ki.as_ref().map(|ki| ki.sig_status()) { + signature_key.status(format!("{}", ks)); } - if verbose { - if pws.pw1_cds_valid_once() { - println!(" User PIN presentation valid for one signature"); - } else { - println!(" User PIN presentation valid for unlimited signatures"); - } + if pws.pw1_cds_valid_once() { + signature_key.pin_valid_once(); } - let sst = open.security_support_template()?; - println!(" Signatures made: {}", sst.signature_count()); - if pkm { if let Ok(pkm) = open.public_key(KeyType::Signing) { - println! {" Public key material: {}", pkm}; + signature_key.public_key_material(pkm.to_string()); } } - println!(); - println!("Decryption key"); + output.signature_key(signature_key); + + let sst = open.security_support_template()?; + output.signature_count(sst.signature_count()); + + let mut decryption_key = output::KeySlotInfo::default(); if let Some(fp) = fps.decryption() { - println!(" Fingerprint: {}", fp.to_spaced_hex()); + decryption_key.fingerprint(fp.to_spaced_hex()); } - println! {" Algorithm: {}", open.algorithm_attributes(KeyType::Decryption)?}; + decryption_key.algorithm(format!( + "{}", + open.algorithm_attributes(KeyType::Decryption)? + )); if let Some(kgt) = kgt.decryption() { - println! {" Created: {}", kgt.to_datetime()}; + decryption_key.created(format!("{}", kgt.to_datetime())); } - if verbose { - if let Some(uif) = ard.uif_pso_dec()? { - println!( - " Touch policy: {} [Features: {}]", - uif.touch_policy(), - uif.features() - ); - } - if let Some(ks) = ki.as_ref().map(|ki| ki.dec_status()) { - println!(" Key Status: {}", ks); - } + if let Some(uif) = ard.uif_pso_dec()? { + decryption_key.touch_policy(format!("{}", uif.touch_policy())); + decryption_key.touch_features(format!("{}", uif.features())); + } + if let Some(ks) = ki.as_ref().map(|ki| ki.dec_status()) { + decryption_key.status(format!("{}", ks)); } if pkm { if let Ok(pkm) = open.public_key(KeyType::Decryption) { - println! {" Public key material: {}", pkm}; + decryption_key.public_key_material(pkm.to_string()); } } + output.decryption_key(decryption_key); - println!(); - println!("Authentication key"); + let mut authentication_key = output::KeySlotInfo::default(); if let Some(fp) = fps.authentication() { - println!(" Fingerprint: {}", fp.to_spaced_hex()); + authentication_key.fingerprint(fp.to_spaced_hex()); } - println! {" Algorithm: {}", open.algorithm_attributes(KeyType::Authentication)?}; + authentication_key.algorithm(format!( + "{}", + open.algorithm_attributes(KeyType::Authentication)? + )); if let Some(kgt) = kgt.authentication() { - println! {" Created: {}", kgt.to_datetime()}; + authentication_key.created(format!("{}", kgt.to_datetime())); } - if verbose { - if let Some(uif) = ard.uif_pso_aut()? { - println!( - " Touch policy: {} [Features: {}]", - uif.touch_policy(), - uif.features() - ); - } - if let Some(ks) = ki.as_ref().map(|ki| ki.aut_status()) { - println!(" Key Status: {}", ks); - } + if let Some(uif) = ard.uif_pso_aut()? { + authentication_key.touch_policy(format!("{}", uif.touch_policy())); + authentication_key.touch_features(format!("{}", uif.features())); + } + if let Some(ks) = ki.as_ref().map(|ki| ki.aut_status()) { + authentication_key.status(format!("{}", ks)); } if pkm { if let Ok(pkm) = open.public_key(KeyType::Authentication) { - println! {" public key material: {}", pkm}; + authentication_key.public_key_material(pkm.to_string()); } } + output.authentication_key(authentication_key); // technical details about the card's state - println!(); + output.user_pin_remaining_attempts(pws.err_count_pw1()); + output.admin_pin_remaining_attempts(pws.err_count_pw3()); + output.reset_code_remaining_attempts(pws.err_count_rc()); - println!( - "Remaining PIN attempts: User: {}, Admin: {}, Reset Code: {}", - pws.err_count_pw1(), - pws.err_count_pw3(), - pws.err_count_rc(), - ); + // FIXME: Handle attestation key information as a separate + // KeySlotInfo! Attestation touch information should go into its + // own `Option`, and (if any information about the + // attestation key exists at all, which is not the case for most + // cards) it should be printed as a fourth KeySlot block. + if let Some(uif) = ard.uif_attestation()? { + output.card_touch_policy(uif.touch_policy().to_string()); + output.card_touch_features(uif.features().to_string()); + } - if verbose { - println!(); - - if let Some(uif) = ard.uif_attestation()? { - println!( - "Touch policy attestation: {} [Features: {}]", - uif.touch_policy(), - uif.features() - ); - println!(); + if let Some(ki) = ki { + let num = ki.num_additional(); + for i in 0..num { + output.key_status(ki.additional_ref(i), ki.additional_status(i).to_string()); } + } - if let Some(ki) = ki { - let num = ki.num_additional(); - for i in 0..num { - println!( - "Key Status (#{}): {}", - ki.additional_ref(i), - ki.additional_status(i) - ); - } - - if num > 0 { - println!(); - } - } - - if let Ok(fps) = ard.ca_fingerprints() { - for (num, fp) in fps.iter().enumerate() { - if let Some(fp) = fp { - println!("CA fingerprint {}: {:x?}", num + 1, fp); - } - } + if let Ok(fps) = ard.ca_fingerprints() { + for fp in fps.iter().flatten() { + output.ca_fingerprint(fp.to_string()); } } // FIXME: print "Login Data" + println!("{}", output.print(format, output_version)?); + Ok(()) } /// print metadata information about a card -fn print_info(ident: Option) -> Result<()> { +fn print_info( + format: OutputFormat, + output_version: OutputVersion, + ident: Option, +) -> Result<()> { + let mut output = output::Info::default(); + let card = pick_card_for_reading(ident)?; let mut pgp = OpenPgp::new(card); @@ -835,36 +836,44 @@ fn print_info(ident: Option) -> Result<()> { let ai = open.application_identifier()?; - print!("OpenPGP card {}", ai.ident()); + output.ident(ai.ident()); let version = ai.version().to_be_bytes(); - println!(" (card version {}.{})\n", version[0], version[1]); + output.card_version(format!("{}.{}", version[0], version[1])); - println!("Application Identifier: {}", ai); - println!( - "Manufacturer [{:04X}]: {}\n", - ai.manufacturer(), - ai.manufacturer_name() - ); + output.application_id(ai.to_string()); + output.manufacturer_id(format!("{:04X}", ai.manufacturer())); + output.manufacturer_name(ai.manufacturer_name().to_string()); if let Some(cc) = open.historical_bytes()?.card_capabilities() { - println!("Card Capabilities:\n{}", cc); + for line in cc.to_string().lines() { + let line = line.strip_prefix("- ").unwrap_or(line); + output.card_capability(line.to_string()); + } } if let Some(csd) = open.historical_bytes()?.card_service_data() { - println!("Card service data:\n{}", csd); + output.card_service_data(csd.to_string()); } if let Some(eli) = open.extended_length_information()? { - println!("Extended Length Info:\n{}", eli); + for line in eli.to_string().lines() { + let line = line.strip_prefix("- ").unwrap_or(line); + output.extended_length_info(line.to_string()); + } } let ec = open.extended_capabilities()?; - println!("Extended Capabilities:\n{}", ec); + for line in ec.to_string().lines() { + let line = line.strip_prefix("- ").unwrap_or(line); + output.extended_capability(line.to_string()); + } // Algorithm information (list of supported algorithms) if let Ok(Some(ai)) = open.algorithm_information() { - println!("Supported algorithms:"); - println!("{}", ai); + for line in ai.to_string().lines() { + let line = line.strip_prefix("- ").unwrap_or(line); + output.algorithm(line.to_string()); + } } // FIXME: print KDF info @@ -872,55 +881,63 @@ fn print_info(ident: Option) -> Result<()> { // YubiKey specific (?) firmware version if let Ok(ver) = open.firmware_version() { let ver = ver.iter().map(u8::to_string).collect::>().join("."); - - println!("Firmware Version: {}\n", ver); + output.firmware_version(ver); } + println!("{}", output.print(format, output_version)?); + Ok(()) } -fn print_ssh(ident: Option) -> Result<()> { +fn print_ssh( + format: OutputFormat, + output_version: OutputVersion, + ident: Option, +) -> Result<()> { + let mut output = output::Ssh::default(); + let card = pick_card_for_reading(ident)?; let mut pgp = OpenPgp::new(card); let mut open = Open::new(pgp.transaction()?)?; let ident = open.application_identifier()?.ident(); - - println!("OpenPGP card {}", ident); + output.ident(ident.clone()); // Print fingerprint of authentication subkey let fps = open.fingerprints()?; - println!(); if let Some(fp) = fps.authentication() { - println!("Authentication key fingerprint:\n{}", fp); + output.authentication_key_fingerprint(fp.to_string()); } // Show authentication subkey as openssh public key string if let Ok(pkm) = open.public_key(KeyType::Authentication) { if let Ok(ssh) = util::get_ssh_pubkey_string(&pkm, ident) { - println!(); - println!("SSH public key:\n{}", ssh); + output.ssh_public_key(ssh); } } + println!("{}", output.print(format, output_version)?); Ok(()) } fn print_pubkey( + format: OutputFormat, + output_version: OutputVersion, ident: Option, user_pin: Option, user_ids: Vec, ) -> Result<()> { + let mut output = output::PublicKey::default(); + let card = pick_card_for_reading(ident)?; let mut pgp = OpenPgp::new(card); let mut open = Open::new(pgp.transaction()?)?; let ident = open.application_identifier()?.ident(); - - println!("OpenPGP card {}", ident); + output.ident(ident); let user_pin = util::get_pin(&mut open, user_pin, ENTER_USER_PIN); @@ -971,8 +988,9 @@ fn print_pubkey( )?; let armored = String::from_utf8(cert.armored().to_vec()?)?; - println!("{}", armored); + output.public_key(armored); + println!("{}", output.print(format, output_version)?); Ok(()) } @@ -1096,15 +1114,20 @@ fn get_cert( #[allow(clippy::too_many_arguments)] fn generate_keys( + format: OutputFormat, + version: OutputVersion, mut open: Open, admin_pin: Option<&[u8]>, user_pin: Option<&[u8]>, - output: Option, + output_file: Option, decrypt: bool, auth: bool, algo: Option, user_ids: Vec, ) -> Result<()> { + let mut output = output::AdminGenerate::default(); + output.ident(open.application_identifier()?.ident()); + // 1) Interpret the user's choice of algorithm. // // Unset (None) means that the algorithm that is specified on the card @@ -1131,6 +1154,7 @@ fn generate_keys( }; log::info!(" Key generation will be attempted with algo: {:?}", a); + output.algorithm(format!("{:?}", a)); // 2) Then, generate keys on the card. // We need "admin" access to the card for this). @@ -1156,10 +1180,12 @@ fn generate_keys( )?; let armored = String::from_utf8(cert.armored().to_vec()?)?; + output.public_key(armored); // Write armored certificate to the output file (or stdout) - let mut output = util::open_or_stdout(output.as_deref())?; - output.write_all(armored.as_bytes())?; + let mut handle = util::open_or_stdout(output_file.as_deref())?; + handle.write_all(output.print(format, version)?.as_bytes())?; + let _ = handle.write(b"\n")?; Ok(()) } diff --git a/tools/src/bin/opgpcard/output/attest.rs b/tools/src/bin/opgpcard/output/attest.rs new file mode 100644 index 0000000..1849d78 --- /dev/null +++ b/tools/src/bin/opgpcard/output/attest.rs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Debug, Default, Serialize)] +pub struct AttestationCert { + ident: String, + attestation_cert: String, +} + +impl AttestationCert { + pub fn ident(&mut self, ident: String) { + self.ident = ident; + } + + pub fn attestation_cert(&mut self, cert: String) { + self.attestation_cert = cert; + } + + fn text(&self) -> Result { + Ok(format!( + "OpenPGP card {}\n\n{}\n", + self.ident, self.attestation_cert, + )) + } + + fn v1(&self) -> Result { + Ok(AttestationCertV0 { + schema_version: AttestationCertV0::VERSION, + ident: self.ident.clone(), + attestation_cert: self.attestation_cert.clone(), + }) + } +} + +impl OutputBuilder for AttestationCert { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if AttestationCertV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if AttestationCertV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +struct AttestationCertV0 { + schema_version: OutputVersion, + ident: String, + attestation_cert: String, +} + +impl OutputVariant for AttestationCertV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} diff --git a/tools/src/bin/opgpcard/output/generate.rs b/tools/src/bin/opgpcard/output/generate.rs new file mode 100644 index 0000000..33c3282 --- /dev/null +++ b/tools/src/bin/opgpcard/output/generate.rs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Debug, Default, Serialize)] +pub struct AdminGenerate { + ident: String, + algorithm: String, + public_key: String, +} + +impl AdminGenerate { + pub fn ident(&mut self, ident: String) { + self.ident = ident; + } + + pub fn algorithm(&mut self, algorithm: String) { + self.algorithm = algorithm; + } + + pub fn public_key(&mut self, key: String) { + self.public_key = key; + } + + fn text(&self) -> Result { + Ok(format!( + "OpenPGP card {}\n\n{}\n", + self.ident, self.public_key, + )) + } + + fn v1(&self) -> Result { + Ok(AdminGenerateV0 { + schema_version: AdminGenerateV0::VERSION, + ident: self.ident.clone(), + algorithm: self.algorithm.clone(), + public_key: self.public_key.clone(), + }) + } +} + +impl OutputBuilder for AdminGenerate { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if AdminGenerateV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if AdminGenerateV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +struct AdminGenerateV0 { + schema_version: OutputVersion, + ident: String, + algorithm: String, + public_key: String, +} + +impl OutputVariant for AdminGenerateV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} diff --git a/tools/src/bin/opgpcard/output/info.rs b/tools/src/bin/opgpcard/output/info.rs new file mode 100644 index 0000000..57f2b10 --- /dev/null +++ b/tools/src/bin/opgpcard/output/info.rs @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Debug, Default, Serialize)] +pub struct Info { + ident: String, + card_version: String, + application_id: String, + manufacturer_id: String, + manufacturer_name: String, + card_capabilities: Vec, + card_service_data: String, + extended_length_info: Vec, + extended_capabilities: Vec, + algorithms: Option>, + firmware_version: Option, +} + +impl Info { + pub fn ident(&mut self, ident: String) { + self.ident = ident; + } + + pub fn card_version(&mut self, version: String) { + self.card_version = version; + } + + pub fn application_id(&mut self, id: String) { + self.application_id = id; + } + + pub fn manufacturer_id(&mut self, id: String) { + self.manufacturer_id = id; + } + + pub fn manufacturer_name(&mut self, name: String) { + self.manufacturer_name = name; + } + + pub fn card_capability(&mut self, capability: String) { + self.card_capabilities.push(capability); + } + + pub fn card_service_data(&mut self, data: String) { + self.card_service_data = data; + } + + pub fn extended_length_info(&mut self, info: String) { + self.extended_length_info.push(info); + } + + pub fn extended_capability(&mut self, capability: String) { + self.extended_capabilities.push(capability); + } + + pub fn algorithm(&mut self, algorithm: String) { + if let Some(ref mut algos) = self.algorithms { + algos.push(algorithm); + } else { + self.algorithms = Some(vec![algorithm]); + } + } + + pub fn firmware_version(&mut self, version: String) { + self.firmware_version = Some(version); + } + + fn text(&self) -> Result { + let mut s = format!("OpenPGP card {}\n\n", self.ident); + + s.push_str(&format!( + "Application Identifier: {}\n", + self.application_id + )); + s.push_str(&format!( + "Manufacturer [{}]: {}\n\n", + self.manufacturer_id, self.manufacturer_name + )); + + if !self.card_capabilities.is_empty() { + s.push_str("Card Capabilities:\n"); + for c in self.card_capabilities.iter() { + s.push_str(&format!("- {}\n", c)); + } + s.push('\n'); + } + + if !self.card_service_data.is_empty() { + s.push_str(&format!("Card service data: {}\n", self.card_service_data)); + s.push('\n'); + } + + if !self.extended_length_info.is_empty() { + s.push_str("Extended Length Info:\n"); + for c in self.extended_length_info.iter() { + s.push_str(&format!("- {}\n", c)); + } + s.push('\n'); + } + + s.push_str("Extended Capabilities:\n"); + for c in self.extended_capabilities.iter() { + s.push_str(&format!("- {}\n", c)); + } + s.push('\n'); + + if let Some(algos) = &self.algorithms { + s.push_str("Supported algorithms:\n"); + for c in algos.iter() { + s.push_str(&format!("- {}\n", c)); + } + s.push('\n'); + } + + if let Some(v) = &self.firmware_version { + s.push_str(&format!("Firmware Version: {}\n", v)); + } + + Ok(s) + } + + fn v1(&self) -> Result { + Ok(InfoV0 { + schema_version: InfoV0::VERSION, + ident: self.ident.clone(), + card_version: self.card_version.clone(), + application_id: self.application_id.clone(), + manufacturer_id: self.manufacturer_id.clone(), + manufacturer_name: self.manufacturer_name.clone(), + card_capabilities: self.card_capabilities.clone(), + card_service_data: self.card_service_data.clone(), + extended_length_info: self.extended_length_info.clone(), + extended_capabilities: self.extended_capabilities.clone(), + algorithms: self.algorithms.clone(), + firmware_version: self.firmware_version.clone(), + }) + } +} + +impl OutputBuilder for Info { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if InfoV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if InfoV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +struct InfoV0 { + schema_version: OutputVersion, + ident: String, + card_version: String, + application_id: String, + manufacturer_id: String, + manufacturer_name: String, + card_capabilities: Vec, + card_service_data: String, + extended_length_info: Vec, + extended_capabilities: Vec, + algorithms: Option>, + firmware_version: Option, +} + +impl OutputVariant for InfoV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} diff --git a/tools/src/bin/opgpcard/output/list.rs b/tools/src/bin/opgpcard/output/list.rs new file mode 100644 index 0000000..eb2d010 --- /dev/null +++ b/tools/src/bin/opgpcard/output/list.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Default, Debug, Serialize)] +pub struct List { + idents: Vec, +} + +impl List { + pub fn push(&mut self, idnet: String) { + self.idents.push(idnet); + } + + fn text(&self) -> Result { + let s = if self.idents.is_empty() { + "No OpenPGP cards found.".into() + } else { + let mut s = "Available OpenPGP cards:\n".to_string(); + for id in self.idents.iter() { + s.push_str(&format!(" {}\n", id)); + } + s + }; + Ok(s) + } + + fn v1(&self) -> Result { + Ok(ListV0 { + schema_version: ListV0::VERSION, + idents: self.idents.clone(), + }) + } +} + +impl OutputBuilder for List { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if ListV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if ListV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +struct ListV0 { + schema_version: OutputVersion, + idents: Vec, +} + +impl OutputVariant for ListV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} diff --git a/tools/src/bin/opgpcard/output/mod.rs b/tools/src/bin/opgpcard/output/mod.rs new file mode 100644 index 0000000..1534851 --- /dev/null +++ b/tools/src/bin/opgpcard/output/mod.rs @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::OutputVersion; + +#[derive(Debug, thiserror::Error)] +pub enum OpgpCardError { + #[error("unknown output version {0}")] + UnknownVersion(OutputVersion), + + #[error("failed to serialize JSON output with serde_json")] + SerdeJson(#[source] serde_json::Error), + + #[error("failed to serialize YAML output with serde_yaml")] + SerdeYaml(#[source] serde_yaml::Error), +} + +mod list; +pub use list::List; + +mod status; +pub use status::{KeySlotInfo, Status}; + +mod info; +pub use info::Info; + +mod ssh; +pub use ssh::Ssh; + +mod pubkey; +pub use pubkey::PublicKey; + +mod generate; +pub use generate::AdminGenerate; + +mod attest; +pub use attest::AttestationCert; diff --git a/tools/src/bin/opgpcard/output/pubkey.rs b/tools/src/bin/opgpcard/output/pubkey.rs new file mode 100644 index 0000000..0c9ddbd --- /dev/null +++ b/tools/src/bin/opgpcard/output/pubkey.rs @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Debug, Default, Serialize)] +pub struct PublicKey { + ident: String, + public_key: String, +} + +impl PublicKey { + pub fn ident(&mut self, ident: String) { + self.ident = ident; + } + + pub fn public_key(&mut self, key: String) { + self.public_key = key; + } + + fn text(&self) -> Result { + Ok(format!( + "OpenPGP card {}\n\n{}\n", + self.ident, self.public_key + )) + } + + fn v1(&self) -> Result { + Ok(PublicKeyV0 { + schema_version: PublicKeyV0::VERSION, + ident: self.ident.clone(), + public_key: self.public_key.clone(), + }) + } +} + +impl OutputBuilder for PublicKey { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if PublicKeyV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if PublicKeyV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +struct PublicKeyV0 { + schema_version: OutputVersion, + ident: String, + public_key: String, +} + +impl OutputVariant for PublicKeyV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} diff --git a/tools/src/bin/opgpcard/output/ssh.rs b/tools/src/bin/opgpcard/output/ssh.rs new file mode 100644 index 0000000..7d73288 --- /dev/null +++ b/tools/src/bin/opgpcard/output/ssh.rs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Debug, Default, Serialize)] +pub struct Ssh { + ident: String, + authentication_key_fingerprint: Option, + ssh_public_key: Option, +} + +impl Ssh { + pub fn ident(&mut self, ident: String) { + self.ident = ident; + } + + pub fn authentication_key_fingerprint(&mut self, fp: String) { + self.authentication_key_fingerprint = Some(fp); + } + + pub fn ssh_public_key(&mut self, key: String) { + self.ssh_public_key = Some(key); + } + + fn text(&self) -> Result { + let mut s = format!("OpenPGP card {}\n", self.ident); + + if let Some(fp) = &self.authentication_key_fingerprint { + s.push_str(&format!("Authentication key fingerprint:\n{}\n", fp)); + } + if let Some(key) = &self.ssh_public_key { + s.push_str(&format!("SSH public key:\n{}\n", key)); + } + + Ok(s) + } + + fn v1(&self) -> Result { + Ok(SshV0 { + schema_version: SshV0::VERSION, + ident: self.ident.clone(), + authentication_key_fingerprint: self.authentication_key_fingerprint.clone(), + ssh_public_key: self.ssh_public_key.clone(), + }) + } +} + +impl OutputBuilder for Ssh { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if SshV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if SshV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +struct SshV0 { + schema_version: OutputVersion, + ident: String, + authentication_key_fingerprint: Option, + ssh_public_key: Option, +} + +impl OutputVariant for SshV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} diff --git a/tools/src/bin/opgpcard/output/status.rs b/tools/src/bin/opgpcard/output/status.rs new file mode 100644 index 0000000..4e462ad --- /dev/null +++ b/tools/src/bin/opgpcard/output/status.rs @@ -0,0 +1,327 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Serialize; + +use crate::output::OpgpCardError; +use crate::{OutputBuilder, OutputFormat, OutputVariant, OutputVersion}; + +#[derive(Debug, Default, Serialize)] +pub struct Status { + verbose: bool, + ident: String, + card_version: String, + card_holder: Option, + url: Option, + language_preferences: Vec, + signature_key: KeySlotInfo, + signature_count: u32, + decryption_key: KeySlotInfo, + authentication_key: KeySlotInfo, + user_pin_remaining_attempts: u8, + admin_pin_remaining_attempts: u8, + reset_code_remaining_attempts: u8, + card_touch_policy: String, + card_touch_features: String, + key_statuses: Vec<(u8, String)>, + ca_fingerprints: Vec, +} + +impl Status { + pub fn verbose(&mut self, verbose: bool) { + self.verbose = verbose; + } + + pub fn ident(&mut self, ident: String) { + self.ident = ident; + } + + pub fn card_version(&mut self, card_version: String) { + self.card_version = card_version; + } + + pub fn card_holder(&mut self, card_holder: String) { + self.card_holder = Some(card_holder); + } + + pub fn url(&mut self, url: String) { + self.url = Some(url); + } + + pub fn language_preference(&mut self, pref: String) { + self.language_preferences.push(pref); + } + + pub fn signature_key(&mut self, key: KeySlotInfo) { + self.signature_key = key; + } + + pub fn signature_count(&mut self, count: u32) { + self.signature_count = count; + } + + pub fn decryption_key(&mut self, key: KeySlotInfo) { + self.decryption_key = key; + } + + pub fn authentication_key(&mut self, key: KeySlotInfo) { + self.authentication_key = key; + } + + pub fn user_pin_remaining_attempts(&mut self, count: u8) { + self.user_pin_remaining_attempts = count; + } + + pub fn admin_pin_remaining_attempts(&mut self, count: u8) { + self.admin_pin_remaining_attempts = count; + } + + pub fn reset_code_remaining_attempts(&mut self, count: u8) { + self.reset_code_remaining_attempts = count; + } + + pub fn card_touch_policy(&mut self, policy: String) { + self.card_touch_policy = policy; + } + + pub fn card_touch_features(&mut self, features: String) { + self.card_touch_features = features; + } + + pub fn key_status(&mut self, keyref: u8, status: String) { + self.key_statuses.push((keyref, status)); + } + + pub fn ca_fingerprint(&mut self, fingerprint: String) { + self.ca_fingerprints.push(fingerprint); + } + + fn text(&self) -> Result { + let mut s = String::new(); + + s.push_str(&format!( + "OpenPGP card {} (card version {})\n\n", + self.ident, self.card_version + )); + + let mut nl = false; + if let Some(name) = &self.card_holder { + if !name.is_empty() { + s.push_str(&format!("Cardholder: {}\n", name)); + nl = true; + } + } + + if let Some(url) = &self.url { + if !url.is_empty() { + s.push_str(&format!("URL: {}\n", url)); + nl = true; + } + } + + if !self.language_preferences.is_empty() { + let prefs = self.language_preferences.to_vec().join(", "); + if !prefs.is_empty() { + s.push_str(&format!("Language preferences: '{}'\n", prefs)); + nl = true; + } + } + + if nl { + s.push('\n'); + } + + s.push_str("Signature key:\n"); + for line in self.signature_key.format(self.verbose) { + s.push_str(&format!(" {}\n", line)); + } + s.push_str(&format!(" Signatures made: {}\n", self.signature_count)); + s.push('\n'); + + s.push_str("Decryption key:\n"); + for line in self.decryption_key.format(self.verbose) { + s.push_str(&format!(" {}\n", line)); + } + s.push('\n'); + + s.push_str("Authentication key:\n"); + for line in self.authentication_key.format(self.verbose) { + s.push_str(&format!(" {}\n", line)); + } + s.push('\n'); + + s.push_str(&format!( + "Remaining PIN attempts: User: {}, Admin: {}, Reset Code: {}\n", + self.user_pin_remaining_attempts, + self.admin_pin_remaining_attempts, + self.reset_code_remaining_attempts + )); + + if self.verbose { + s.push_str(&format!( + "Touch policy attestation: {}\n", + self.card_touch_policy + )); + + for (keyref, status) in self.key_statuses.iter() { + s.push_str(&format!("Key status (#{}): {}\n", keyref, status)); + } + } + + Ok(s) + } + + fn v1(&self) -> Result { + Ok(StatusV0 { + schema_version: StatusV0::VERSION, + ident: self.ident.clone(), + card_version: self.card_version.clone(), + card_holder: self.card_holder.clone(), + url: self.url.clone(), + language_preferences: self.language_preferences.clone(), + signature_key: self.signature_key.clone(), + signature_count: self.signature_count, + decryption_key: self.decryption_key.clone(), + authentication_key: self.authentication_key.clone(), + user_pin_remaining_attempts: self.user_pin_remaining_attempts, + admin_pin_remaining_attempts: self.admin_pin_remaining_attempts, + reset_code_remaining_attempts: self.reset_code_remaining_attempts, + card_touch_policy: self.card_touch_policy.clone(), + card_touch_features: self.card_touch_features.clone(), + key_statuses: self.key_statuses.clone(), + ca_fingerprints: self.ca_fingerprints.clone(), + }) + } +} + +impl OutputBuilder for Status { + type Err = OpgpCardError; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result { + match format { + OutputFormat::Json => { + let result = if StatusV0::VERSION.is_acceptable_for(&version) { + self.v1()?.json() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeJson) + } + OutputFormat::Yaml => { + let result = if StatusV0::VERSION.is_acceptable_for(&version) { + self.v1()?.yaml() + } else { + return Err(Self::Err::UnknownVersion(version)); + }; + result.map_err(Self::Err::SerdeYaml) + } + OutputFormat::Text => Ok(self.text()?), + } + } +} + +#[derive(Debug, Serialize)] +pub struct StatusV0 { + schema_version: OutputVersion, + ident: String, + card_version: String, + card_holder: Option, + url: Option, + language_preferences: Vec, + signature_key: KeySlotInfo, + signature_count: u32, + decryption_key: KeySlotInfo, + authentication_key: KeySlotInfo, + user_pin_remaining_attempts: u8, + admin_pin_remaining_attempts: u8, + reset_code_remaining_attempts: u8, + card_touch_policy: String, + card_touch_features: String, + key_statuses: Vec<(u8, String)>, + ca_fingerprints: Vec, +} + +impl OutputVariant for StatusV0 { + const VERSION: OutputVersion = OutputVersion::new(0, 9, 0); +} + +#[derive(Debug, Default, Clone, Serialize)] +pub struct KeySlotInfo { + fingerprint: Option, + algorithm: Option, + created: Option, + touch_policy: Option, + touch_features: Option, + status: Option, + pin_valid_once: bool, + public_key_material: Option, +} + +impl KeySlotInfo { + pub fn fingerprint(&mut self, fingerprint: String) { + self.fingerprint = Some(fingerprint); + } + + pub fn algorithm(&mut self, algorithm: String) { + self.algorithm = Some(algorithm); + } + + pub fn created(&mut self, created: String) { + self.created = Some(created); + } + + pub fn touch_policy(&mut self, policy: String) { + self.touch_policy = Some(policy); + } + + pub fn touch_features(&mut self, features: String) { + self.touch_features = Some(features); + } + + pub fn status(&mut self, status: String) { + self.status = Some(status); + } + + pub fn pin_valid_once(&mut self) { + self.pin_valid_once = true; + } + + pub fn public_key_material(&mut self, material: String) { + self.public_key_material = Some(material); + } + + fn format(&self, verbose: bool) -> Vec { + let mut lines = vec![]; + + if let Some(fp) = &self.fingerprint { + lines.push(format!("Fingerprint: {}", fp)); + } + if let Some(a) = &self.algorithm { + lines.push(format!("Algorithm: {}", a)); + } + if let Some(ts) = &self.created { + lines.push(format!("Created: {}", ts)); + } + + if verbose { + if let Some(policy) = &self.touch_policy { + if let Some(features) = &self.touch_features { + lines.push(format!("Touch policy: {} (features: {})", policy, features)); + } + } + if let Some(status) = &self.status { + lines.push(format!("Key Status: {}", status)); + } + if self.pin_valid_once { + lines.push("User PIN presentation valid for one signature".into()); + } else { + lines.push("User PIN presentation valid for unlimited signatures".into()); + } + } + if let Some(material) = &self.public_key_material { + lines.push(format!("Public key material: {}", material)); + } + + lines + } +} diff --git a/tools/src/bin/opgpcard/versioned_output.rs b/tools/src/bin/opgpcard/versioned_output.rs new file mode 100644 index 0000000..c8078b6 --- /dev/null +++ b/tools/src/bin/opgpcard/versioned_output.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use clap::ValueEnum; +use semver::Version; +use serde::{Serialize, Serializer}; +use std::str::FromStr; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +pub enum OutputFormat { + Json, + Text, + Yaml, +} + +#[derive(Debug, Clone)] +pub struct OutputVersion { + version: Version, +} + +impl OutputVersion { + pub const fn new(major: u64, minor: u64, patch: u64) -> Self { + Self { + version: Version::new(major, minor, patch), + } + } + + /// Does this version fulfill the needs of the version that is requested? + pub fn is_acceptable_for(&self, wanted: &Self) -> bool { + self.version.major == wanted.version.major + && (self.version.minor > wanted.version.minor + || (self.version.minor == wanted.version.minor + && self.version.patch >= wanted.version.patch)) + } +} + +impl std::fmt::Display for OutputVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.version) + } +} + +impl FromStr for OutputVersion { + type Err = semver::Error; + + fn from_str(s: &str) -> Result { + let v = Version::parse(s)?; + Ok(Self::new(v.major, v.minor, v.patch)) + } +} + +impl PartialEq for &OutputVersion { + fn eq(&self, other: &Self) -> bool { + self.version == other.version + } +} + +impl Serialize for OutputVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +pub trait OutputBuilder { + type Err; + + fn print(&self, format: OutputFormat, version: OutputVersion) -> Result; +} + +pub trait OutputVariant: Serialize { + const VERSION: OutputVersion; + fn json(&self) -> Result { + serde_json::to_string_pretty(self) + } + fn yaml(&self) -> Result { + serde_yaml::to_string(self) + } +} diff --git a/tools/subplot/opgpcard.md b/tools/subplot/opgpcard.md new file mode 100644 index 0000000..9ed378b --- /dev/null +++ b/tools/subplot/opgpcard.md @@ -0,0 +1,114 @@ + + +# Introduction + +This document describes the requirements and acceptance criteria for +the `opgpcard` tool, and also how to verify that they are met. This +document is meant to be read and understood by all stakeholders, and +processed by the [Subplot](https://subplot.tech/) tool, which also +generates code to automatically perform the verification. + +## Note about running the tests described here + +The verification scenarios in this document assume the availability of +a virtual smart card. Specifically one described in +. The +`openpgp-card/tools` crate is set up to generate tests only if the +file `tools/virtual-card-available` exists, and the `openpgp-card` +repository `.gitlab-ci.yml` file is set up to create that file when +the repository is tested in GitLab CI. + +This means that if you run `cargo test`, no test code is normally +generated from this document. To run the tests locally, outside of +GitLab CI, use the script `tools/cargo-test-in-docker`. + +# Acceptance criteria + +These scenarios mainly test the JSON output format of the tool. That +format is meant for consumption by other tools, and it is thus more +important that it stays stable. The text output that is meant for +human consumption may change at will, so it's not worth testing. + +## Smoke test + +_Requirement: The tool can report its version._ + +Justification: This is useful mainly to make sure the tool can be run +at all. As such, it acts as a simple [smoke +test](https://en.wikipedia.org/wiki/Smoke_testing_(software)). +However, if this fails, then nothing else has a chance to work. + +Note that this is not in JSON format, as it is output by the `clap` +library, and `opgpcard` doesn't affect what it looks like. + +~~~scenario +given an installed opgpcard +when I run opgpcard --version +then stdout matches regex ^opgpcard \d+\.\d+\.\d+$ +~~~ + +## List cards: `opgpcard list` + +_Requirement: The tool lists available cards._ + +This is not at all a thorough test, but it exercises the simple happy +paths of the subcommand. + +~~~scenario +given an installed opgpcard +when I run opgpcard --output-format=json list +then stdout, as JSON, matches embedded file list.json +~~~ + +~~~{#list.json .file .json} +{ + "idents": ["AFAF:00001234"] +} +~~~ + +## Card status: `opgpcard status` + +_Requirement: The tool shows status of available cards._ + +This is not at all a thorough test, but it exercises the simple happy +paths of the subcommand. + +~~~scenario +given an installed opgpcard +when I run opgpcard --output-format=json status +then stdout, as JSON, matches embedded file status.json +~~~ + +~~~{#status.json .file .json} +{ + "card_version": "2.0", + "ident": "AFAF:00001234" +} +~~~ + +## Card information: `opgpcard info` + +_Requirement: The tool shows information about available cards._ + +This is not at all a thorough test, but it exercises the simple happy +paths of the subcommand. + +~~~scenario +given an installed opgpcard +when I run opgpcard --output-format=json info +then stdout, as JSON, matches embedded file info.json +~~~ + +~~~{#info.json .file .json} +{ + "card_version": "2.0", + "application_id": "D276000124 01 01 0200 AFAF 00001234 0000", + "manufacturer_id": "AFAF", + "manufacturer_name": "Unknown", + "card_service_data": "", + "ident": "AFAF:00001234" +} +~~~ diff --git a/tools/subplot/opgpcard.rs b/tools/subplot/opgpcard.rs new file mode 100644 index 0000000..0297eb4 --- /dev/null +++ b/tools/subplot/opgpcard.rs @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use subplotlib::file::SubplotDataFile; +use subplotlib::steplibrary::runcmd::Runcmd; + +use serde_json::Value; +use std::path::Path; + +#[derive(Debug, Default)] +struct SubplotContext {} + +impl ContextElement for SubplotContext {} + +#[step] +#[context(SubplotContext)] +#[context(Runcmd)] +fn install_opgpcard(context: &ScenarioContext) { + let target_exe = env!("CARGO_BIN_EXE_opgpcard"); + let target_path = Path::new(target_exe); + let target_path = target_path.parent().ok_or("No parent?")?; + context.with_mut( + |context: &mut Runcmd| { + context.prepend_to_path(target_path); + Ok(()) + }, + false, + )?; +} + +#[step] +#[context(Runcmd)] +fn stdout_matches_json_file(context: &ScenarioContext, file: SubplotDataFile) { + let expected: Value = serde_json::from_slice(file.data())?; + println!("expecting JSON: {:#?}", expected); + + let stdout = context.with(|runcmd: &Runcmd| Ok(runcmd.stdout_as_string()), false)?; + let actual: Value = serde_json::from_str(&stdout)?; + println!("stdout JSON: {:#?}", actual); + + println!("fuzzy checking JSON values"); + assert!(json_eq(&actual, &expected)); +} + +// Fuzzy match JSON values. For objects, anything in expected must be +// in actual, but it's OK for there to be extra things. +fn json_eq(actual: &Value, expected: &Value) -> bool { + match actual { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + println!("simple value"); + println!("actual ={:?}", actual); + println!("expected={:?}", expected); + let eq = actual == expected; + println!("simple value eq={}", eq); + return eq; + } + Value::Array(a_values) => { + if let Value::Array(e_values) = expected { + println!("both actual and equal are arrays"); + for (a_value, e_value) in a_values.iter().zip(e_values.iter()) { + println!("comparing corresponding array elements"); + if !json_eq(a_value, e_value) { + println!("array elements differ"); + return false; + } + } + println!("arrays match"); + return true; + } else { + println!("actual is array, expected is not"); + return false; + } + } + Value::Object(a_obj) => { + if let Value::Object(e_obj) = expected { + println!("both actual and equal are objects"); + for key in e_obj.keys() { + println!("checking key {}", key); + if !a_obj.contains_key(key) { + println!("key {} is missing from actual", key); + return false; + } + let a_value = a_obj.get(key).unwrap(); + let e_value = e_obj.get(key).unwrap(); + let eq = json_eq(a_value, e_value); + println!("values for {} eq={}", key, eq); + if !eq { + return false; + } + } + println!("objects match"); + return true; + } else { + println!("actual is object, expected is not"); + return false; + } + } + } +} diff --git a/tools/subplot/opgpcard.subplot b/tools/subplot/opgpcard.subplot new file mode 100644 index 0000000..855d350 --- /dev/null +++ b/tools/subplot/opgpcard.subplot @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2022 Lars Wirzenius +# SPDX-License-Identifier: MIT OR Apache-2.0 + +title: "opgpcard acceptance tests" +markdowns: + - opgpcard.md +bindings: + - opgpcard.yaml + - lib/files.yaml + - lib/runcmd.yaml +impls: + rust: + - opgpcard.rs +classes: + - json diff --git a/tools/subplot/opgpcard.yaml b/tools/subplot/opgpcard.yaml new file mode 100644 index 0000000..1bdc70b --- /dev/null +++ b/tools/subplot/opgpcard.yaml @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2022 Lars Wirzenius +# SPDX-License-Identifier: MIT OR Apache-2.0 + +- given: an installed opgpcard + impl: + rust: + function: install_opgpcard + +- then: "stdout, as JSON, matches embedded file {file:file}" + impl: + rust: + function: stdout_matches_json_file diff --git a/tools/subplot/test-in-docker.sh b/tools/subplot/test-in-docker.sh new file mode 100755 index 0000000..13bf2e1 --- /dev/null +++ b/tools/subplot/test-in-docker.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Run "cargo test" inside a Docker container with virtual cards +# installed and running. This will allow at least rudimentary testing +# of actual card functionality of opgpcard. +# +# SPDX-FileCopyrightText: 2022 Lars Wirzenius +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -euo pipefail + +image="registry.gitlab.com/openpgp-card/virtual-cards/smartpgp-builddeps" + +src="$(cd .. && pwd)" + +if ! [ -e Cargo.toml ] || ! grep '^name.*openpgp-card-tools' Cargo.toml; then + echo "MUST run this in the root of the openpgp-card-tool crate" 1>&2 + exit 1 +fi + +cargo build +docker run --rm -t \ + --volume "cargo:/cargo" \ + --volume "dotcargo:/root/.cargo" \ + --volume "$src:/opt" "$image" \ + bash -c ' +/etc/init.d/pcscd start && \ +su - -c "sh /home/jcardsim/run-card.sh >/dev/null" jcardsim && \ +cd /opt/tools && env CARGO_TARGET_DIR=/cargo cargo test' diff --git a/tools/tests/opgpcard.rs b/tools/tests/opgpcard.rs new file mode 100644 index 0000000..72f3554 --- /dev/null +++ b/tools/tests/opgpcard.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2022 Lars Wirzenius +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![allow(clippy::needless_return)] + +include!(concat!(env!("OUT_DIR"), "/opgpcard.rs"));