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
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

View file

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

View file

@ -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"]

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 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 {},

View file

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<String>) -> Result<Box<dyn CardBackend +
Err(anyhow::anyhow!("No cards found"))
} else {
println!("Found {} cards:", cards.len());
list_cards()?;
list_cards(OutputFormat::Text, OutputVersion::new(1, 0, 0))?;
println!();
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 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 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<<first")
let name: Vec<_> = name.split("<<").collect();
let name = name.iter().cloned().rev().collect::<Vec<_>>().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::<Vec<_>>()
.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<String>, 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<KeySlotInfo>`, 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<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 mut pgp = OpenPgp::new(card);
@ -835,36 +836,44 @@ fn print_info(ident: Option<String>) -> 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<String>) -> Result<()> {
// YubiKey specific (?) firmware version
if let Ok(ver) = open.firmware_version() {
let ver = ver.iter().map(u8::to_string).collect::<Vec<_>>().join(".");
println!("Firmware Version: {}\n", ver);
output.firmware_version(ver);
}
println!("{}", output.print(format, output_version)?);
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 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<String>,
user_pin: Option<PathBuf>,
user_ids: Vec<String>,
) -> 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<PathBuf>,
output_file: Option<PathBuf>,
decrypt: bool,
auth: bool,
algo: Option<String>,
user_ids: Vec<String>,
) -> 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(())
}

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"));