Merge branch 'liw/json' into 'main'

Add JSON and YAML output to opgpcard

Closes #36

See merge request openpgp-card/openpgp-card!9
This commit is contained in:
Heiko 2022-10-24 17:54:21 +00:00
commit 44c73a154b
23 changed files with 1657 additions and 175 deletions

View file

@ -58,6 +58,7 @@ cargo-clippy:
- apt clean - apt clean
script: script:
- rustup component add clippy - rustup component add clippy
- rm tools/tests/opgpcard.rs # otherwise build fails
- cargo clippy --verbose --tests -- -D warnings - cargo clippy --verbose --tests -- -D warnings
cache: cache:
# inherit all general cache settings # 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 - 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 - 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: script:
- rm tools/tests/opgpcard.rs # otherwise build fails
- cargo udeps --workspace --all-features --all-targets - cargo udeps --workspace --all-features --all-targets
cache: [ ] cache: [ ]
cargo-test: cargo-test:
stage: test stage: test
image: rust:latest image: registry.gitlab.com/openpgp-card/virtual-cards/smartpgp-builddeps
before_script: before_script:
- mkdir -p /run/user/$UID - mkdir -p /run/user/$UID
- apt update -y -qq - 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 install -y -qq --no-install-recommends git clang make pkg-config nettle-dev libssl-dev capnproto ca-certificates libpcsclite-dev
- apt clean - apt clean
- /etc/init.d/pcscd start
- su - -c "sh /home/jcardsim/run-card.sh >/dev/null" jcardsim
script: script:
- touch tools/virtual-card-available
- cargo test - cargo test
cache: cache:
# inherit all general cache settings # inherit all general cache settings
@ -97,13 +102,16 @@ cargo-test:
cargo-test-debian-bookworm: cargo-test-debian-bookworm:
stage: test stage: test
image: debian:bookworm-slim image: registry.gitlab.com/openpgp-card/virtual-cards/smartpgp-builddeps
before_script: before_script:
- mkdir -p /run/user/$UID - mkdir -p /run/user/$UID
- apt update -y -qq - 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 install -y -qq --no-install-recommends git rustc cargo clang make pkg-config nettle-dev libssl-dev capnproto ca-certificates libpcsclite-dev
- apt clean - apt clean
- /etc/init.d/pcscd start
- su - -c "sh /home/jcardsim/run-card.sh >/dev/null" jcardsim
script: script:
- touch tools/virtual-card-available
- cargo test - cargo test
cache: cache:
# inherit all general cache settings # inherit all general cache settings

View file

@ -16,6 +16,7 @@ ignore = [
unlicensed = "deny" unlicensed = "deny"
allow = [ allow = [
"MIT", "MIT",
"MIT-0",
"Apache-2.0", "Apache-2.0",
"BSD-3-Clause", "BSD-3-Clause",
"BSD-2-Clause", "BSD-2-Clause",

View file

@ -23,3 +23,18 @@ anyhow = "1"
clap = { version = "3", features = ["derive"] } clap = { version = "3", features = ["derive"] }
env_logger = "0.9" env_logger = "0.9"
log = "0.4" 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"]

27
tools/build.rs Normal file
View file

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fir>
// 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();
}
}
}

23
tools/cargo-test-in-docker Executable file
View file

@ -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 <liw@liw.fi>
# 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'

62
tools/scripting.md Normal file
View file

@ -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 <liw@liw.fi>
SPDX-License-Identifier: MIT OR Apache-2.0

View file

@ -4,6 +4,11 @@
use clap::{AppSettings, Parser}; use clap::{AppSettings, Parser};
use std::path::PathBuf; 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)] #[derive(Parser, Debug)]
#[clap( #[clap(
name = "opgpcard", name = "opgpcard",
@ -13,12 +18,24 @@ use std::path::PathBuf;
about = "A tool for inspecting and configuring OpenPGP cards." about = "A tool for inspecting and configuring OpenPGP cards."
)] )]
pub struct Cli { 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)] #[clap(subcommand)]
pub cmd: Command, pub cmd: Command,
} }
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub enum Command { pub enum Command {
/// Show all output versions that are supported. Mark the
/// currently chosen one with a star.
OutputVersions {},
/// Enumerate available OpenPGP cards /// Enumerate available OpenPGP cards
List {}, List {},

View file

@ -16,7 +16,7 @@ use sequoia_openpgp::types::{HashAlgorithm, SymmetricAlgorithm};
use sequoia_openpgp::Cert; use sequoia_openpgp::Cert;
use openpgp_card::algorithm::AlgoSimple; 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::{CardBackend, KeyType, OpenPgp};
use openpgp_card_sequoia::card::{Admin, Card, Open}; use openpgp_card_sequoia::card::{Admin, Card, Open};
use openpgp_card_sequoia::util::{ use openpgp_card_sequoia::util::{
@ -28,7 +28,12 @@ use crate::util::{load_pin, print_gnuk_note};
use std::io::Write; use std::io::Write;
mod cli; mod cli;
mod output;
mod util; 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_USER_PIN: &str = "Enter User PIN:";
const ENTER_ADMIN_PIN: &str = "Enter Admin PIN:"; const ENTER_ADMIN_PIN: &str = "Enter Admin PIN:";
@ -39,29 +44,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = cli::Cli::parse(); let cli = cli::Cli::parse();
match cli.cmd { match cli.cmd {
cli::Command::OutputVersions {} => {
output_versions(cli.output_version);
}
cli::Command::List {} => { cli::Command::List {} => {
println!("Available OpenPGP cards:"); list_cards(cli.output_format, cli.output_version)?;
list_cards()?;
} }
cli::Command::Status { cli::Command::Status {
ident, ident,
verbose, verbose,
pkm, pkm,
} => { } => {
print_status(ident, verbose, pkm)?; print_status(cli.output_format, cli.output_version, ident, verbose, pkm)?;
} }
cli::Command::Info { ident } => { cli::Command::Info { ident } => {
print_info(ident)?; print_info(cli.output_format, cli.output_version, ident)?;
} }
cli::Command::Ssh { ident } => { cli::Command::Ssh { ident } => {
print_ssh(ident)?; print_ssh(cli.output_format, cli.output_version, ident)?;
} }
cli::Command::Pubkey { cli::Command::Pubkey {
ident, ident,
user_pin, user_pin,
user_id, 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 } => { cli::Command::SetIdentity { ident, id } => {
set_identity(&ident, id)?; set_identity(&ident, id)?;
@ -89,15 +102,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
cli::Command::Attestation { cmd } => match cmd { cli::Command::Attestation { cmd } => match cmd {
cli::AttCommand::Cert { ident } => { cli::AttCommand::Cert { ident } => {
let mut output = output::AttestationCert::default();
let card = pick_card_for_reading(ident)?; let card = pick_card_for_reading(ident)?;
let mut pgp = OpenPgp::new(card); let mut pgp = OpenPgp::new(card);
let mut open = Open::new(pgp.transaction()?)?; let mut open = Open::new(pgp.transaction()?)?;
output.ident(open.application_identifier()?.ident());
if let Ok(ac) = open.attestation_certificate() { if let Ok(ac) = open.attestation_certificate() {
let pem = util::pem_encode(ac); let pem = util::pem_encode(ac);
println!("{}", pem); output.attestation_cert(pem);
} }
println!("{}", output.print(cli.output_format, cli.output_version)?);
} }
cli::AttCommand::Generate { cli::AttCommand::Generate {
ident, ident,
@ -309,6 +327,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let user_pin = util::get_pin(&mut open, user_pin, ENTER_USER_PIN); let user_pin = util::get_pin(&mut open, user_pin, ENTER_USER_PIN);
generate_keys( generate_keys(
cli.output_format,
cli.output_version,
open, open,
admin_pin.as_deref(), admin_pin.as_deref(),
user_pin.as_deref(), user_pin.as_deref(),
@ -558,17 +578,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) 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 cards = util::cards()?;
let mut output = output::List::default();
if !cards.is_empty() { if !cards.is_empty() {
for card in cards { for card in cards {
let mut pgp = OpenPgp::new(card); let mut pgp = OpenPgp::new(card);
let open = Open::new(pgp.transaction()?)?; 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(()) Ok(())
} }
@ -595,7 +625,7 @@ fn pick_card_for_reading(ident: Option<String>) -> Result<Box<dyn CardBackend +
Err(anyhow::anyhow!("No cards found")) Err(anyhow::anyhow!("No cards found"))
} else { } else {
println!("Found {} cards:", cards.len()); println!("Found {} cards:", cards.len());
list_cards()?; list_cards(OutputFormat::Text, OutputVersion::new(1, 0, 0))?;
println!(); println!();
println!("Specify which card to use with '--card <card ident>'"); println!("Specify which card to use with '--card <card ident>'");
@ -606,7 +636,16 @@ fn pick_card_for_reading(ident: Option<String>) -> Result<Box<dyn CardBackend +
} }
} }
fn print_status(ident: Option<String>, verbose: bool, pkm: bool) -> Result<()> { fn print_status(
format: OutputFormat,
output_version: OutputVersion,
ident: Option<String>,
verbose: bool,
pkm: bool,
) -> Result<()> {
let mut output = output::Status::default();
output.verbose(verbose);
let card = pick_card_for_reading(ident)?; let card = pick_card_for_reading(ident)?;
let mut pgp = OpenPgp::new(card); let mut pgp = OpenPgp::new(card);
@ -615,61 +654,44 @@ fn print_status(ident: Option<String>, verbose: bool, pkm: bool) -> Result<()> {
let ard = pgpt.application_related_data()?; let ard = pgpt.application_related_data()?;
let mut open = Open::new(pgpt)?; let mut open = Open::new(pgpt)?;
output.ident(open.application_identifier()?.ident());
print!("OpenPGP card {}", open.application_identifier()?.ident());
let ai = open.application_identifier()?; let ai = open.application_identifier()?;
let version = ai.version().to_be_bytes(); 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 // card / cardholder metadata
let crd = open.cardholder_related_data()?; 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() { if let Some(name) = crd.name() {
// FIXME: decoding as utf8 is wrong (the spec defines this field as latin1 encoded) // FIXME: decoding as utf8 is wrong (the spec defines this field as latin1 encoded)
let name = String::from_utf8_lossy(name).to_string(); let name = String::from_utf8_lossy(name).to_string();
print!("Cardholder: "); // // This field is silly, maybe ignore it?!
// if let Some(sex) = crd.sex() {
// This field is silly, maybe ignore it?! // if sex == Sex::Male {
if let Some(sex) = crd.sex() { // print!("Mr. ");
if sex == Sex::Male { // } else if sex == Sex::Female {
print!("Mr. "); // print!("Mrs. ");
} else if sex == Sex::Female { // }
print!("Mrs. "); // }
}
}
// re-format name ("last<<first") // re-format name ("last<<first")
let name: Vec<_> = name.split("<<").collect(); let name: Vec<_> = name.split("<<").collect();
let name = name.iter().cloned().rev().collect::<Vec<_>>().join(" "); let name = name.iter().cloned().rev().collect::<Vec<_>>().join(" ");
println!("{}", name); output.card_holder(name);
card_holder_output = true;
} }
let url = open.url()?; let url = open.url()?;
if !url.is_empty() { if !url.is_empty() {
println!("URL: {}", url); output.url(url);
card_holder_output = true;
} }
if let Some(lang) = crd.lang() { if let Some(lang) = crd.lang() {
let l = lang for lang in lang {
.iter() output.language_preference(format!("{}", lang));
.map(|l| format!("{}", l)) }
.collect::<Vec<_>>()
.join(", ");
println!("Language preferences: '{}'", l);
card_holder_output = true;
}
if card_holder_output {
println!();
} }
// key information (imported vs. generated on card) // key information (imported vs. generated on card)
@ -682,152 +704,131 @@ fn print_status(ident: Option<String>, verbose: bool, pkm: bool) -> Result<()> {
let fps = open.fingerprints()?; let fps = open.fingerprints()?;
let kgt = open.key_generation_times()?; let kgt = open.key_generation_times()?;
println!("Signature key"); let mut signature_key = output::KeySlotInfo::default();
if let Some(fp) = fps.signature() { 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() { 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()? {
if let Some(uif) = ard.uif_pso_cds()? { signature_key.touch_policy(format!("{}", uif.touch_policy()));
println!( signature_key.touch_features(format!("{}", uif.features()));
" Touch policy: {} [Features: {}]", }
uif.touch_policy(), if let Some(ks) = ki.as_ref().map(|ki| ki.sig_status()) {
uif.features() signature_key.status(format!("{}", ks));
);
}
if let Some(ks) = ki.as_ref().map(|ki| ki.sig_status()) {
println!(" Key Status: {}", ks);
}
} }
if verbose { if pws.pw1_cds_valid_once() {
if pws.pw1_cds_valid_once() { signature_key.pin_valid_once();
println!(" User PIN presentation valid for one signature");
} else {
println!(" User PIN presentation valid for unlimited signatures");
}
} }
let sst = open.security_support_template()?;
println!(" Signatures made: {}", sst.signature_count());
if pkm { if pkm {
if let Ok(pkm) = open.public_key(KeyType::Signing) { if let Ok(pkm) = open.public_key(KeyType::Signing) {
println! {" Public key material: {}", pkm}; signature_key.public_key_material(pkm.to_string());
} }
} }
println!(); output.signature_key(signature_key);
println!("Decryption 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() { 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() { 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()? {
if let Some(uif) = ard.uif_pso_dec()? { decryption_key.touch_policy(format!("{}", uif.touch_policy()));
println!( decryption_key.touch_features(format!("{}", uif.features()));
" Touch policy: {} [Features: {}]", }
uif.touch_policy(), if let Some(ks) = ki.as_ref().map(|ki| ki.dec_status()) {
uif.features() decryption_key.status(format!("{}", ks));
);
}
if let Some(ks) = ki.as_ref().map(|ki| ki.dec_status()) {
println!(" Key Status: {}", ks);
}
} }
if pkm { if pkm {
if let Ok(pkm) = open.public_key(KeyType::Decryption) { 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!(); let mut authentication_key = output::KeySlotInfo::default();
println!("Authentication key");
if let Some(fp) = fps.authentication() { 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() { 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()? {
if let Some(uif) = ard.uif_pso_aut()? { authentication_key.touch_policy(format!("{}", uif.touch_policy()));
println!( authentication_key.touch_features(format!("{}", uif.features()));
" Touch policy: {} [Features: {}]", }
uif.touch_policy(), if let Some(ks) = ki.as_ref().map(|ki| ki.aut_status()) {
uif.features() authentication_key.status(format!("{}", ks));
);
}
if let Some(ks) = ki.as_ref().map(|ki| ki.aut_status()) {
println!(" Key Status: {}", ks);
}
} }
if pkm { if pkm {
if let Ok(pkm) = open.public_key(KeyType::Authentication) { 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 // 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!( // FIXME: Handle attestation key information as a separate
"Remaining PIN attempts: User: {}, Admin: {}, Reset Code: {}", // KeySlotInfo! Attestation touch information should go into its
pws.err_count_pw1(), // own `Option<KeySlotInfo>`, and (if any information about the
pws.err_count_pw3(), // attestation key exists at all, which is not the case for most
pws.err_count_rc(), // 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 { if let Some(ki) = ki {
println!(); let num = ki.num_additional();
for i in 0..num {
if let Some(uif) = ard.uif_attestation()? { output.key_status(ki.additional_ref(i), ki.additional_status(i).to_string());
println!(
"Touch policy attestation: {} [Features: {}]",
uif.touch_policy(),
uif.features()
);
println!();
} }
}
if let Some(ki) = ki { if let Ok(fps) = ard.ca_fingerprints() {
let num = ki.num_additional(); for fp in fps.iter().flatten() {
for i in 0..num { output.ca_fingerprint(fp.to_string());
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);
}
}
} }
} }
// FIXME: print "Login Data" // FIXME: print "Login Data"
println!("{}", output.print(format, output_version)?);
Ok(()) Ok(())
} }
/// print metadata information about a card /// print metadata information about a card
fn print_info(ident: Option<String>) -> Result<()> { fn print_info(
format: OutputFormat,
output_version: OutputVersion,
ident: Option<String>,
) -> Result<()> {
let mut output = output::Info::default();
let card = pick_card_for_reading(ident)?; let card = pick_card_for_reading(ident)?;
let mut pgp = OpenPgp::new(card); let mut pgp = OpenPgp::new(card);
@ -835,36 +836,44 @@ fn print_info(ident: Option<String>) -> Result<()> {
let ai = open.application_identifier()?; let ai = open.application_identifier()?;
print!("OpenPGP card {}", ai.ident()); output.ident(ai.ident());
let version = ai.version().to_be_bytes(); 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); output.application_id(ai.to_string());
println!( output.manufacturer_id(format!("{:04X}", ai.manufacturer()));
"Manufacturer [{:04X}]: {}\n", output.manufacturer_name(ai.manufacturer_name().to_string());
ai.manufacturer(),
ai.manufacturer_name()
);
if let Some(cc) = open.historical_bytes()?.card_capabilities() { 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() { 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()? { 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()?; 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) // Algorithm information (list of supported algorithms)
if let Ok(Some(ai)) = open.algorithm_information() { if let Ok(Some(ai)) = open.algorithm_information() {
println!("Supported algorithms:"); for line in ai.to_string().lines() {
println!("{}", ai); let line = line.strip_prefix("- ").unwrap_or(line);
output.algorithm(line.to_string());
}
} }
// FIXME: print KDF info // FIXME: print KDF info
@ -872,55 +881,63 @@ fn print_info(ident: Option<String>) -> Result<()> {
// YubiKey specific (?) firmware version // YubiKey specific (?) firmware version
if let Ok(ver) = open.firmware_version() { if let Ok(ver) = open.firmware_version() {
let ver = ver.iter().map(u8::to_string).collect::<Vec<_>>().join("."); let ver = ver.iter().map(u8::to_string).collect::<Vec<_>>().join(".");
output.firmware_version(ver);
println!("Firmware Version: {}\n", ver);
} }
println!("{}", output.print(format, output_version)?);
Ok(()) Ok(())
} }
fn print_ssh(ident: Option<String>) -> Result<()> { fn print_ssh(
format: OutputFormat,
output_version: OutputVersion,
ident: Option<String>,
) -> Result<()> {
let mut output = output::Ssh::default();
let card = pick_card_for_reading(ident)?; let card = pick_card_for_reading(ident)?;
let mut pgp = OpenPgp::new(card); let mut pgp = OpenPgp::new(card);
let mut open = Open::new(pgp.transaction()?)?; let mut open = Open::new(pgp.transaction()?)?;
let ident = open.application_identifier()?.ident(); let ident = open.application_identifier()?.ident();
output.ident(ident.clone());
println!("OpenPGP card {}", ident);
// Print fingerprint of authentication subkey // Print fingerprint of authentication subkey
let fps = open.fingerprints()?; let fps = open.fingerprints()?;
println!();
if let Some(fp) = fps.authentication() { 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 // Show authentication subkey as openssh public key string
if let Ok(pkm) = open.public_key(KeyType::Authentication) { if let Ok(pkm) = open.public_key(KeyType::Authentication) {
if let Ok(ssh) = util::get_ssh_pubkey_string(&pkm, ident) { if let Ok(ssh) = util::get_ssh_pubkey_string(&pkm, ident) {
println!(); output.ssh_public_key(ssh);
println!("SSH public key:\n{}", ssh);
} }
} }
println!("{}", output.print(format, output_version)?);
Ok(()) Ok(())
} }
fn print_pubkey( fn print_pubkey(
format: OutputFormat,
output_version: OutputVersion,
ident: Option<String>, ident: Option<String>,
user_pin: Option<PathBuf>, user_pin: Option<PathBuf>,
user_ids: Vec<String>, user_ids: Vec<String>,
) -> Result<()> { ) -> Result<()> {
let mut output = output::PublicKey::default();
let card = pick_card_for_reading(ident)?; let card = pick_card_for_reading(ident)?;
let mut pgp = OpenPgp::new(card); let mut pgp = OpenPgp::new(card);
let mut open = Open::new(pgp.transaction()?)?; let mut open = Open::new(pgp.transaction()?)?;
let ident = open.application_identifier()?.ident(); let ident = open.application_identifier()?.ident();
output.ident(ident);
println!("OpenPGP card {}", ident);
let user_pin = util::get_pin(&mut open, user_pin, ENTER_USER_PIN); 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()?)?; let armored = String::from_utf8(cert.armored().to_vec()?)?;
println!("{}", armored); output.public_key(armored);
println!("{}", output.print(format, output_version)?);
Ok(()) Ok(())
} }
@ -1096,15 +1114,20 @@ fn get_cert(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn generate_keys( fn generate_keys(
format: OutputFormat,
version: OutputVersion,
mut open: Open, mut open: Open,
admin_pin: Option<&[u8]>, admin_pin: Option<&[u8]>,
user_pin: Option<&[u8]>, user_pin: Option<&[u8]>,
output: Option<PathBuf>, output_file: Option<PathBuf>,
decrypt: bool, decrypt: bool,
auth: bool, auth: bool,
algo: Option<String>, algo: Option<String>,
user_ids: Vec<String>, user_ids: Vec<String>,
) -> Result<()> { ) -> Result<()> {
let mut output = output::AdminGenerate::default();
output.ident(open.application_identifier()?.ident());
// 1) Interpret the user's choice of algorithm. // 1) Interpret the user's choice of algorithm.
// //
// Unset (None) means that the algorithm that is specified on the card // 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); log::info!(" Key generation will be attempted with algo: {:?}", a);
output.algorithm(format!("{:?}", a));
// 2) Then, generate keys on the card. // 2) Then, generate keys on the card.
// We need "admin" access to the card for this). // 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()?)?; let armored = String::from_utf8(cert.armored().to_vec()?)?;
output.public_key(armored);
// Write armored certificate to the output file (or stdout) // Write armored certificate to the output file (or stdout)
let mut output = util::open_or_stdout(output.as_deref())?; let mut handle = util::open_or_stdout(output_file.as_deref())?;
output.write_all(armored.as_bytes())?; handle.write_all(output.print(format, version)?.as_bytes())?;
let _ = handle.write(b"\n")?;
Ok(()) Ok(())
} }

View file

@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String, OpgpCardError> {
Ok(format!(
"OpenPGP card {}\n\n{}\n",
self.ident, self.attestation_cert,
))
}
fn v1(&self) -> Result<AttestationCertV0, OpgpCardError> {
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<String, Self::Err> {
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);
}

View file

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String, OpgpCardError> {
Ok(format!(
"OpenPGP card {}\n\n{}\n",
self.ident, self.public_key,
))
}
fn v1(&self) -> Result<AdminGenerateV0, OpgpCardError> {
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<String, Self::Err> {
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);
}

View file

@ -0,0 +1,189 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String>,
card_service_data: String,
extended_length_info: Vec<String>,
extended_capabilities: Vec<String>,
algorithms: Option<Vec<String>>,
firmware_version: Option<String>,
}
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<String, OpgpCardError> {
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<InfoV0, OpgpCardError> {
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<String, Self::Err> {
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<String>,
card_service_data: String,
extended_length_info: Vec<String>,
extended_capabilities: Vec<String>,
algorithms: Option<Vec<String>>,
firmware_version: Option<String>,
}
impl OutputVariant for InfoV0 {
const VERSION: OutputVersion = OutputVersion::new(0, 9, 0);
}

View file

@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String>,
}
impl List {
pub fn push(&mut self, idnet: String) {
self.idents.push(idnet);
}
fn text(&self) -> Result<String, OpgpCardError> {
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<ListV0, OpgpCardError> {
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<String, Self::Err> {
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<String>,
}
impl OutputVariant for ListV0 {
const VERSION: OutputVersion = OutputVersion::new(0, 9, 0);
}

View file

@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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;

View file

@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String, OpgpCardError> {
Ok(format!(
"OpenPGP card {}\n\n{}\n",
self.ident, self.public_key
))
}
fn v1(&self) -> Result<PublicKeyV0, OpgpCardError> {
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<String, Self::Err> {
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);
}

View file

@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String>,
ssh_public_key: Option<String>,
}
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<String, OpgpCardError> {
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<SshV0, OpgpCardError> {
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<String, Self::Err> {
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<String>,
ssh_public_key: Option<String>,
}
impl OutputVariant for SshV0 {
const VERSION: OutputVersion = OutputVersion::new(0, 9, 0);
}

View file

@ -0,0 +1,327 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<String>,
url: Option<String>,
language_preferences: Vec<String>,
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<String>,
}
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<String, OpgpCardError> {
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<StatusV0, OpgpCardError> {
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<String, Self::Err> {
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<String>,
url: Option<String>,
language_preferences: Vec<String>,
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<String>,
}
impl OutputVariant for StatusV0 {
const VERSION: OutputVersion = OutputVersion::new(0, 9, 0);
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct KeySlotInfo {
fingerprint: Option<String>,
algorithm: Option<String>,
created: Option<String>,
touch_policy: Option<String>,
touch_features: Option<String>,
status: Option<String>,
pin_valid_once: bool,
public_key_material: Option<String>,
}
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<String> {
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
}
}

View file

@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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<Self, Self::Err> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
pub trait OutputBuilder {
type Err;
fn print(&self, format: OutputFormat, version: OutputVersion) -> Result<String, Self::Err>;
}
pub trait OutputVariant: Serialize {
const VERSION: OutputVersion;
fn json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
fn yaml(&self) -> Result<String, serde_yaml::Error> {
serde_yaml::to_string(self)
}
}

114
tools/subplot/opgpcard.md Normal file
View file

@ -0,0 +1,114 @@
<!--
SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
SPDX-License-Identifier: MIT OR Apache-2.0
-->
# 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
<https://gitlab.com/openpgp-card/virtual-cards>. 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"
}
~~~

99
tools/subplot/opgpcard.rs Normal file
View file

@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// 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;
}
}
}
}

View file

@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
# 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

View file

@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
# 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

29
tools/subplot/test-in-docker.sh Executable file
View file

@ -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 <liw@liw.fi>
# 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'

6
tools/tests/opgpcard.rs Normal file
View file

@ -0,0 +1,6 @@
// SPDX-FileCopyrightText: 2022 Lars Wirzenius <liw@liw.fi>
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::needless_return)]
include!(concat!(env!("OUT_DIR"), "/opgpcard.rs"));