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