From c753a1fe5aaebe4163d977e5ffcaca40675149f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Fri, 26 Jun 2026 21:18:34 +0200 Subject: [PATCH] feat: add PIV and YubiKey hardware backends --- .github/workflows/ci.yml | 11 +- .github/workflows/release.yml | 12 +- Cargo.lock | 204 ++++++- Cargo.toml | 5 + Dockerfile | 42 +- crates/rite-piv/Cargo.toml | 23 + crates/rite-piv/src/backend.rs | 216 +++++++ crates/rite-piv/src/convert.rs | 267 +++++++++ crates/rite-piv/src/lib.rs | 38 ++ crates/rite-piv/src/ops.rs | 566 ++++++++++++++++++ crates/rite-runtime/src/system_info.rs | 3 +- crates/rite-runtime/src/test_support.rs | 29 +- crates/rite-stdlib/Cargo.toml | 8 + crates/rite-stdlib/src/backend/mock.rs | 82 ++- crates/rite-stdlib/src/backend/mod.rs | 30 +- crates/rite-stdlib/src/lib.rs | 21 +- crates/rite-stdlib/src/piv/attest.rs | 150 +++++ crates/rite-stdlib/src/piv/mod.rs | 17 + crates/rite-stdlib/src/piv/params.rs | 89 +++ .../rite-stdlib/src/piv/read_certificate.rs | 180 ++++++ crates/rite-stdlib/src/piv/sign.rs | 379 ++++++++++++ crates/rite-yubikey/Cargo.toml | 24 + crates/rite-yubikey/src/backend.rs | 262 ++++++++ crates/rite-yubikey/src/lib.rs | 30 + crates/rite/Cargo.toml | 4 + crates/rite/build.rs | 2 + crates/rite/src/system_info.rs | 4 + docker-bake.hcl | 5 + docs/development/crate-layout.md | 25 +- docs/development/hardware-backends.md | 95 +++ docs/docker.md | 72 ++- examples/piv/README.md | 63 ++ examples/piv/test_data/release_manifest.txt | 9 + examples/piv/yubikey_signing.rite.yaml | 127 ++++ 34 files changed, 3041 insertions(+), 53 deletions(-) create mode 100644 crates/rite-piv/Cargo.toml create mode 100644 crates/rite-piv/src/backend.rs create mode 100644 crates/rite-piv/src/convert.rs create mode 100644 crates/rite-piv/src/lib.rs create mode 100644 crates/rite-piv/src/ops.rs create mode 100644 crates/rite-stdlib/src/piv/attest.rs create mode 100644 crates/rite-stdlib/src/piv/mod.rs create mode 100644 crates/rite-stdlib/src/piv/params.rs create mode 100644 crates/rite-stdlib/src/piv/read_certificate.rs create mode 100644 crates/rite-stdlib/src/piv/sign.rs create mode 100644 crates/rite-yubikey/Cargo.toml create mode 100644 crates/rite-yubikey/src/backend.rs create mode 100644 crates/rite-yubikey/src/lib.rs create mode 100644 docs/development/hardware-backends.md create mode 100644 examples/piv/README.md create mode 100644 examples/piv/test_data/release_manifest.txt create mode 100644 examples/piv/yubikey_signing.rite.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820244b..ab090d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Install system libraries - run: sudo apt-get update && sudo apt-get install -y libssl-dev + run: sudo apt-get update && sudo apt-get install -y libssl-dev libpcsclite-dev - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable with: components: rustfmt, clippy @@ -28,6 +28,10 @@ jobs: run: cargo fmt --all -- --check - name: Clippy run: cargo clippy --workspace --all-targets -- -D warnings + # The default workspace build leaves the hardware-backend features off, so + # lint the piv/yubikey code paths and their tests explicitly. + - name: Clippy (hardware backends) + run: cargo clippy -p rite-stdlib --features piv,yubikey --all-targets -- -D warnings test: name: Test @@ -35,11 +39,14 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Install system libraries - run: sudo apt-get update && sudo apt-get install -y libssl-dev + run: sudo apt-get update && sudo apt-get install -y libssl-dev libpcsclite-dev - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Test run: cargo test --workspace + # piv/yubikey are off by default; run their action and backend tests. + - name: Test (hardware backends) + run: cargo test -p rite-stdlib --features piv,yubikey msrv: # Verifies the MSRV floor declared on the library crates that are diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 216e5c8..19940a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,10 @@ jobs: key: release-${{ matrix.rust_target }} - name: Build rite - run: cargo build --release -p rite --target ${{ matrix.rust_target }} --features openssl-vendored + # PC/SC is a system framework on macOS (PCSC.framework) and Windows + # (WinSCard), so the native release binaries carry the piv/yubikey + # backends. Only the static musl Linux tarballs stay software-only. + run: cargo build --release -p rite --target ${{ matrix.rust_target }} --features openssl-vendored,piv,yubikey - name: Build rite-ls run: cargo build --release -p rite-ls --target ${{ matrix.rust_target }} @@ -104,6 +107,13 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + # The glibc `image` target compiles the arm64 binary under emulation, so + # register QEMU binfmt explicitly rather than relying on the runner's + # preinstalled handlers. The musl `binaries-*` targets cross-compile and + # do not need it. + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/Cargo.lock b/Cargo.lock index 0badbd2..4b54409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -533,6 +543,15 @@ dependencies = [ "syn", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "digest" version = "0.10.7" @@ -604,6 +623,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf 0.12.4", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -640,7 +660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -890,6 +910,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -989,6 +1018,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.48.0" @@ -1176,6 +1214,12 @@ dependencies = [ "serde", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1188,6 +1232,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -1209,6 +1263,7 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.6", + "serde", "smallvec", "zeroize", ] @@ -1348,6 +1403,18 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "palette" version = "0.7.6" @@ -1395,6 +1462,35 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pcsc" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" +dependencies = [ + "bitflags", + "pcsc-sys", +] + +[[package]] +name = "pcsc-sys" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003" +dependencies = [ + "pkg-config", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1513,6 +1609,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", "rand_chacha", "rand_core 0.6.4", ] @@ -1675,7 +1772,7 @@ dependencies = [ "rite-stdlib", "rite-tui", "rpassword", - "secrecy", + "secrecy 0.10.3", "serde_json", "tempfile", ] @@ -1712,6 +1809,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rite-piv" +version = "0.3.0" +dependencies = [ + "der", + "rite-sdk", + "serde_json", + "sha2 0.11.0", + "yubikey", +] + [[package]] name = "rite-render" version = "0.3.0" @@ -1747,12 +1855,12 @@ dependencies = [ "base64ct", "chrono", "crossbeam-channel", - "hkdf", + "hkdf 0.13.0", "rand 0.10.1", "rite-model", "rite-resolver", "rite-sdk", - "secrecy", + "secrecy 0.10.3", "serde", "serde_json", "sha2 0.11.0", @@ -1780,12 +1888,15 @@ dependencies = [ "p256", "rite-model", "rite-openssl", + "rite-piv", "rite-runtime", "rite-sdk", + "rite-yubikey", "rsa", + "secrecy 0.10.3", "serde", "serde_json", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "subtle", "sysinfo", @@ -1802,7 +1913,17 @@ dependencies = [ "ratatui", "rite-model", "rite-runtime", - "secrecy", + "secrecy 0.10.3", +] + +[[package]] +name = "rite-yubikey" +version = "0.3.0" +dependencies = [ + "rite-piv", + "rite-sdk", + "serde_json", + "yubikey", ] [[package]] @@ -1830,6 +1951,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2 0.10.9", "signature", "spki", "subtle", @@ -1865,7 +1987,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1900,6 +2022,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -1958,6 +2089,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2152,7 +2294,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2375,6 +2517,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2806,6 +2959,8 @@ checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", "der", + "sha1 0.10.6", + "signature", "spki", "tls_codec", ] @@ -2821,6 +2976,39 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yubikey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1efb43c1e3edd4cf871c8dc500d900abfa083c1f2bab10b781ea8ffcadedcb" +dependencies = [ + "base16ct 0.2.0", + "der", + "des", + "ecdsa", + "elliptic-curve", + "hmac 0.12.1", + "log", + "nom", + "num-bigint-dig", + "num-integer", + "num-traits", + "p256", + "p384", + "pbkdf2", + "pcsc", + "rand_core 0.6.4", + "rsa", + "secrecy 0.8.0", + "sha1 0.10.6", + "sha2 0.10.9", + "signature", + "subtle", + "uuid", + "x509-cert", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index cc91d82..2dc150b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ members = [ "crates/rite-runtime", "crates/rite-stdlib", "crates/rite-openssl", + "crates/rite-piv", + "crates/rite-yubikey", "crates/rite-render", "crates/rite", "crates/rite-ls", @@ -31,6 +33,8 @@ rite-model = { path = "crates/rite-model", version = "0.3" } rite-resolver = { path = "crates/rite-resolver", version = "0.3" } rite-runtime = { path = "crates/rite-runtime", version = "0.3" } rite-openssl = { path = "crates/rite-openssl", version = "0.3" } +rite-piv = { path = "crates/rite-piv", version = "0.3" } +rite-yubikey = { path = "crates/rite-yubikey", version = "0.3" } # Sole consumer (the `rite` CLI) builds it without default features. rite-stdlib = { path = "crates/rite-stdlib", version = "0.3", default-features = false } rite-render = { path = "crates/rite-render", version = "0.3" } @@ -51,6 +55,7 @@ base32ct = { version = "0.3.1", features = ["alloc"] } base64ct = { version = "1.8.3", features = ["alloc"] } rand = "0.10.1" openssl = { version = "0.10.78" } +yubikey = "0.8" tempfile = "3.27.0" clap = { version = "4.6.1", features = ["derive"] } clap_complete = "4.6.2" diff --git a/Dockerfile b/Dockerfile index 4a3c53d..6352438 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,9 +54,6 @@ RUN --mount=type=cache,target=/root/.cargo/registry,id=cargo-registry-arm64 \ install -D -m 0755 target/aarch64-unknown-linux-musl/release/rite /out/rite && \ install -D -m 0755 target/aarch64-unknown-linux-musl/release/rite-ls /out/rite-ls -ARG TARGETARCH -FROM builder-${TARGETARCH} AS builder - FROM scratch AS binaries-amd64 COPY --from=builder-amd64 /out/rite /rite COPY --from=builder-amd64 /out/rite-ls /rite-ls @@ -65,11 +62,48 @@ FROM scratch AS binaries-arm64 COPY --from=builder-arm64 /out/rite /rite COPY --from=builder-arm64 /out/rite-ls /rite-ls +# glibc builder for the published image. Unlike the musl cross stages above +# (which feed the static, software-only release tarballs and pin `--platform` to +# the toolchain arch), this stage sets no `--platform`, so buildx builds it once +# per target arch under the `image` target: amd64 natively, arm64 emulated via +# QEMU. It dynamically links libpcsclite and +# system OpenSSL, so the image binary can carry the piv/yubikey smart-card +# backends the static musl build cannot. It builds only `rite`; `rite-ls` is a +# release-tarball artifact, not part of the image. +FROM rust:1-trixie@sha256:6df234c1eb92b0545468fab8c18fc5f9adfb994e7d4f67d81d45fe2fcabf5657 AS builder-image +WORKDIR /src +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpcsclite-dev \ + libssl-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* +# Default feature set (which includes system OpenSSL) plus the hardware backends. +ARG CARGO_BUILD_ARGS="--features piv,yubikey" +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +# TARGETARCH keeps the cache mounts per-arch so the two platform builds never +# share a target dir. Commit ARGs at the RUN to keep the COPY layer cache-stable. +ARG TARGETARCH +ARG RITE_BUILD_COMMIT +ARG RITE_BUILD_COMMIT_DATE +RUN --mount=type=cache,target=/root/.cargo/registry,id=cargo-registry-image-${TARGETARCH} \ + --mount=type=cache,target=/src/target,id=cargo-target-image-${TARGETARCH} \ + RITE_BUILD_COMMIT="$RITE_BUILD_COMMIT" \ + RITE_BUILD_COMMIT_DATE="$RITE_BUILD_COMMIT_DATE" \ + cargo build --locked --release -p rite $CARGO_BUILD_ARGS && \ + install -D -m 0755 target/release/rite /out/rite + FROM debian:trixie-slim@sha256:28de0877c2189802884ccd20f15ee41c203573bd87bb6b883f5f46362d24c5c2 AS runtime-base +# libssl3 and libpcsclite1 are the shared libraries the glibc image binary links +# (system OpenSSL + PC/SC). The pcscd daemon and CCID driver are not installed: +# they are only needed to open a real card, which the container does by mounting +# the host's PC/SC socket. See docs/docker.md. RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ jq \ + libssl3 \ + libpcsclite1 \ && rm -rf /var/lib/apt/lists/* RUN groupadd --system --gid 1001 rite \ @@ -84,5 +118,5 @@ ENTRYPOINT ["rite"] CMD ["--help"] FROM runtime-base AS release -COPY --from=builder /out/rite /usr/local/bin/rite +COPY --from=builder-image /out/rite /usr/local/bin/rite USER rite diff --git a/crates/rite-piv/Cargo.toml b/crates/rite-piv/Cargo.toml new file mode 100644 index 0000000..af79d37 --- /dev/null +++ b/crates/rite-piv/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rite-piv" +description = "PIV smart card backend for Rite ceremonies (internal to the `rite` CLI; no stable API)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = [] +categories = [] +publish = true + +[dependencies] +rite-sdk = { workspace = true } + +yubikey = { workspace = true } +der = { workspace = true } +sha2 = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rite-piv/src/backend.rs b/crates/rite-piv/src/backend.rs new file mode 100644 index 0000000..2657b66 --- /dev/null +++ b/crates/rite-piv/src/backend.rs @@ -0,0 +1,216 @@ +//! PIV smart card backend implementation. +//! +//! Wraps a `yubikey::YubiKey` connection (via a `Mutex` for interior +//! mutability) to provide standard PIV operations per NIST SP 800-73-5. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use rite_sdk::{ + Backend, BackendConfig, BackendError, CertRef, CertStoreBackend, KeyId, KeyMetadata, KeySpec, + KeyStoreBackend, PivBackend, PivDeviceInfo, PivSlotInfo, SignAlgorithm, SignBackend, +}; + +use crate::ops; + +/// PIV smart card backend. +/// +/// Wraps a `yubikey::YubiKey` connection to provide standard PIV operations +/// per NIST SP 800-73-5. The inner `YubiKey` is held in a `Mutex` because +/// PC/SC transactions require exclusive device access even for read-only +/// commands, yet several backend trait methods take `&self`. +pub struct PivCardBackend { + name: String, + yubikey: Mutex, +} + +impl PivCardBackend { + /// Connect to a PIV card. + /// + /// If the backend config carries a `serial` key, opens the card with that + /// serial number. Otherwise opens the first available PIV card. + /// + /// # Errors + /// + /// Returns `BackendError::HardwareFailure` when no card is present, or + /// `BackendError::Configuration` when the serial number cannot be parsed. + pub fn try_new(name: String, config: &BackendConfig) -> Result { + let serial = config + .extra + .get("serial") + .and_then(serde_json::Value::as_str); + let yk = if let Some(serial_str) = serial { + let serial_n: u32 = serial_str.parse().map_err(|_| { + BackendError::Configuration(format!("Invalid serial number: {serial_str}")) + })?; + yubikey::YubiKey::open_by_serial(yubikey::Serial(serial_n)).map_err(ops::map_error)? + } else { + yubikey::YubiKey::open().map_err(ops::map_error)? + }; + Ok(Self { + name, + yubikey: Mutex::new(yk), + }) + } + + /// Lock the device, recovering the guard if a previous holder panicked. + /// + /// PC/SC access is serialized through this mutex; a poisoned lock means a + /// prior operation panicked mid-transaction. We recover the guard rather + /// than propagate the poison so a single failed step does not permanently + /// wedge the backend for the rest of the ceremony. + fn device(&self) -> MutexGuard<'_, yubikey::YubiKey> { + self.yubikey.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl Backend for PivCardBackend { + fn name(&self) -> &str { + &self.name + } + + fn provider(&self) -> &'static str { + "piv" + } + + fn fingerprint(&self) -> String { + let yk = self.device(); + format!("piv-serial={}+firmware={}", yk.serial(), yk.version()) + } + + rite_sdk::backend_capabilities!( + as_keystore_mut: KeyStoreBackend, + as_sign_mut: SignBackend, + as_certstore_mut: CertStoreBackend, + as_piv_mut: PivBackend, + ); +} + +impl KeyStoreBackend for PivCardBackend { + fn generate_key(&mut self, spec: KeySpec) -> Result { + let mut yk = self.device(); + ops::generate_key( + &mut yk, + spec.algorithm, + spec.label, + spec.location_hint.as_deref(), + ) + } + + fn import_private_key( + &mut self, + _spec: KeySpec, + _key_bytes: &[u8], + ) -> Result { + Err(BackendError::UnsupportedOperation( + "PIV key import requires the 'untested' yubikey feature".to_string(), + )) + } + + fn export_public_key(&self, key_id: &KeyId) -> Result, BackendError> { + let mut yk = self.device(); + ops::export_public_key(&mut yk, key_id) + } + + fn list_keys(&self) -> Result, BackendError> { + let mut yk = self.device(); + ops::list_keys(&mut yk) + } + + fn delete_key(&mut self, _key_id: &KeyId) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "PIV does not support key deletion".to_string(), + )) + } +} + +impl SignBackend for PivCardBackend { + fn sign( + &mut self, + key_id: &KeyId, + message: &[u8], + algorithm: SignAlgorithm, + ) -> Result, BackendError> { + let mut yk = self.device(); + ops::sign(&mut yk, key_id, message, algorithm) + } + + fn verify( + &self, + _key_id: &KeyId, + _message: &[u8], + _signature: &[u8], + _algorithm: SignAlgorithm, + ) -> Result { + Err(BackendError::UnsupportedOperation( + "PIV cards are signing-only; call verify with the raw public key".to_string(), + )) + } +} + +impl CertStoreBackend for PivCardBackend { + fn store_cert(&mut self, cert_ref: &CertRef, cert_der: &[u8]) -> Result<(), BackendError> { + match cert_ref { + CertRef::PivSlot(slot) => { + let mut yk = self.device(); + ops::write_certificate(&mut yk, *slot, cert_der) + } + // `CertRef` is #[non_exhaustive]; PIV addresses certificates by slot only. + _ => Err(BackendError::UnsupportedOperation( + "PIV backend only supports PivSlot certificate references".to_string(), + )), + } + } + + fn read_cert(&self, cert_ref: &CertRef) -> Result, BackendError> { + match cert_ref { + CertRef::PivSlot(slot) => { + let mut yk = self.device(); + ops::read_certificate(&mut yk, *slot) + } + // `CertRef` is #[non_exhaustive]; PIV addresses certificates by slot only. + _ => Err(BackendError::UnsupportedOperation( + "PIV backend only supports PivSlot certificate references".to_string(), + )), + } + } + + fn delete_cert(&mut self, _cert_ref: &CertRef) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "PIV does not support certificate deletion".to_string(), + )) + } +} + +impl PivBackend for PivCardBackend { + fn list_slots(&self) -> Result, BackendError> { + let mut yk = self.device(); + ops::list_slots(&mut yk) + } + + fn verify_pin(&mut self, pin: &[u8]) -> Result<(), BackendError> { + let mut yk = self.device(); + yk.verify_pin(pin).map_err(ops::map_error) + } + + fn change_pin(&mut self, _current: &[u8], _new: &[u8]) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "Changing PIN requires the 'untested' yubikey feature".to_string(), + )) + } + + fn pin_retries(&mut self) -> Result { + let mut yk = self.device(); + ops::pin_retries(&mut yk) + } + + fn unblock_pin(&mut self, _puk: &[u8], _new_pin: &[u8]) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "PIN unblock requires the 'untested' yubikey feature".to_string(), + )) + } + + fn device_info(&self) -> Result { + let yk = self.device(); + Ok(ops::device_info(&yk)) + } +} diff --git a/crates/rite-piv/src/convert.rs b/crates/rite-piv/src/convert.rs new file mode 100644 index 0000000..61d0571 --- /dev/null +++ b/crates/rite-piv/src/convert.rs @@ -0,0 +1,267 @@ +//! Conversions between `yubikey` crate types and Rite backend types. +//! +//! These helpers are `pub` for reuse by `rite-yubikey`. + +use rite_sdk::{BackendError, KeyAlgorithm, PivKeyOrigin, PivPinPolicy, PivSlot, PivTouchPolicy}; + +/// PIV key reference for retired key-management slot index 0. +/// +/// NIST SP 800-73-5 assigns the retired slots key references `0x82..=0x95`. +/// Rite's [`PivSlot::Retired`] stores a 0-based index (`0..=19`), while the +/// `yubikey` crate's `RetiredSlotId` uses the raw key reference. The two +/// conversion helpers below bridge the representations. +const RETIRED_KEY_REF_BASE: u8 = 0x82; + +/// Convert a Rite `PivSlot` to a `yubikey::piv::SlotId`. +/// +/// # Errors +/// +/// Returns `BackendError::Configuration` when a `Retired` slot index is outside +/// the valid `0..=19` range. +pub fn to_yubikey_slot(slot: PivSlot) -> Result { + use yubikey::piv::{RetiredSlotId, SlotId}; + let id = match slot { + PivSlot::Authentication => SlotId::Authentication, + PivSlot::Signature => SlotId::Signature, + PivSlot::KeyManagement => SlotId::KeyManagement, + PivSlot::CardAuthentication => SlotId::CardAuthentication, + PivSlot::Retired(index) => { + let key_ref = RETIRED_KEY_REF_BASE.checked_add(index).ok_or_else(|| { + BackendError::Configuration(format!( + "retired PIV slot index {index} out of range (valid: 0..=19)" + )) + })?; + let retired = RetiredSlotId::try_from(key_ref).map_err(|_| { + BackendError::Configuration(format!( + "retired PIV slot index {index} out of range (valid: 0..=19)" + )) + })?; + SlotId::Retired(retired) + } + }; + Ok(id) +} + +/// Convert a `yubikey::piv::SlotId` to a Rite `PivSlot`. +/// +/// The `yubikey` crate's `SlotId` includes the Attestation slot (F9), which is +/// Yubico-specific and has no standard PIV equivalent; it falls back to +/// `PivSlot::Authentication`. +pub fn from_yubikey_slot(slot: yubikey::piv::SlotId) -> PivSlot { + use yubikey::piv::SlotId; + match slot { + SlotId::Signature => PivSlot::Signature, + SlotId::KeyManagement => PivSlot::KeyManagement, + SlotId::CardAuthentication => PivSlot::CardAuthentication, + SlotId::Retired(r) => PivSlot::Retired(u8::from(r).saturating_sub(RETIRED_KEY_REF_BASE)), + // PIV Authentication (9A), the Yubico F9 attestation slot, and any + // future vendor slots all map to Authentication. + _ => PivSlot::Authentication, + } +} + +/// Convert a `yubikey::piv::AlgorithmId` to a Rite `KeyAlgorithm`, if supported. +pub fn from_yubikey_algorithm(algo: yubikey::piv::AlgorithmId) -> Option { + match algo { + yubikey::piv::AlgorithmId::Rsa1024 => None, // Not supported by Rite + yubikey::piv::AlgorithmId::Rsa2048 => Some(KeyAlgorithm::Rsa2048), + yubikey::piv::AlgorithmId::EccP256 => Some(KeyAlgorithm::EcdsaP256), + yubikey::piv::AlgorithmId::EccP384 => Some(KeyAlgorithm::EcdsaP384), + } +} + +/// Convert a Rite `KeyAlgorithm` to a `yubikey::piv::AlgorithmId`. +/// +/// Returns `None` for algorithms not supported by PIV cards. +pub fn to_yubikey_algorithm(algo: KeyAlgorithm) -> Option { + match algo { + KeyAlgorithm::Rsa2048 => Some(yubikey::piv::AlgorithmId::Rsa2048), + KeyAlgorithm::EcdsaP256 => Some(yubikey::piv::AlgorithmId::EccP256), + KeyAlgorithm::EcdsaP384 => Some(yubikey::piv::AlgorithmId::EccP384), + // RSA-4096, Ed25519, and symmetric algorithms not supported by standard PIV. + _ => None, + } +} + +/// Convert a `yubikey::piv::Origin` to a Rite `PivKeyOrigin`. +pub fn from_yubikey_origin(origin: yubikey::piv::Origin) -> PivKeyOrigin { + match origin { + yubikey::piv::Origin::Generated => PivKeyOrigin::Generated, + yubikey::piv::Origin::Imported => PivKeyOrigin::Imported, + } +} + +/// Convert a `yubikey::PinPolicy` to a Rite `PivPinPolicy`. +pub fn from_yubikey_pin_policy(policy: yubikey::PinPolicy) -> PivPinPolicy { + match policy { + yubikey::PinPolicy::Default => PivPinPolicy::Default, + yubikey::PinPolicy::Never => PivPinPolicy::Never, + yubikey::PinPolicy::Once => PivPinPolicy::Once, + yubikey::PinPolicy::Always => PivPinPolicy::Always, + } +} + +/// Convert a `yubikey::TouchPolicy` to a Rite `PivTouchPolicy`. +pub fn from_yubikey_touch_policy(policy: yubikey::TouchPolicy) -> PivTouchPolicy { + match policy { + yubikey::TouchPolicy::Default => PivTouchPolicy::Default, + yubikey::TouchPolicy::Never => PivTouchPolicy::Never, + yubikey::TouchPolicy::Always => PivTouchPolicy::Always, + yubikey::TouchPolicy::Cached => PivTouchPolicy::Cached, + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use rite_sdk::{KeyAlgorithm, PivKeyOrigin, PivPinPolicy, PivSlot, PivTouchPolicy}; + use yubikey::piv::{AlgorithmId, Origin}; + + // --- Slot conversions --- + + #[test] + fn slot_roundtrip_standard_slots() { + let slots = [ + PivSlot::Authentication, + PivSlot::Signature, + PivSlot::KeyManagement, + PivSlot::CardAuthentication, + ]; + for slot in slots { + assert_eq!(from_yubikey_slot(to_yubikey_slot(slot).unwrap()), slot); + } + } + + #[test] + fn retired_slot_roundtrip_uses_zero_based_index() { + // Rite index 0 maps to PIV key reference 0x82, index 19 to 0x95. + for index in [0_u8, 1, 9, 19] { + let slot = PivSlot::Retired(index); + assert_eq!(from_yubikey_slot(to_yubikey_slot(slot).unwrap()), slot); + } + } + + #[test] + fn retired_slot_out_of_range_is_configuration_error() { + assert!(matches!( + to_yubikey_slot(PivSlot::Retired(20)).unwrap_err(), + BackendError::Configuration(_) + )); + assert!(matches!( + to_yubikey_slot(PivSlot::Retired(255)).unwrap_err(), + BackendError::Configuration(_) + )); + } + + #[test] + fn attestation_slot_falls_back_to_authentication() { + // The Attestation slot (F9) is Yubico-specific and has no PivSlot equivalent. + assert_eq!( + from_yubikey_slot(yubikey::piv::SlotId::Attestation), + PivSlot::Authentication + ); + } + + // --- Algorithm conversions --- + + #[test] + fn algorithm_from_yubikey_maps_all_variants() { + assert_eq!(from_yubikey_algorithm(AlgorithmId::Rsa1024), None); + assert_eq!( + from_yubikey_algorithm(AlgorithmId::Rsa2048), + Some(KeyAlgorithm::Rsa2048) + ); + assert_eq!( + from_yubikey_algorithm(AlgorithmId::EccP256), + Some(KeyAlgorithm::EcdsaP256) + ); + assert_eq!( + from_yubikey_algorithm(AlgorithmId::EccP384), + Some(KeyAlgorithm::EcdsaP384) + ); + } + + #[test] + fn algorithm_to_yubikey_maps_supported_variants() { + assert_eq!( + to_yubikey_algorithm(KeyAlgorithm::Rsa2048), + Some(AlgorithmId::Rsa2048) + ); + assert_eq!( + to_yubikey_algorithm(KeyAlgorithm::EcdsaP256), + Some(AlgorithmId::EccP256) + ); + assert_eq!( + to_yubikey_algorithm(KeyAlgorithm::EcdsaP384), + Some(AlgorithmId::EccP384) + ); + } + + #[test] + fn algorithm_to_yubikey_rejects_unsupported() { + assert_eq!(to_yubikey_algorithm(KeyAlgorithm::Rsa4096), None); + assert_eq!(to_yubikey_algorithm(KeyAlgorithm::Ed25519), None); + } + + // --- Origin conversion --- + + #[test] + fn origin_from_yubikey() { + assert_eq!( + from_yubikey_origin(Origin::Generated), + PivKeyOrigin::Generated + ); + assert_eq!( + from_yubikey_origin(Origin::Imported), + PivKeyOrigin::Imported + ); + } + + // --- PinPolicy mapping --- + + #[test] + fn pin_policy_from_yubikey() { + assert_eq!( + from_yubikey_pin_policy(yubikey::PinPolicy::Default), + PivPinPolicy::Default + ); + assert_eq!( + from_yubikey_pin_policy(yubikey::PinPolicy::Never), + PivPinPolicy::Never + ); + assert_eq!( + from_yubikey_pin_policy(yubikey::PinPolicy::Once), + PivPinPolicy::Once + ); + assert_eq!( + from_yubikey_pin_policy(yubikey::PinPolicy::Always), + PivPinPolicy::Always + ); + } + + // --- TouchPolicy mapping --- + + #[test] + fn touch_policy_from_yubikey() { + assert_eq!( + from_yubikey_touch_policy(yubikey::TouchPolicy::Default), + PivTouchPolicy::Default + ); + assert_eq!( + from_yubikey_touch_policy(yubikey::TouchPolicy::Never), + PivTouchPolicy::Never + ); + assert_eq!( + from_yubikey_touch_policy(yubikey::TouchPolicy::Always), + PivTouchPolicy::Always + ); + assert_eq!( + from_yubikey_touch_policy(yubikey::TouchPolicy::Cached), + PivTouchPolicy::Cached + ); + } +} diff --git a/crates/rite-piv/src/lib.rs b/crates/rite-piv/src/lib.rs new file mode 100644 index 0000000..ea3d254 --- /dev/null +++ b/crates/rite-piv/src/lib.rs @@ -0,0 +1,38 @@ +//! PIV smart card backend for Rite ceremonies. +//! +//! Provides a backend implementation for generic PIV (Personal Identity +//! Verification) smart cards per FIPS 201-3 and NIST SP 800-73-5. +//! +//! Communication uses the `yubikey` Rust crate over PC/SC. Despite its name, +//! the crate implements the standard PIV command set, so [`PivCardBackend`] +//! works with any compliant PIV card. Yubico vendor extensions (attestation, +//! touch policy, management key) live in the separate `rite-yubikey` crate. +//! +//! ## Capabilities +//! +//! - [`KeyStoreBackend`](rite_sdk::KeyStoreBackend): slot-based key generation +//! - [`SignBackend`](rite_sdk::SignBackend): on-card signing +//! - [`CertStoreBackend`](rite_sdk::CertStoreBackend): slot certificate storage +//! - [`PivBackend`](rite_sdk::PivBackend): slot management, PIN lifecycle +//! +//! Standard PIV has no attestation, so `AttestationBackend` is not implemented +//! here (see `rite-yubikey`). +//! +//! This crate is a pure backend: it depends only on `rite-sdk` and the vendor +//! `yubikey` crate. The ceremony actions that drive it (`piv_read_certificate`, +//! `piv_sign`) live in `rite-stdlib`, which calls [`ops::slot_from_hint`] to +//! parse slot identifiers from ceremony YAML. +//! +//! # Stability +//! +//! Internal crate. This is an implementation detail of the `rite` CLI, with no +//! stable API and no semver guarantees across releases. Build against the +//! public `rite-sdk`, `rite-model`, or `rite-resolver` crates instead. + +#![warn(missing_docs)] + +mod backend; +pub mod convert; +pub mod ops; + +pub use backend::PivCardBackend; diff --git a/crates/rite-piv/src/ops.rs b/crates/rite-piv/src/ops.rs new file mode 100644 index 0000000..df14c85 --- /dev/null +++ b/crates/rite-piv/src/ops.rs @@ -0,0 +1,566 @@ +//! PIV operations as free functions. +//! +//! All operations take `&mut YubiKey` because PC/SC transactions require +//! exclusive device access even for nominally read-only commands (e.g. +//! `Certificate::read` or `piv::metadata`). Backends call these through a +//! `Mutex` to satisfy the `Backend: Sync` requirement while still +//! implementing `&self` trait methods. + +use der::Encode; +use sha2::{Digest, Sha256, Sha384}; +use yubikey::certificate::CertInfo; +use yubikey::piv::{self, AlgorithmId, SlotId}; +use yubikey::{Certificate, YubiKey}; + +use rite_sdk::{ + BackendError, KeyAlgorithm, KeyId, KeyMetadata, PivDeviceInfo, PivKeyOrigin, PivPinPolicy, + PivSlot, PivSlotInfo, PivTouchPolicy, SignAlgorithm, YubikeySlotMetadata, +}; + +use crate::convert; + +// ============================================================================ +// Error mapping +// ============================================================================ + +/// Map a `yubikey::Error` to a `BackendError`. +pub fn map_error(e: yubikey::Error) -> BackendError { + use yubikey::Error; + match e { + Error::NotFound => BackendError::HardwareFailure("No PIV device found".to_string()), + Error::AuthenticationError => BackendError::PinRequired, + Error::WrongPin { tries } => BackendError::PinFailed(u32::from(tries)), + Error::PinLocked => BackendError::PinBlocked, + Error::NotSupported => { + BackendError::UnsupportedOperation("operation not supported by device".to_string()) + } + Error::AlgorithmError => { + BackendError::UnsupportedAlgorithm("algorithm not supported by device".to_string()) + } + other => BackendError::HardwareFailure(other.to_string()), + } +} + +// ============================================================================ +// Slot / KeyId helpers +// ============================================================================ + +/// Parse a PIV slot from a string hint. +/// +/// Accepts the standard slot identifiers (`"9a"`, `"9c"`, `"9d"`, `"9e"`), the +/// retired key-management slots by their hex key reference (`"82"`..`"95"`), or +/// any of these with a `"piv:"` prefix (`"piv:9a"`, `"piv:82"`). +/// +/// Returns a Rite [`PivSlot`] — callers that need the vendor `SlotId` should +/// chain [`convert::to_yubikey_slot`]. +/// +/// # Errors +/// +/// Returns `BackendError::Configuration` for an unrecognized slot string. +pub fn slot_from_hint(hint: &str) -> Result { + let s = hint.strip_prefix("piv:").unwrap_or(hint); + match s { + "9a" => Ok(PivSlot::Authentication), + "9c" => Ok(PivSlot::Signature), + "9d" => Ok(PivSlot::KeyManagement), + "9e" => Ok(PivSlot::CardAuthentication), + other => { + // Retired key-management slots are addressed by their NIST key + // reference (0x82..=0x95). Rite stores them as a 0-based index. + let unknown = || BackendError::Configuration(format!("Unknown PIV slot: {hint}")); + let key_ref = u8::from_str_radix(other, 16).map_err(|_| unknown())?; + if (RETIRED_KEY_REF_MIN..=RETIRED_KEY_REF_MAX).contains(&key_ref) { + Ok(PivSlot::Retired( + key_ref.saturating_sub(RETIRED_KEY_REF_MIN), + )) + } else { + Err(unknown()) + } + } + } +} + +/// First retired key-management slot key reference (NIST SP 800-73-5). +const RETIRED_KEY_REF_MIN: u8 = 0x82; +/// Last retired key-management slot key reference (NIST SP 800-73-5). +const RETIRED_KEY_REF_MAX: u8 = 0x95; + +/// Parse a PIV slot from a `KeyId`. +/// +/// # Errors +/// +/// Returns `BackendError::Configuration` for an unrecognized slot string. +pub fn slot_from_key_id(key_id: &KeyId) -> Result { + slot_from_hint(key_id.as_str()) +} + +/// Convert a `yubikey::piv::SlotId` to a Rite `KeyId`. +// `SlotId` is a vendor #[non_exhaustive] enum; everything outside the four +// standard PIV slots, the retired range, and F9 is deliberately bucketed as +// unknown, so a single wildcard arm is intentional here. +#[allow(clippy::match_wildcard_for_single_variants)] +pub fn key_id_for_slot(slot: SlotId) -> KeyId { + match slot { + SlotId::Authentication => KeyId::new("piv:9a"), + SlotId::Signature => KeyId::new("piv:9c"), + SlotId::KeyManagement => KeyId::new("piv:9d"), + SlotId::CardAuthentication => KeyId::new("piv:9e"), + SlotId::Retired(r) => KeyId::new(format!("piv:{:02x}", u8::from(r))), + SlotId::Attestation => KeyId::new("piv:f9"), + _ => KeyId::new("piv:unknown"), + } +} + +// ============================================================================ +// Device operations +// ============================================================================ + +/// Get device identity information from a connected card. +/// +/// Only accesses cached values (serial, version); no device I/O required. +pub fn device_info(yk: &YubiKey) -> PivDeviceInfo { + PivDeviceInfo { + serial: Some(yk.serial().to_string()), + firmware_version: Some(yk.version().to_string()), + form_factor: None, + } +} + +/// Get the number of remaining PIN retries. +/// +/// # Errors +/// +/// Returns a `BackendError` if the device query fails. +pub fn pin_retries(yk: &mut YubiKey) -> Result { + yk.get_pin_retries().map(u32::from).map_err(map_error) +} + +/// Authenticate with the management key. +/// +/// # Errors +/// +/// Returns a `BackendError` if the key is malformed or authentication fails. +pub fn authenticate_management(yk: &mut YubiKey, mgm_key: &[u8]) -> Result<(), BackendError> { + let key = yubikey::MgmKey::from_bytes(mgm_key).map_err(map_error)?; + yk.authenticate(key).map_err(map_error) +} + +// ============================================================================ +// Slot and certificate operations +// ============================================================================ + +/// List all populated PIV slots with metadata. +/// +/// # Errors +/// +/// Returns a `BackendError` if slot enumeration fails. +pub fn list_slots(yk: &mut YubiKey) -> Result, BackendError> { + let keys = piv::Key::list(yk).map_err(map_error)?; + let mut result = Vec::new(); + for key in keys { + let slot_id = key.slot(); + let rite_slot = convert::from_yubikey_slot(slot_id); + let (algorithm, origin) = match piv::metadata(yk, slot_id) { + Ok(meta) => { + let algo = match meta.algorithm { + piv::ManagementAlgorithmId::Asymmetric(a) => convert::from_yubikey_algorithm(a), + _ => None, + }; + let origin = meta + .origin + .map_or(PivKeyOrigin::Unknown, convert::from_yubikey_origin); + (algo, origin) + } + Err(_) => (None, PivKeyOrigin::Unknown), + }; + result.push(PivSlotInfo { + slot: rite_slot, + algorithm, + has_certificate: true, + origin, + }); + } + Ok(result) +} + +/// Read the DER-encoded X.509 certificate from a PIV slot. +/// +/// # Errors +/// +/// Returns a `BackendError` if the slot is invalid or the read fails. +pub fn read_certificate(yk: &mut YubiKey, slot: PivSlot) -> Result, BackendError> { + let slot_id = convert::to_yubikey_slot(slot)?; + let cert = Certificate::read(yk, slot_id).map_err(map_error)?; + cert.cert + .to_der() + .map_err(|e| BackendError::Other(e.to_string())) +} + +/// Write a DER-encoded X.509 certificate to a PIV slot. +/// +/// # Errors +/// +/// Returns a `BackendError` if the slot is invalid, the certificate is +/// malformed, or the write fails. +pub fn write_certificate( + yk: &mut YubiKey, + slot: PivSlot, + cert_der: &[u8], +) -> Result<(), BackendError> { + let slot_id = convert::to_yubikey_slot(slot)?; + let cert = Certificate::from_bytes(cert_der.to_vec()).map_err(map_error)?; + cert.write(yk, slot_id, CertInfo::Uncompressed) + .map_err(map_error) +} + +// ============================================================================ +// Key management operations +// ============================================================================ + +/// Generate a new asymmetric key pair on the device. +/// +/// Stores the key in the PIV slot indicated by `slot_hint` (e.g. `"9c"` or +/// `"piv:9c"`). Defaults to the Authentication slot (9A) when no hint is given. +/// +/// # Errors +/// +/// Returns a `BackendError` if the algorithm or slot is unsupported, or if +/// generation fails on the device. +pub fn generate_key( + yk: &mut YubiKey, + algorithm: KeyAlgorithm, + label: String, + slot_hint: Option<&str>, +) -> Result { + let yk_algo = convert::to_yubikey_algorithm(algorithm).ok_or_else(|| { + BackendError::UnsupportedAlgorithm(format!("{algorithm} is not supported by PIV")) + })?; + let piv_slot = slot_hint + .map(slot_from_hint) + .transpose()? + .unwrap_or(PivSlot::Authentication); + let slot = convert::to_yubikey_slot(piv_slot)?; + let spki = piv::generate( + yk, + slot, + yk_algo, + yubikey::PinPolicy::Default, + yubikey::TouchPolicy::Default, + ) + .map_err(map_error)?; + let spki_der = spki + .to_der() + .map_err(|e| BackendError::Other(e.to_string()))?; + let key_id = key_id_for_slot(slot); + Ok(KeyMetadata { + key_id, + algorithm, + label, + public_key: Some(spki_der), + attestation: None, + }) +} + +/// Export the public key from a slot in SPKI DER format. +/// +/// # Errors +/// +/// Returns a `BackendError` if the slot is invalid or empty. +pub fn export_public_key(yk: &mut YubiKey, key_id: &KeyId) -> Result, BackendError> { + let slot = convert::to_yubikey_slot(slot_from_key_id(key_id)?)?; + let meta = piv::metadata(yk, slot).map_err(map_error)?; + let spki = meta + .public + .ok_or_else(|| BackendError::SlotEmpty(key_id.to_string()))?; + spki.to_der() + .map_err(|e| BackendError::Other(e.to_string())) +} + +/// List all keys (slots with certificates) on the device. +/// +/// # Errors +/// +/// Returns a `BackendError` if slot enumeration fails. +pub fn list_keys(yk: &mut YubiKey) -> Result, BackendError> { + let keys = piv::Key::list(yk).map_err(map_error)?; + let mut result = Vec::new(); + for key in keys { + let slot_id = key.slot(); + let key_id = key_id_for_slot(slot_id); + let (algorithm, public_key) = match piv::metadata(yk, slot_id) { + Ok(meta) => { + let algo = match meta.algorithm { + piv::ManagementAlgorithmId::Asymmetric(a) => convert::from_yubikey_algorithm(a), + _ => None, + }; + let pk = meta.public.and_then(|spki| spki.to_der().ok()); + (algo, pk) + } + Err(_) => (None, None), + }; + if let Some(algorithm) = algorithm { + result.push(KeyMetadata { + key_id, + algorithm, + label: String::new(), + public_key, + attestation: None, + }); + } + } + Ok(result) +} + +// ============================================================================ +// Signing +// ============================================================================ + +/// Sign data using an on-device private key. +/// +/// Pre-hashes the message before passing it to the PIV sign operation; PIV +/// cards expect a digest as input, not the raw message. +/// +/// # Errors +/// +/// Returns a `BackendError` if the algorithm is unsupported or signing fails. +pub fn sign( + yk: &mut YubiKey, + key_id: &KeyId, + message: &[u8], + algorithm: SignAlgorithm, +) -> Result, BackendError> { + let slot = convert::to_yubikey_slot(slot_from_key_id(key_id)?)?; + + let (yk_algo, hash) = match algorithm { + SignAlgorithm::EcdsaSha256 => { + let mut h = Sha256::new(); + h.update(message); + (AlgorithmId::EccP256, h.finalize().to_vec()) + } + SignAlgorithm::EcdsaSha384 => { + let mut h = Sha384::new(); + h.update(message); + (AlgorithmId::EccP384, h.finalize().to_vec()) + } + SignAlgorithm::RsaPkcs1Sha256 | SignAlgorithm::RsaPssSha256 => { + let mut h = Sha256::new(); + h.update(message); + (AlgorithmId::Rsa2048, h.finalize().to_vec()) + } + SignAlgorithm::Ed25519 => { + return Err(BackendError::UnsupportedAlgorithm( + "Ed25519 is not supported by PIV cards".to_string(), + )); + } + // `SignAlgorithm` is #[non_exhaustive]; reject anything PIV cannot do. + _ => { + return Err(BackendError::UnsupportedAlgorithm(format!( + "{algorithm:?} is not supported by PIV cards" + ))); + } + }; + let sig = piv::sign_data(yk, &hash, yk_algo, slot).map_err(map_error)?; + Ok(sig.to_vec()) +} + +// ============================================================================ +// YubiKey-specific operations +// ============================================================================ + +/// Read YubiKey-specific slot metadata (touch policy, PIN policy, key origin). +/// +/// Requires firmware 5.2.3 or later. +/// +/// # Errors +/// +/// Returns a `BackendError` on older firmware or if the metadata read fails. +pub fn yubikey_slot_metadata( + yk: &mut YubiKey, + slot: PivSlot, +) -> Result { + let slot_id = convert::to_yubikey_slot(slot)?; + let meta = piv::metadata(yk, slot_id).map_err(map_error)?; + let (pin_policy, touch_policy) = meta.policy.map_or( + (PivPinPolicy::Default, PivTouchPolicy::Default), + |(pin, touch)| { + ( + convert::from_yubikey_pin_policy(pin), + convert::from_yubikey_touch_policy(touch), + ) + }, + ); + let origin = meta + .origin + .map_or(PivKeyOrigin::Unknown, convert::from_yubikey_origin); + let public_key = meta.public.and_then(|spki| spki.to_der().ok()); + Ok(YubikeySlotMetadata { + pin_policy, + touch_policy, + origin, + public_key, + }) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use rite_sdk::BackendError; + + // --- slot_from_hint --- + + #[test] + fn slot_from_hint_bare_identifiers() { + assert_eq!(slot_from_hint("9a").unwrap(), PivSlot::Authentication); + assert_eq!(slot_from_hint("9c").unwrap(), PivSlot::Signature); + assert_eq!(slot_from_hint("9d").unwrap(), PivSlot::KeyManagement); + assert_eq!(slot_from_hint("9e").unwrap(), PivSlot::CardAuthentication); + } + + #[test] + fn slot_from_hint_piv_prefixed() { + assert_eq!(slot_from_hint("piv:9a").unwrap(), PivSlot::Authentication); + assert_eq!(slot_from_hint("piv:9c").unwrap(), PivSlot::Signature); + assert_eq!(slot_from_hint("piv:9d").unwrap(), PivSlot::KeyManagement); + assert_eq!( + slot_from_hint("piv:9e").unwrap(), + PivSlot::CardAuthentication + ); + } + + #[test] + fn slot_from_hint_retired_slots() { + // 0x82 is retired index 0, 0x95 is retired index 19. + assert_eq!(slot_from_hint("82").unwrap(), PivSlot::Retired(0)); + assert_eq!(slot_from_hint("95").unwrap(), PivSlot::Retired(19)); + assert_eq!(slot_from_hint("piv:82").unwrap(), PivSlot::Retired(0)); + assert_eq!(slot_from_hint("piv:8a").unwrap(), PivSlot::Retired(8)); + } + + #[test] + fn slot_from_hint_unknown_returns_configuration_error() { + // 0x9b is outside both the standard and retired ranges. + assert!(matches!( + slot_from_hint("9b").unwrap_err(), + BackendError::Configuration(_) + )); + assert!(matches!( + slot_from_hint("piv:ff").unwrap_err(), + BackendError::Configuration(_) + )); + assert!(matches!( + slot_from_hint("invalid").unwrap_err(), + BackendError::Configuration(_) + )); + } + + // --- slot_from_key_id --- + + #[test] + fn slot_from_key_id_parses_prefixed_and_bare() { + assert_eq!( + slot_from_key_id(&KeyId::new("piv:9a")).unwrap(), + PivSlot::Authentication + ); + assert_eq!( + slot_from_key_id(&KeyId::new("9c")).unwrap(), + PivSlot::Signature + ); + } + + #[test] + fn slot_from_key_id_unknown_returns_error() { + assert!(matches!( + slot_from_key_id(&KeyId::new("piv:xx")).unwrap_err(), + BackendError::Configuration(_) + )); + } + + // --- key_id_for_slot --- + + #[test] + fn key_id_for_slot_standard_slots() { + assert_eq!( + key_id_for_slot(SlotId::Authentication), + KeyId::new("piv:9a") + ); + assert_eq!(key_id_for_slot(SlotId::Signature), KeyId::new("piv:9c")); + assert_eq!(key_id_for_slot(SlotId::KeyManagement), KeyId::new("piv:9d")); + assert_eq!( + key_id_for_slot(SlotId::CardAuthentication), + KeyId::new("piv:9e") + ); + assert_eq!(key_id_for_slot(SlotId::Attestation), KeyId::new("piv:f9")); + } + + #[test] + fn key_id_for_slot_roundtrips_through_slot_from_hint() { + for slot in [ + SlotId::Authentication, + SlotId::Signature, + SlotId::KeyManagement, + SlotId::CardAuthentication, + ] { + let key_id = key_id_for_slot(slot); + let piv_slot = slot_from_key_id(&key_id).unwrap(); + assert_eq!(convert::to_yubikey_slot(piv_slot).unwrap(), slot); + } + } + + // --- map_error --- + + #[test] + fn map_error_not_found_is_hardware_failure() { + assert!(matches!( + map_error(yubikey::Error::NotFound), + BackendError::HardwareFailure(_) + )); + } + + #[test] + fn map_error_authentication_error_is_pin_required() { + assert!(matches!( + map_error(yubikey::Error::AuthenticationError), + BackendError::PinRequired + )); + } + + #[test] + fn map_error_wrong_pin_carries_retry_count() { + assert!(matches!( + map_error(yubikey::Error::WrongPin { tries: 3 }), + BackendError::PinFailed(3) + )); + assert!(matches!( + map_error(yubikey::Error::WrongPin { tries: 0 }), + BackendError::PinFailed(0) + )); + } + + #[test] + fn map_error_pin_locked_is_pin_blocked() { + assert!(matches!( + map_error(yubikey::Error::PinLocked), + BackendError::PinBlocked + )); + } + + #[test] + fn map_error_not_supported_is_unsupported_operation() { + assert!(matches!( + map_error(yubikey::Error::NotSupported), + BackendError::UnsupportedOperation(_) + )); + } + + #[test] + fn map_error_algorithm_error_is_unsupported_algorithm() { + assert!(matches!( + map_error(yubikey::Error::AlgorithmError), + BackendError::UnsupportedAlgorithm(_) + )); + } +} diff --git a/crates/rite-runtime/src/system_info.rs b/crates/rite-runtime/src/system_info.rs index f8fe109..3ecca93 100644 --- a/crates/rite-runtime/src/system_info.rs +++ b/crates/rite-runtime/src/system_info.rs @@ -117,8 +117,7 @@ pub struct Hardening { } /// A single security-feature determination: a machine-comparable status plus -/// optional human context. Replaces the free-form status strings the -/// `machine_info` action used to emit. +/// optional human context. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeatureCheck { /// Whether the feature is active. diff --git a/crates/rite-runtime/src/test_support.rs b/crates/rite-runtime/src/test_support.rs index 302dbeb..9c27601 100644 --- a/crates/rite-runtime/src/test_support.rs +++ b/crates/rite-runtime/src/test_support.rs @@ -12,7 +12,7 @@ use crossbeam_channel::{Receiver, Sender, unbounded}; use rite_model::StepId; use crate::clock::{Clock, SystemClock}; -use crate::protocol::{ExecEvent, UiCommand}; +use crate::protocol::{ExecEvent, PromptId, Response, UiCommand}; use crate::reporter::Reporter; use crate::transcript_sink::InMemorySink; use rite_model::StepFact; @@ -26,8 +26,9 @@ pub struct ReporterHarness { sink: InMemorySink, event_tx: Sender, _event_rx: Receiver, - _cmd_tx: Sender, + cmd_tx: Sender, cmd_rx: Receiver, + next_response_id: u64, } impl ReporterHarness { @@ -40,11 +41,33 @@ impl ReporterHarness { sink: InMemorySink::new(), event_tx, _event_rx: event_rx, - _cmd_tx: cmd_tx, + cmd_tx, cmd_rx, + next_response_id: 0, } } + /// Pre-queue a response to the next prompt the action under test will issue. + /// + /// Prompt ids start at 0 and increase by one per prompt, so queued + /// responses are matched in issue order: the first call answers the first + /// prompt, the second answers the second, and so on. The reporter reads + /// commands from an unbounded channel, so queuing before `execute` is + /// enough and no second thread is needed. + /// + /// Assumes a single reporter built from this harness (the id sequence is + /// not reset by [`Self::reporter`]); that matches every current test. + pub fn enqueue_response(&mut self, response: Response) { + let prompt_id = PromptId::new(self.next_response_id); + self.next_response_id = self.next_response_id.wrapping_add(1); + // The receiver is held by the harness for its lifetime, so this send + // cannot disconnect. + let _ = self.cmd_tx.send(UiCommand::PromptResponse { + prompt_id, + response, + }); + } + /// Build a reporter scoped to the given step. The reporter borrows /// the harness for its lifetime. /// diff --git a/crates/rite-stdlib/Cargo.toml b/crates/rite-stdlib/Cargo.toml index dfb1e2a..67384dd 100644 --- a/crates/rite-stdlib/Cargo.toml +++ b/crates/rite-stdlib/Cargo.toml @@ -19,6 +19,11 @@ crypto = [] pki = ["dep:x509-cert", "dep:der", "dep:sha1", "dep:rsa", "dep:p256"] openssl = ["dep:rite-openssl"] openssl-vendored = ["openssl", "rite-openssl/vendored"] +# Hardware smart-card backends. Opt-in: they pull in the `yubikey` crate, which +# links the PC/SC system library and so is excluded from the default build and +# the static musl artifacts. `yubikey` implies `piv` (it extends it). +piv = ["dep:rite-piv", "dep:secrecy"] +yubikey = ["dep:rite-yubikey", "piv"] full = ["verification", "attestation", "crypto", "pki", "openssl"] [dependencies] @@ -26,6 +31,8 @@ rite-sdk = { workspace = true } rite-model = { workspace = true } rite-runtime = { workspace = true } rite-openssl = { workspace = true, optional = true } +rite-piv = { workspace = true, optional = true } +rite-yubikey = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } @@ -39,6 +46,7 @@ der = { workspace = true, optional = true } sha1 = { workspace = true, optional = true } rsa = { workspace = true, optional = true } p256 = { workspace = true, optional = true } +secrecy = { workspace = true, optional = true } [dev-dependencies] rite-openssl = { workspace = true } diff --git a/crates/rite-stdlib/src/backend/mock.rs b/crates/rite-stdlib/src/backend/mock.rs index a0cb68e..f436417 100644 --- a/crates/rite-stdlib/src/backend/mock.rs +++ b/crates/rite-stdlib/src/backend/mock.rs @@ -31,8 +31,8 @@ use rite_sdk::{ use rite_openssl::OpenSslBackend; #[cfg(feature = "openssl")] use rite_sdk::{ - KeyMetadata, KeySpec, KeyStoreBackend, KeyTransportBackend, RandomBackend, SignAlgorithm, - SignBackend, WrapAlgorithm, WrappedKey, + KeyMetadata, KeyPolicy, KeySpec, KeyStoreBackend, KeyTransportBackend, RandomBackend, + SignAlgorithm, SignBackend, WrapAlgorithm, WrappedKey, }; /// Mock backend for testing and dry-run. @@ -45,6 +45,14 @@ pub struct MockBackend { /// Embedded software backend that performs the real cryptographic work. #[cfg(feature = "openssl")] crypto: OpenSslBackend, + /// Synthetic stand-in keys, keyed by the reference a ceremony signs with. + /// + /// A real card has keys provisioned before the ceremony (slot `9c`, say). + /// A rehearsal never generated them, so the first signature against an + /// unknown reference lazily mints a stand-in key of the matching algorithm + /// here, letting the whole ceremony be walked without hardware. + #[cfg(feature = "openssl")] + stand_in_keys: std::collections::BTreeMap, } impl std::fmt::Debug for MockBackend { @@ -70,10 +78,49 @@ impl MockBackend { Self { #[cfg(feature = "openssl")] crypto: OpenSslBackend::try_new(&name).expect("OpenSslBackend::try_new is infallible"), + #[cfg(feature = "openssl")] + stand_in_keys: std::collections::BTreeMap::new(), name, seed, } } + + /// Return the backing key for `key_id`, minting a synthetic stand-in of the + /// algorithm implied by `algorithm` the first time it is seen. + /// + /// Used by [`SignBackend::sign`] so a rehearsal can sign with a reference + /// (a PIV slot, say) whose key was provisioned outside the ceremony. + #[cfg(feature = "openssl")] + fn stand_in_key( + &mut self, + key_id: &KeyId, + algorithm: SignAlgorithm, + ) -> Result { + if let Some(backing) = self.stand_in_keys.get(key_id.as_str()) { + return Ok(backing.clone()); + } + let key_algorithm = match algorithm { + SignAlgorithm::EcdsaSha256 => KeyAlgorithm::EcdsaP256, + SignAlgorithm::EcdsaSha384 => KeyAlgorithm::EcdsaP384, + SignAlgorithm::RsaPkcs1Sha256 | SignAlgorithm::RsaPssSha256 => KeyAlgorithm::Rsa2048, + SignAlgorithm::Ed25519 => KeyAlgorithm::Ed25519, + // `SignAlgorithm` is #[non_exhaustive]; no stand-in for unknowns. + _ => { + return Err(BackendError::UnsupportedAlgorithm(format!( + "{algorithm:?} has no mock stand-in key" + ))); + } + }; + let meta = self.crypto.generate_key(KeySpec { + algorithm: key_algorithm, + label: format!("mock-stand-in:{key_id}"), + policy: KeyPolicy::default(), + location_hint: None, + })?; + self.stand_in_keys + .insert(key_id.as_str().to_string(), meta.key_id.clone()); + Ok(meta.key_id) + } } impl Backend for MockBackend { @@ -145,7 +192,16 @@ impl SignBackend for MockBackend { message: &[u8], algorithm: SignAlgorithm, ) -> Result, BackendError> { - self.crypto.sign(key_id, message, algorithm) + match self.crypto.sign(key_id, message, algorithm) { + // The reference points at a key the rehearsal never generated, such + // as a pre-provisioned hardware slot. Stand in a synthetic key so + // the signing step can still be walked end to end. + Err(BackendError::KeyNotFound(_)) => { + let backing = self.stand_in_key(key_id, algorithm)?; + self.crypto.sign(&backing, message, algorithm) + } + result => result, + } } fn verify( @@ -404,6 +460,26 @@ mod tests { assert_eq!(unwrapped.label, "unwrapped-key"); } + #[test] + fn sign_lazily_provisions_a_stand_in_for_unknown_references() { + // A slot-addressed reference (piv:9c) was never generated in the + // rehearsal; signing must still succeed via a synthetic stand-in. + let mut backend = MockBackend::new("token".to_string(), "seed".to_string()); + let slot = KeyId::new("piv:9c"); + + let sig = backend + .sign(&slot, b"release manifest", SignAlgorithm::EcdsaSha256) + .expect("stand-in signing succeeds"); + assert!(!sig.is_empty()); + + // A second signature reuses the same stand-in key. + let again = backend + .sign(&slot, b"another payload", SignAlgorithm::EcdsaSha256) + .expect("stand-in is reused"); + assert!(!again.is_empty()); + assert_eq!(backend.stand_in_keys.len(), 1); + } + #[test] fn list_keys_reflects_generated_keys() { let mut backend = MockBackend::new("test".to_string(), "seed".to_string()); diff --git a/crates/rite-stdlib/src/backend/mod.rs b/crates/rite-stdlib/src/backend/mod.rs index 9c35b0c..130dc6a 100644 --- a/crates/rite-stdlib/src/backend/mod.rs +++ b/crates/rite-stdlib/src/backend/mod.rs @@ -13,7 +13,9 @@ use rite_sdk::{BackendConfig, BackendError}; /// Create a backend by provider name and config. /// -/// Supports: `"mock"`, `"openssl"` (requires the `openssl` feature). +/// Supports: `"mock"`, `"openssl"` (requires the `openssl` feature), `"piv"` +/// (requires the `piv` feature), and `"yubikey"` (requires the `yubikey` +/// feature). pub fn create_backend( name: String, config: &BackendConfig, @@ -41,6 +43,32 @@ pub fn create_backend( )) } } + "piv" => { + #[cfg(feature = "piv")] + { + rite_piv::PivCardBackend::try_new(name, config) + .map(|b| Box::new(b) as Box) + } + #[cfg(not(feature = "piv"))] + { + Err(BackendError::Configuration( + "Backend 'piv' requires the 'piv' feature".to_string(), + )) + } + } + "yubikey" => { + #[cfg(feature = "yubikey")] + { + rite_yubikey::YubikeyDevice::try_new(name, config) + .map(|b| Box::new(b) as Box) + } + #[cfg(not(feature = "yubikey"))] + { + Err(BackendError::Configuration( + "Backend 'yubikey' requires the 'yubikey' feature".to_string(), + )) + } + } other => Err(BackendError::Configuration(format!( "Unknown backend provider '{other}'" ))), diff --git a/crates/rite-stdlib/src/lib.rs b/crates/rite-stdlib/src/lib.rs index 5eb0e26..9d20aa5 100644 --- a/crates/rite-stdlib/src/lib.rs +++ b/crates/rite-stdlib/src/lib.rs @@ -19,7 +19,9 @@ //! - `attestation`: attestation recording //! - `crypto`: crypto actions (`generate_keypair`, `export_public`, `wrap_key`, `unwrap_key`) //! - `pki`: PKI actions (`generate_csr`, `issue_certificate`; requires `x509-cert`, `der`, `sha1`, `rsa`, `p256`) -//! - `default`: all features enabled +//! - `piv`: PIV smart-card actions (`piv_read_certificate`, `piv_sign`; requires PC/SC) +//! - `yubikey`: `YubiKey` actions (`yubikey_attest_slot`; implies `piv`; requires PC/SC) +//! - `default`: all features enabled except the hardware backends (`piv`, `yubikey`) //! //! # Usage //! @@ -45,6 +47,8 @@ pub mod attestation; #[cfg(feature = "crypto")] pub mod crypto; pub mod entropy; +#[cfg(feature = "piv")] +pub mod piv; #[cfg(feature = "pki")] pub mod pki; #[cfg(feature = "verification")] @@ -61,6 +65,10 @@ pub use attestation::AttestAction; #[cfg(feature = "crypto")] pub use crypto::{ExportPublicAction, GenerateKeypairAction, UnwrapKeyAction, WrapKeyAction}; pub use entropy::GatherEntropyAction; +#[cfg(feature = "yubikey")] +pub use piv::YubikeyAttestSlotAction; +#[cfg(feature = "piv")] +pub use piv::{PivReadCertificateAction, PivSignAction}; #[cfg(feature = "pki")] pub use pki::{GenerateCsrAction, IssueCertificateAction}; #[cfg(feature = "verification")] @@ -111,6 +119,17 @@ pub fn register_stdlib(registry: &mut ActionRegistry) { registry.register(Arc::new(GenerateCsrAction)); registry.register(Arc::new(IssueCertificateAction)); } + + #[cfg(feature = "piv")] + { + registry.register(Arc::new(PivReadCertificateAction)); + registry.register(Arc::new(PivSignAction)); + } + + #[cfg(feature = "yubikey")] + { + registry.register(Arc::new(YubikeyAttestSlotAction)); + } } /// Build the default [`BackendFactory`] closure. diff --git a/crates/rite-stdlib/src/piv/attest.rs b/crates/rite-stdlib/src/piv/attest.rs new file mode 100644 index 0000000..8fcbd3c --- /dev/null +++ b/crates/rite-stdlib/src/piv/attest.rs @@ -0,0 +1,150 @@ +//! `yubikey_attest_slot` action: generate a `YubiKey` attestation certificate. + +use rite_model::{ActionType, StepFact}; +use rite_runtime::{ + Action, ActionCategory, ActionError, ActionMetadata, ArtifactValue, HandlerContext, Icon, + Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, +}; +use rite_sdk::Backend; +use serde_json::json; + +use super::params::AttestSlotParams; + +/// Generate a `YubiKey` attestation certificate for a PIV slot. +/// +/// Uses `YubiKey` slot F9 (the attestation key factory-provisioned by `Yubico`) +/// to sign the certificate of the key in the target slot, proving that key was +/// generated on-device and was never exported. +pub struct YubikeyAttestSlotAction; + +impl Action for YubikeyAttestSlotAction { + fn metadata(&self) -> ActionMetadata { + ActionMetadata { + action_type: ActionType::YubikeyAttestSlot, + description: "Generate YubiKey attestation certificate for a PIV slot", + category: ActionCategory::Crypto, + } + } + + fn execute( + &self, + step: &StepInfo, + _ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: AttestSlotParams = parse_params(params)?; + + if let Some(msg) = &typed.message { + reporter.log(Icon::Info, msg.clone())?; + } + reporter.log( + Icon::Spinner, + format!("Generating YubiKey attestation for slot {}...", typed.slot), + )?; + + // Validate the slot hint early so bad params fail before hardware access. + let piv_slot = rite_piv::ops::slot_from_hint(&typed.slot) + .map_err(|e| ActionError::Failed(format!("Invalid PIV slot: {e}")))?; + + let backend = backend.ok_or_else(|| { + ActionError::Failed("Backend required for yubikey_attest_slot action".into()) + })?; + let backend_name = backend.name().to_string(); + + let yubikey = backend.as_yubikey_mut().ok_or_else(|| { + ActionError::Failed(format!( + "Backend '{backend_name}' does not support YubiKey operations" + )) + })?; + + let cert_der = yubikey.attest_slot(piv_slot)?; + let cert_fingerprint = compute_fingerprint(&cert_der); + + reporter.log( + Icon::Checkmark, + format!( + "Attestation certificate generated ({} bytes)", + cert_der.len() + ), + )?; + + reporter.fact(StepFact::BackendOperation { + step: step.id.clone(), + kind: "yubikey_attest_slot".to_string(), + inputs: json!({ "slot": typed.slot }), + outputs: json!({ + "cert_fingerprint": cert_fingerprint, + "cert_size": cert_der.len(), + }), + fingerprint: Some(cert_fingerprint), + })?; + + if let Some(produces) = &step.produces { + reporter.log( + Icon::Info, + format!("Attestation certificate stored as artifact '{produces}'"), + )?; + Ok(StepResult::completed_with_artifact( + "YubiKey attestation certificate generated", + produces.clone(), + ArtifactValue::Bytes(cert_der), + )) + } else { + Ok(StepResult::completed( + "YubiKey attestation certificate generated", + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MockBackend; + use rite_model::{ArtifactId, StepFact, StepId}; + use rite_runtime::test_support::ReporterHarness; + use std::collections::HashMap; + + #[test] + fn attests_slot_and_emits_fact() { + let mut harness = ReporterHarness::new(); + let step = StepInfo::new( + StepId::new("attest"), + None, + Some("mock".to_string()), + Some(ArtifactId::new("attestation")), + None, + ); + let params = serde_json::json!({ "slot": "9c" }); + let artifacts = HashMap::new(); + let pmap = HashMap::new(); + let roles = HashMap::new(); + let materials = HashMap::new(); + let ctx = HandlerContext { + params: &pmap, + artifacts: &artifacts, + roles: &roles, + materials: &materials, + }; + let mut backend = MockBackend::new("token".to_string(), "seed".to_string()); + + let result = { + let mut reporter = harness.reporter(StepId::new("attest")); + YubikeyAttestSlotAction + .execute(&step, &ctx, ¶ms, &mut reporter, Some(&mut backend)) + .expect("attest succeeds against the mock") + }; + + assert_eq!(result.artifacts.len(), 1); + let (id, value) = result.artifacts.first().expect("one produced artifact"); + assert_eq!(id.as_str(), "attestation"); + assert!(matches!(value, ArtifactValue::Bytes(b) if b == b"MOCK_ATTESTATION_CERT_DER")); + + assert!(harness.facts().iter().any(|f| matches!( + f, + StepFact::BackendOperation { kind, .. } if kind == "yubikey_attest_slot" + ))); + } +} diff --git a/crates/rite-stdlib/src/piv/mod.rs b/crates/rite-stdlib/src/piv/mod.rs new file mode 100644 index 0000000..7b08581 --- /dev/null +++ b/crates/rite-stdlib/src/piv/mod.rs @@ -0,0 +1,17 @@ +//! PIV smart-card and `YubiKey` ceremony actions. +//! +//! These actions are backend-agnostic: they drive any backend that implements +//! the relevant `rite-sdk` capability traits (`CertStoreBackend`, `SignBackend`, +//! `PivBackend`, `YubikeyBackend`). Slot-hint parsing is shared from `rite-piv`. + +mod params; +mod read_certificate; +mod sign; + +pub use read_certificate::PivReadCertificateAction; +pub use sign::PivSignAction; + +#[cfg(feature = "yubikey")] +mod attest; +#[cfg(feature = "yubikey")] +pub use attest::YubikeyAttestSlotAction; diff --git a/crates/rite-stdlib/src/piv/params.rs b/crates/rite-stdlib/src/piv/params.rs new file mode 100644 index 0000000..127f009 --- /dev/null +++ b/crates/rite-stdlib/src/piv/params.rs @@ -0,0 +1,89 @@ +//! Parameter types for PIV and `YubiKey` ceremony actions. + +use serde::{Deserialize, Serialize}; + +/// Parameters for the `piv_read_certificate` action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PivReadCertificateParams { + /// PIV slot to read: `9a`, `9c`, `9d`, `9e`, or a retired slot key + /// reference in hex (`82`..`95`). + pub slot: String, + /// Optional display message. + #[serde(default)] + pub message: Option, +} + +/// Parameters for the `piv_sign` action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PivSignParams { + /// PIV slot containing the signing key (default: "9c"). + #[serde(default = "default_sign_slot")] + pub slot: String, + /// Signing algorithm: `ecdsa_sha256`, `ecdsa_sha384`, `rsa_pkcs1_sha256`, `rsa_pss_sha256`. + #[serde(default = "default_sign_algorithm")] + pub algorithm: String, + /// Optional display message. + #[serde(default)] + pub message: Option, +} + +fn default_sign_slot() -> String { + "9c".to_string() +} + +fn default_sign_algorithm() -> String { + "ecdsa_sha256".to_string() +} + +/// Parameters for the `yubikey_attest_slot` action. +#[cfg(feature = "yubikey")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttestSlotParams { + /// PIV slot to attest (e.g., `9a`, `9c`, `9d`, `9e`). + pub slot: String, + /// Custom message to display before the attestation step. + #[serde(default)] + pub message: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_certificate_params_deserializes() { + let json = serde_json::json!({ "slot": "9a" }); + let params: PivReadCertificateParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.slot, "9a"); + assert!(params.message.is_none()); + } + + #[test] + fn read_certificate_params_rejects_missing_slot() { + let json = serde_json::json!({}); + let result = serde_json::from_value::(json); + assert!(result.is_err()); + } + + #[test] + fn sign_params_defaults() { + let json = serde_json::json!({}); + let params: PivSignParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.slot, "9c"); + assert_eq!(params.algorithm, "ecdsa_sha256"); + assert!(params.message.is_none()); + } + + #[test] + fn sign_params_explicit_values() { + let json = serde_json::json!({ + "slot": "9d", + "algorithm": "rsa_pkcs1_sha256", + "message": "Sign the key" + }); + let params: PivSignParams = serde_json::from_value(json).unwrap(); + assert_eq!(params.slot, "9d"); + assert_eq!(params.algorithm, "rsa_pkcs1_sha256"); + assert_eq!(params.message.as_deref(), Some("Sign the key")); + } +} diff --git a/crates/rite-stdlib/src/piv/read_certificate.rs b/crates/rite-stdlib/src/piv/read_certificate.rs new file mode 100644 index 0000000..5193a6b --- /dev/null +++ b/crates/rite-stdlib/src/piv/read_certificate.rs @@ -0,0 +1,180 @@ +//! `piv_read_certificate` action: read an X.509 certificate from a PIV slot. + +use rite_model::{ActionType, StepFact}; +use rite_runtime::{ + Action, ActionCategory, ActionError, ActionMetadata, ArtifactValue, HandlerContext, Icon, + Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, +}; +use rite_sdk::{Backend, CertRef}; +use serde_json::json; + +use super::params::PivReadCertificateParams; + +/// Read an X.509 certificate from a PIV smart card slot. +// +// This is PIV-specific only in that it addresses the certificate by slot. If a +// generic "read certificate from a backend" action is later introduced (any +// `CertStoreBackend`, addressed by `CertRef`), this could collapse into a thin +// wrapper that maps a slot hint to `CertRef::PivSlot`. Worth reconciling at that +// point rather than growing two parallel cert-read paths. +pub struct PivReadCertificateAction; + +impl Action for PivReadCertificateAction { + fn metadata(&self) -> ActionMetadata { + ActionMetadata { + action_type: ActionType::PivReadCertificate, + description: "Read X.509 certificate from PIV smart card slot", + category: ActionCategory::Crypto, + } + } + + fn execute( + &self, + step: &StepInfo, + _ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: PivReadCertificateParams = parse_params(params)?; + + if let Some(msg) = &typed.message { + reporter.log(Icon::Info, msg.clone())?; + } + reporter.log( + Icon::Spinner, + format!("Reading certificate from PIV slot {}...", typed.slot), + )?; + + // Validate the slot early so bad params fail before hardware access. + let piv_slot = rite_piv::ops::slot_from_hint(&typed.slot) + .map_err(|e| ActionError::Failed(format!("Invalid PIV slot: {e}")))?; + + let backend = backend.ok_or_else(|| { + ActionError::Failed("Backend required to read PIV certificate".into()) + })?; + let backend_name = backend.name().to_string(); + + let certstore = backend.as_certstore_mut().ok_or_else(|| { + ActionError::Failed(format!( + "Backend '{backend_name}' does not support certificate operations" + )) + })?; + + let cert_der = certstore.read_cert(&CertRef::PivSlot(piv_slot))?; + let cert_fingerprint = compute_fingerprint(&cert_der); + + reporter.log( + Icon::Checkmark, + format!( + "Certificate read ({} bytes, fingerprint: {cert_fingerprint})", + cert_der.len() + ), + )?; + + reporter.fact(StepFact::BackendOperation { + step: step.id.clone(), + kind: "piv_read_certificate".to_string(), + inputs: json!({ "slot": typed.slot }), + outputs: json!({ + "cert_fingerprint": cert_fingerprint, + "cert_size": cert_der.len(), + }), + fingerprint: Some(cert_fingerprint), + })?; + + if let Some(produces) = &step.produces { + reporter.log( + Icon::Info, + format!("Certificate stored as artifact '{produces}'"), + )?; + Ok(StepResult::completed_with_artifact( + "Certificate read from PIV slot", + produces.clone(), + ArtifactValue::Bytes(cert_der), + )) + } else { + Ok(StepResult::completed("Certificate read from PIV slot")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MockBackend; + use rite_model::{ArtifactId, StepFact, StepId}; + use rite_runtime::test_support::ReporterHarness; + use std::collections::HashMap; + + #[test] + fn reads_certificate_and_emits_fact() { + let mut harness = ReporterHarness::new(); + let step = StepInfo::new( + StepId::new("read"), + None, + Some("mock".to_string()), + Some(ArtifactId::new("cert")), + None, + ); + let params = serde_json::json!({ "slot": "9c" }); + let artifacts = HashMap::new(); + let pmap = HashMap::new(); + let roles = HashMap::new(); + let materials = HashMap::new(); + let ctx = HandlerContext { + params: &pmap, + artifacts: &artifacts, + roles: &roles, + materials: &materials, + }; + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + + let result = { + let mut reporter = harness.reporter(StepId::new("read")); + PivReadCertificateAction + .execute(&step, &ctx, ¶ms, &mut reporter, Some(&mut backend)) + .expect("read succeeds against the mock") + }; + + assert_eq!(result.artifacts.len(), 1); + let (id, value) = result.artifacts.first().expect("one produced artifact"); + assert_eq!(id.as_str(), "cert"); + assert!(matches!(value, ArtifactValue::Bytes(b) if b == b"MOCK_CERTIFICATE_DER")); + + assert!(harness.facts().iter().any(|f| matches!( + f, + StepFact::BackendOperation { kind, .. } if kind == "piv_read_certificate" + ))); + } + + #[test] + fn rejects_an_unknown_slot() { + let mut harness = ReporterHarness::new(); + let step = StepInfo::new( + StepId::new("read"), + None, + Some("mock".to_string()), + None, + None, + ); + let params = serde_json::json!({ "slot": "zz" }); + let artifacts = HashMap::new(); + let pmap = HashMap::new(); + let roles = HashMap::new(); + let materials = HashMap::new(); + let ctx = HandlerContext { + params: &pmap, + artifacts: &artifacts, + roles: &roles, + materials: &materials, + }; + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + + let mut reporter = harness.reporter(StepId::new("read")); + let err = PivReadCertificateAction + .execute(&step, &ctx, ¶ms, &mut reporter, Some(&mut backend)) + .expect_err("an unknown slot must fail"); + assert!(err.to_string().contains("Invalid PIV slot")); + } +} diff --git a/crates/rite-stdlib/src/piv/sign.rs b/crates/rite-stdlib/src/piv/sign.rs new file mode 100644 index 0000000..0275db3 --- /dev/null +++ b/crates/rite-stdlib/src/piv/sign.rs @@ -0,0 +1,379 @@ +//! `piv_sign` action: sign data with a PIV smart card on-device key. + +use rite_model::{ActionType, Prompt, StepFact, StepInputs}; +use rite_runtime::{ + Action, ActionCategory, ActionError, ActionMetadata, ArtifactValue, HandlerContext, Icon, + Reporter, Response, StepInfo, StepResult, compute_fingerprint, parse_params, + resolve_artifact_bytes, +}; +use rite_sdk::{Backend, KeyId, SignAlgorithm}; +use secrecy::ExposeSecret; +use serde_json::json; + +use super::params::PivSignParams; + +/// Sign data using a PIV smart card on-device key. +pub struct PivSignAction; + +impl Action for PivSignAction { + fn metadata(&self) -> ActionMetadata { + ActionMetadata { + action_type: ActionType::PivSign, + description: "Sign data using PIV smart card on-device key", + category: ActionCategory::Crypto, + } + } + + fn execute( + &self, + step: &StepInfo, + ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: PivSignParams = parse_params(params)?; + + if let Some(msg) = &typed.message { + reporter.log(Icon::Info, msg.clone())?; + } + reporter.log( + Icon::Spinner, + format!( + "Signing with PIV slot {} using {}...", + typed.slot, typed.algorithm + ), + )?; + + // Validate slot and algorithm early. + rite_piv::ops::slot_from_hint(&typed.slot) + .map_err(|e| ActionError::Failed(format!("Invalid PIV slot: {e}")))?; + let sign_algorithm = parse_sign_algorithm(&typed.algorithm)?; + + // Resolve the single input artifact to sign. + let input_ref = step + .typed_inputs + .as_ref() + .and_then(StepInputs::as_single) + .ok_or_else(|| { + ActionError::Failed("piv_sign requires a single input artifact reference".into()) + })?; + reporter.log(Icon::Info, format!("Input: {}", input_ref.display_name()))?; + + let artifact_id = input_ref.artifact_id(); + let data = resolve_artifact_bytes(ctx.artifacts, &artifact_id, input_ref.property()) + .map_err(|e| { + ActionError::Failed(format!( + "input '{}' could not be resolved: {e}", + input_ref.display_name() + )) + })?; + + let backend = backend + .ok_or_else(|| ActionError::Failed("Backend required for PIV signing".into()))?; + let backend_name = backend.name().to_string(); + let backend_fingerprint = backend.fingerprint(); + + // PIN verification through the PIV capability. + { + let piv = backend.as_piv_mut().ok_or_else(|| { + ActionError::Failed(format!( + "Backend '{backend_name}' does not support PIV operations" + )) + })?; + + let retries = piv.pin_retries()?; + if retries <= 1 { + reporter.log( + Icon::Warning, + format!("Only {retries} PIN attempt(s) remaining. Card will lock on failure."), + )?; + } + + let response = reporter.prompt(&Prompt::Secret { + label: "Enter PIV PIN".to_string(), + })?; + let Response::Secret(pin) = response else { + return Err(ActionError::Failed( + "expected a secret response for the PIV PIN".to_string(), + )); + }; + + piv.verify_pin(pin.expose_secret().as_bytes())?; + reporter.log(Icon::Checkmark, "PIN verified")?; + } + + // Signing through the Sign capability. + let signature = { + let sign_backend = backend.as_sign_mut().ok_or_else(|| { + ActionError::Failed(format!("Backend '{backend_name}' does not support signing")) + })?; + let key_id = KeyId::new(format!("piv:{}", typed.slot)); + sign_backend.sign(&key_id, &data, sign_algorithm)? + }; + + let signature_fingerprint = compute_fingerprint(&signature); + reporter.log( + Icon::Checkmark, + format!("Signature produced ({} bytes)", signature.len()), + )?; + + reporter.fact(StepFact::BackendOperation { + step: step.id.clone(), + kind: "piv_sign".to_string(), + inputs: json!({ + "slot": typed.slot, + "algorithm": typed.algorithm, + "input_artifact": input_ref.display_name(), + }), + outputs: json!({ + "backend": backend_name, + "backend_fingerprint": backend_fingerprint, + "signature_len": signature.len(), + }), + fingerprint: Some(signature_fingerprint), + })?; + + if let Some(produces) = &step.produces { + reporter.log( + Icon::Info, + format!("Signature stored as artifact '{produces}'"), + )?; + Ok(StepResult::completed_with_artifact( + "PIV signature produced", + produces.clone(), + ArtifactValue::Bytes(signature), + )) + } else { + Ok(StepResult::completed("PIV signature produced")) + } + } +} + +/// Map a string algorithm name to a `SignAlgorithm` supported by PIV cards. +fn parse_sign_algorithm(s: &str) -> Result { + match s { + "ecdsa_sha256" => Ok(SignAlgorithm::EcdsaSha256), + "ecdsa_sha384" => Ok(SignAlgorithm::EcdsaSha384), + "rsa_pkcs1_sha256" => Ok(SignAlgorithm::RsaPkcs1Sha256), + "rsa_pss_sha256" => Ok(SignAlgorithm::RsaPssSha256), + other => Err(ActionError::Failed(format!( + "Unsupported signing algorithm: {other}. \ + Supported: ecdsa_sha256, ecdsa_sha384, rsa_pkcs1_sha256, rsa_pss_sha256" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_sign_algorithm_valid() { + assert_eq!( + parse_sign_algorithm("ecdsa_sha256").unwrap(), + SignAlgorithm::EcdsaSha256 + ); + assert_eq!( + parse_sign_algorithm("ecdsa_sha384").unwrap(), + SignAlgorithm::EcdsaSha384 + ); + assert_eq!( + parse_sign_algorithm("rsa_pkcs1_sha256").unwrap(), + SignAlgorithm::RsaPkcs1Sha256 + ); + assert_eq!( + parse_sign_algorithm("rsa_pss_sha256").unwrap(), + SignAlgorithm::RsaPssSha256 + ); + } + + #[test] + fn parse_sign_algorithm_invalid() { + let err = parse_sign_algorithm("ed25519").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("ed25519")); + assert!(msg.contains("Supported:")); + } + + // Signing needs the mock's embedded crypto (and its lazy stand-in key), + // which is only present with the `openssl` feature. + #[cfg(feature = "openssl")] + #[test] + fn signs_input_against_the_mock_and_records_pin_and_operation() { + use crate::MockBackend; + use rite_model::{ArtifactId, ArtifactRef, StepId}; + use rite_runtime::test_support::ReporterHarness; + use secrecy::SecretString; + use std::collections::HashMap; + + let mut harness = ReporterHarness::new(); + // Answer the single PIN prompt the action will issue (prompt id 0). + harness.enqueue_response(Response::Secret(SecretString::from("123456"))); + + let manifest = ArtifactId::new("manifest"); + let mut artifacts = HashMap::new(); + artifacts.insert( + manifest.clone(), + ArtifactValue::Bytes(b"release manifest".to_vec()), + ); + let pmap = HashMap::new(); + let roles = HashMap::new(); + let materials = HashMap::new(); + let ctx = HandlerContext { + params: &pmap, + artifacts: &artifacts, + roles: &roles, + materials: &materials, + }; + + let input = StepInputs::Single(ArtifactRef::Produced { + id: manifest, + property: None, + }); + let step = StepInfo::new( + StepId::new("sign"), + None, + Some("mock".to_string()), + Some(ArtifactId::new("signature")), + Some(input), + ); + let params = serde_json::json!({ "slot": "9c", "algorithm": "ecdsa_sha256" }); + let mut backend = MockBackend::new("token".to_string(), "seed".to_string()); + + let result = { + let mut reporter = harness.reporter(StepId::new("sign")); + PivSignAction + .execute(&step, &ctx, ¶ms, &mut reporter, Some(&mut backend)) + .expect("sign succeeds against the mock stand-in key") + }; + + assert_eq!(result.artifacts.len(), 1); + let (id, value) = result.artifacts.first().expect("one produced artifact"); + assert_eq!(id.as_str(), "signature"); + assert!(matches!(value, ArtifactValue::Bytes(b) if !b.is_empty())); + + // Both the PIN prompt and the signing operation are recorded. + assert!( + harness + .facts() + .iter() + .any(|f| matches!(f, StepFact::PromptAnswered { .. })) + ); + assert!(harness.facts().iter().any(|f| matches!( + f, + StepFact::BackendOperation { kind, .. } if kind == "piv_sign" + ))); + } + + // A backend signing error (an empty slot on real hardware) must surface as a + // failed step. Uses a minimal stub rather than the mock, whose lazy stand-in + // would mask the missing key during a rehearsal. + #[test] + fn surfaces_a_backend_signing_error() { + use rite_model::{ArtifactId, ArtifactRef, StepId}; + use rite_runtime::test_support::ReporterHarness; + use rite_sdk::{ + Backend, BackendError, KeyId, PivBackend, PivDeviceInfo, PivSlotInfo, SignAlgorithm, + SignBackend, + }; + use secrecy::SecretString; + use std::collections::HashMap; + + /// Authenticates fine, but the slot holds no key, so signing fails. + struct EmptySlotBackend; + + impl Backend for EmptySlotBackend { + fn name(&self) -> &'static str { + "empty" + } + fn provider(&self) -> &'static str { + "stub" + } + fn fingerprint(&self) -> String { + "stub".to_string() + } + rite_sdk::backend_capabilities!(as_piv_mut: PivBackend, as_sign_mut: SignBackend); + } + + impl PivBackend for EmptySlotBackend { + fn list_slots(&self) -> Result, BackendError> { + Ok(Vec::new()) + } + fn verify_pin(&mut self, _pin: &[u8]) -> Result<(), BackendError> { + Ok(()) + } + fn change_pin(&mut self, _current: &[u8], _new: &[u8]) -> Result<(), BackendError> { + Ok(()) + } + fn pin_retries(&mut self) -> Result { + Ok(3) + } + fn unblock_pin(&mut self, _puk: &[u8], _new: &[u8]) -> Result<(), BackendError> { + Ok(()) + } + fn device_info(&self) -> Result { + Ok(PivDeviceInfo { + serial: None, + firmware_version: None, + form_factor: None, + }) + } + } + + impl SignBackend for EmptySlotBackend { + fn sign( + &mut self, + key_id: &KeyId, + _message: &[u8], + _algorithm: SignAlgorithm, + ) -> Result, BackendError> { + Err(BackendError::KeyNotFound(key_id.to_string())) + } + fn verify( + &self, + _key_id: &KeyId, + _message: &[u8], + _signature: &[u8], + _algorithm: SignAlgorithm, + ) -> Result { + Ok(false) + } + } + + let mut harness = ReporterHarness::new(); + harness.enqueue_response(Response::Secret(SecretString::from("123456"))); + + let manifest = ArtifactId::new("manifest"); + let mut artifacts = HashMap::new(); + artifacts.insert(manifest.clone(), ArtifactValue::Bytes(b"data".to_vec())); + let pmap = HashMap::new(); + let roles = HashMap::new(); + let materials = HashMap::new(); + let ctx = HandlerContext { + params: &pmap, + artifacts: &artifacts, + roles: &roles, + materials: &materials, + }; + let input = StepInputs::Single(ArtifactRef::Produced { + id: manifest, + property: None, + }); + let step = StepInfo::new( + StepId::new("sign"), + None, + Some("stub".to_string()), + Some(ArtifactId::new("signature")), + Some(input), + ); + let params = serde_json::json!({ "slot": "9c", "algorithm": "ecdsa_sha256" }); + let mut backend = EmptySlotBackend; + + let mut reporter = harness.reporter(StepId::new("sign")); + let err = PivSignAction + .execute(&step, &ctx, ¶ms, &mut reporter, Some(&mut backend)) + .expect_err("an empty slot must fail signing"); + assert!(err.to_string().contains("Key not found")); + } +} diff --git a/crates/rite-yubikey/Cargo.toml b/crates/rite-yubikey/Cargo.toml new file mode 100644 index 0000000..7d7d533 --- /dev/null +++ b/crates/rite-yubikey/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rite-yubikey" +description = "YubiKey backend for Rite ceremonies, PIV with Yubico extensions (internal to the `rite` CLI; no stable API)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords = [] +categories = [] +publish = true + +[dependencies] +rite-sdk = { workspace = true } +rite-piv = { workspace = true } + +# The `untested` feature gates the on-device attestation call (slot F9), which +# is the YubiKey demo path. Yubico flags it untested in their CI, not unsafe. +yubikey = { workspace = true, features = ["untested"] } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/rite-yubikey/src/backend.rs b/crates/rite-yubikey/src/backend.rs new file mode 100644 index 0000000..08928e0 --- /dev/null +++ b/crates/rite-yubikey/src/backend.rs @@ -0,0 +1,262 @@ +//! `YubiKey` backend implementation. +//! +//! Provides PIV operations plus `Yubico` vendor extensions (touch/PIN policy +//! metadata, management key authentication, on-device attestation). The inner +//! `YubiKey` is held in a `Mutex` for the same reason as `PivCardBackend`: +//! PC/SC requires `&mut` even for reads, but some trait methods take `&self`. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use rite_piv::{convert, ops}; +use rite_sdk::{ + Attestation, AttestationBackend, AttestationKind, Backend, BackendConfig, BackendError, + CertRef, CertStoreBackend, KeyId, KeyMetadata, KeySpec, KeyStoreBackend, PivBackend, + PivDeviceInfo, PivSlot, PivSlotInfo, SignAlgorithm, SignBackend, YubikeyBackend, + YubikeySlotMetadata, +}; + +/// `YubiKey` backend with PIV + `Yubico` extensions. +/// +/// Wraps a `yubikey::YubiKey` connection to provide both standard PIV +/// operations (delegated to `rite_piv::ops`) and `Yubico`-specific operations +/// (attestation, slot metadata, management key authentication). +pub struct YubikeyDevice { + name: String, + yubikey: Mutex, +} + +impl YubikeyDevice { + /// Connect to a `YubiKey`. + /// + /// If the backend config carries a `serial` key, opens the device with that + /// serial number. Otherwise opens the first available `YubiKey`. + /// + /// # Errors + /// + /// Returns `BackendError::HardwareFailure` when no device is present, or + /// `BackendError::Configuration` when the serial number cannot be parsed. + pub fn try_new(name: String, config: &BackendConfig) -> Result { + let serial = config + .extra + .get("serial") + .and_then(serde_json::Value::as_str); + let yk = if let Some(serial_str) = serial { + let serial_n: u32 = serial_str.parse().map_err(|_| { + BackendError::Configuration(format!("Invalid serial number: {serial_str}")) + })?; + yubikey::YubiKey::open_by_serial(yubikey::Serial(serial_n)).map_err(ops::map_error)? + } else { + yubikey::YubiKey::open().map_err(ops::map_error)? + }; + Ok(Self { + name, + yubikey: Mutex::new(yk), + }) + } + + /// Lock the device, recovering the guard if a previous holder panicked. + /// + /// See `rite_piv::PivCardBackend` for the rationale: a poisoned PC/SC lock + /// is recovered so a single failed step does not wedge the backend. + fn device(&self) -> MutexGuard<'_, yubikey::YubiKey> { + self.yubikey.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl Backend for YubikeyDevice { + fn name(&self) -> &str { + &self.name + } + + fn provider(&self) -> &'static str { + "yubikey" + } + + fn fingerprint(&self) -> String { + let yk = self.device(); + format!("yubikey-serial={}+firmware={}", yk.serial(), yk.version()) + } + + rite_sdk::backend_capabilities!( + as_keystore_mut: KeyStoreBackend, + as_sign_mut: SignBackend, + as_attest_mut: AttestationBackend, + as_certstore_mut: CertStoreBackend, + as_piv_mut: PivBackend, + as_yubikey_mut: YubikeyBackend, + ); +} + +impl KeyStoreBackend for YubikeyDevice { + fn generate_key(&mut self, spec: KeySpec) -> Result { + let mut yk = self.device(); + ops::generate_key( + &mut yk, + spec.algorithm, + spec.label, + spec.location_hint.as_deref(), + ) + } + + fn import_private_key( + &mut self, + _spec: KeySpec, + _key_bytes: &[u8], + ) -> Result { + Err(BackendError::UnsupportedOperation( + "YubiKey key import requires the 'untested' yubikey feature".to_string(), + )) + } + + fn export_public_key(&self, key_id: &KeyId) -> Result, BackendError> { + let mut yk = self.device(); + ops::export_public_key(&mut yk, key_id) + } + + fn list_keys(&self) -> Result, BackendError> { + let mut yk = self.device(); + ops::list_keys(&mut yk) + } + + fn delete_key(&mut self, _key_id: &KeyId) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "PIV does not support key deletion".to_string(), + )) + } +} + +impl SignBackend for YubikeyDevice { + fn sign( + &mut self, + key_id: &KeyId, + message: &[u8], + algorithm: SignAlgorithm, + ) -> Result, BackendError> { + let mut yk = self.device(); + ops::sign(&mut yk, key_id, message, algorithm) + } + + fn verify( + &self, + _key_id: &KeyId, + _message: &[u8], + _signature: &[u8], + _algorithm: SignAlgorithm, + ) -> Result { + Err(BackendError::UnsupportedOperation( + "PIV cards are signing-only; call verify with the raw public key".to_string(), + )) + } +} + +impl AttestationBackend for YubikeyDevice { + fn attest_key(&self, key_id: &KeyId) -> Result { + let slot = ops::slot_from_key_id(key_id)?; + let cert_der = self.attest_slot(slot)?; + Ok(Attestation { + kind: AttestationKind::HardwareCertChain, + certificates: vec![cert_der], + signature: None, + metadata: serde_json::json!({ "slot": key_id.as_str() }), + }) + } +} + +impl CertStoreBackend for YubikeyDevice { + fn store_cert(&mut self, cert_ref: &CertRef, cert_der: &[u8]) -> Result<(), BackendError> { + match cert_ref { + CertRef::PivSlot(slot) => { + let mut yk = self.device(); + ops::write_certificate(&mut yk, *slot, cert_der) + } + // `CertRef` is #[non_exhaustive]; PIV addresses certificates by slot only. + _ => Err(BackendError::UnsupportedOperation( + "YubiKey backend only supports PivSlot certificate references".to_string(), + )), + } + } + + fn read_cert(&self, cert_ref: &CertRef) -> Result, BackendError> { + match cert_ref { + CertRef::PivSlot(slot) => { + let mut yk = self.device(); + ops::read_certificate(&mut yk, *slot) + } + // `CertRef` is #[non_exhaustive]; PIV addresses certificates by slot only. + _ => Err(BackendError::UnsupportedOperation( + "YubiKey backend only supports PivSlot certificate references".to_string(), + )), + } + } + + fn delete_cert(&mut self, _cert_ref: &CertRef) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "YubiKey does not support certificate deletion".to_string(), + )) + } +} + +impl PivBackend for YubikeyDevice { + fn list_slots(&self) -> Result, BackendError> { + let mut yk = self.device(); + ops::list_slots(&mut yk) + } + + fn verify_pin(&mut self, pin: &[u8]) -> Result<(), BackendError> { + let mut yk = self.device(); + yk.verify_pin(pin).map_err(ops::map_error) + } + + fn change_pin(&mut self, _current: &[u8], _new: &[u8]) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "Changing PIN requires the 'untested' yubikey feature".to_string(), + )) + } + + fn pin_retries(&mut self) -> Result { + let mut yk = self.device(); + ops::pin_retries(&mut yk) + } + + fn unblock_pin(&mut self, _puk: &[u8], _new_pin: &[u8]) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "PIN unblock requires the 'untested' yubikey feature".to_string(), + )) + } + + fn device_info(&self) -> Result { + let yk = self.device(); + Ok(ops::device_info(&yk)) + } +} + +impl YubikeyBackend for YubikeyDevice { + fn attest_slot(&self, slot: PivSlot) -> Result, BackendError> { + let yubikey_slot = convert::to_yubikey_slot(slot)?; + let mut yk = self.device(); + let cert = yubikey::piv::attest(&mut yk, yubikey_slot).map_err(ops::map_error)?; + Ok(cert.to_vec()) + } + + fn authenticate_management(&mut self, mgm_key: &[u8]) -> Result<(), BackendError> { + let mut yk = self.device(); + ops::authenticate_management(&mut yk, mgm_key) + } + + fn change_management_key(&mut self, _current: &[u8], _new: &[u8]) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "Changing the management key requires the 'untested' yubikey feature".to_string(), + )) + } + + fn slot_metadata(&self, slot: PivSlot) -> Result { + let mut yk = self.device(); + ops::yubikey_slot_metadata(&mut yk, slot) + } + + fn block_puk(&mut self) -> Result<(), BackendError> { + Err(BackendError::UnsupportedOperation( + "PUK block requires the 'untested' yubikey feature".to_string(), + )) + } +} diff --git a/crates/rite-yubikey/src/lib.rs b/crates/rite-yubikey/src/lib.rs new file mode 100644 index 0000000..802b475 --- /dev/null +++ b/crates/rite-yubikey/src/lib.rs @@ -0,0 +1,30 @@ +//! `YubiKey` backend for Rite ceremonies. +//! +//! Extends the standard PIV backend (`rite-piv`) with `Yubico`-specific +//! features: on-device attestation (slot F9), touch/PIN policy metadata, and +//! management key authentication. +//! +//! ## Capabilities +//! +//! - [`KeyStoreBackend`](rite_sdk::KeyStoreBackend): slot-based key generation +//! - [`SignBackend`](rite_sdk::SignBackend): on-card signing +//! - [`CertStoreBackend`](rite_sdk::CertStoreBackend): slot certificate storage +//! - [`PivBackend`](rite_sdk::PivBackend): slot management, PIN lifecycle +//! - [`YubikeyBackend`](rite_sdk::YubikeyBackend): attestation, touch policy, management key +//! - [`AttestationBackend`](rite_sdk::AttestationBackend): via `attest_slot` +//! +//! This crate is a pure backend: it depends only on `rite-sdk` and `rite-piv`. +//! The `yubikey_attest_slot` ceremony action that drives it lives in +//! `rite-stdlib`. +//! +//! # Stability +//! +//! Internal crate. This is an implementation detail of the `rite` CLI, with no +//! stable API and no semver guarantees across releases. Build against the +//! public `rite-sdk`, `rite-model`, or `rite-resolver` crates instead. + +#![warn(missing_docs)] + +mod backend; + +pub use backend::YubikeyDevice; diff --git a/crates/rite/Cargo.toml b/crates/rite/Cargo.toml index f01256d..44742fc 100644 --- a/crates/rite/Cargo.toml +++ b/crates/rite/Cargo.toml @@ -24,6 +24,10 @@ attestation = ["rite-stdlib/attestation"] crypto = ["rite-stdlib/crypto"] pki = ["rite-stdlib/pki"] openssl-vendored = ["rite-stdlib/openssl-vendored", "openssl"] +# Hardware smart-card backends. Opt-in only (PC/SC system dependency); kept out +# of `default` and the static musl release artifacts. +piv = ["rite-stdlib/piv"] +yubikey = ["rite-stdlib/yubikey"] tui = ["dep:rite-tui"] [dependencies] diff --git a/crates/rite/build.rs b/crates/rite/build.rs index 9fd709a..d50af59 100644 --- a/crates/rite/build.rs +++ b/crates/rite/build.rs @@ -42,9 +42,11 @@ fn features() -> String { ("CARGO_FEATURE_CRYPTO", "crypto"), ("CARGO_FEATURE_OPENSSL", "openssl"), ("CARGO_FEATURE_OPENSSL_VENDORED", "openssl-vendored"), + ("CARGO_FEATURE_PIV", "piv"), ("CARGO_FEATURE_PKI", "pki"), ("CARGO_FEATURE_RENDER", "render"), ("CARGO_FEATURE_VERIFICATION", "verification"), + ("CARGO_FEATURE_YUBIKEY", "yubikey"), ]; let mut enabled: Vec<&str> = known .iter() diff --git a/crates/rite/src/system_info.rs b/crates/rite/src/system_info.rs index 70711e6..daed92b 100644 --- a/crates/rite/src/system_info.rs +++ b/crates/rite/src/system_info.rs @@ -76,6 +76,10 @@ fn backends() -> Vec { source: Some(openssl_source().to_string()), }); } + // Only backends that expose a meaningful runtime library version are listed + // here (openssl reports the linked libcrypto version). The piv/yubikey + // backends reach hardware over PC/SC and have no such runtime version to + // report; their presence is already conveyed by the build feature list. backends } diff --git a/docker-bake.hcl b/docker-bake.hcl index 8b418b6..1a30fce 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -59,9 +59,14 @@ target "image" { # Provenance is attested in the release workflow via Sigstore (actions/attest), # the same mechanism used for the binaries, so no BuildKit in-registry # attestations are emitted here. + # + # The image uses the glibc `builder-image` stage (not the musl cross stages + # the binaries targets use) so the binary can dynamically link libpcsclite and + # carry the hardware backends. CARGO_BUILD_ARGS is overridable to drop them. args = { RITE_BUILD_COMMIT = RITE_BUILD_COMMIT RITE_BUILD_COMMIT_DATE = RITE_BUILD_COMMIT_DATE + CARGO_BUILD_ARGS = "--features piv,yubikey" } # output / tags injected by CI: `--set image.output=type=registry,push=true`, # tags via the bake file produced by docker/metadata-action. diff --git a/docs/development/crate-layout.md b/docs/development/crate-layout.md index 2232a1a..97e8fa6 100644 --- a/docs/development/crate-layout.md +++ b/docs/development/crate-layout.md @@ -7,6 +7,8 @@ flowchart TD tui[rite-tui
TEA frontend] stdlib[rite-stdlib
default action set] openssl[rite-openssl
backend impl] + piv[rite-piv
PIV backend impl] + yubikey[rite-yubikey
YubiKey backend impl] runtime[rite-runtime
protocol, executor, transcript] resolver[rite-resolver
YAML → IR, diagnostics] render[rite-render
document generation] @@ -22,7 +24,12 @@ flowchart TD tui --> runtime ls --> resolver stdlib --> runtime + stdlib -.piv feature.-> piv + stdlib -.yubikey feature.-> yubikey openssl --> sdk + piv --> sdk + yubikey --> sdk + yubikey --> piv runtime --> sdk runtime --> model render --> model @@ -35,8 +42,10 @@ flowchart TD | `rite-model` | DSL IR (`Ceremony`, `Step`, `Prompt`, …) and the durable transcript schema (`StepFact`, `ResponseRecord`, …). Carries no executor or channel types. | | `rite-resolver` | YAML resolution and lowering, diagnostics, parameter checks. | | `rite-runtime` | Channel protocol, executor, reporter, transcript sink, action trait and registry. | -| `rite-stdlib` | Default action set (verification, attestation, crypto, PKI). | +| `rite-stdlib` | All built-in actions: generic ones (verification, attestation, crypto, PKI) plus backend-specific ones behind features (`piv`/`yubikey`). | | `rite-openssl` | OpenSSL-backed `Backend` implementation. | +| `rite-piv` | PIV smart-card `Backend` implementation (`yubikey` crate over PC/SC). Opt-in via the `piv` feature; the `piv_*` actions live in `rite-stdlib`. | +| `rite-yubikey` | `YubiKey` backend: PIV plus Yubico on-device attestation. Opt-in via the `yubikey` feature; the `yubikey_attest_slot` action lives in `rite-stdlib`. | | `rite-tui` | TEA-based interactive frontend (ratatui + crossterm). | | `rite` | `rite` binary; hosts the console and headless drivers and wires every crate above together. | | `rite-render` | Document generation: ceremony scripts and post-ceremony reports (HTML/PDF). | @@ -46,8 +55,18 @@ flowchart TD - A third-party verifier only needs `rite-model` to parse a transcript; the executor and channel types stay in `rite-runtime` for that reason. -- `rite-sdk` is the only crate a new backend has to depend on. Adding a - backend must not pull in the executor or any frontend crate. +- `rite-sdk` is the backend boundary. A backend crate (`rite-openssl`, + `rite-piv`, `rite-yubikey`) depends only on `rite-sdk` — not the executor or + any frontend. This keeps a first-party in-process backend a faithful mirror + of a future out-of-process plugin, which will implement the same `rite-sdk` + traits over JSON-RPC. The constraint is about the **backend**, not its + actions: a backend crate carries no `Action` implementations. +- Actions live in `rite-stdlib`, never in a backend crate. Generic actions + dispatch through the `rite-sdk` capability traits and work with any backend; + backend-specific actions (`piv_sign`, `yubikey_attest_slot`) are feature-gated + there too. `rite-stdlib` is the integration layer and may depend on backend + crates (optionally, per feature) to register their actions and build them in + the factory. Plugin backends are a goal; pluggable actions are not. - Frontends depend on `rite-runtime` for the protocol vocabulary (`ExecEvent`, `UiCommand`) and on `rite-model` for the persisted types they render. They never reach into `rite-stdlib` or backend crates. diff --git a/docs/development/hardware-backends.md b/docs/development/hardware-backends.md new file mode 100644 index 0000000..67fce41 --- /dev/null +++ b/docs/development/hardware-backends.md @@ -0,0 +1,95 @@ +# Hardware backends (PIV / YubiKey) + +`rite` can drive PIV smart cards, including YubiKeys, so that signing and +attestation happen on a device the private key never leaves. The support ships +as two opt-in crates and a set of ceremony actions in `rite-stdlib`. + +## Crates and features + +| Crate | Provider string | Capabilities | +|----------------|-----------------|----------------------------------------------------------| +| `rite-piv` | `piv` | `KeyStore`, `Sign`, `CertStore`, `Piv` | +| `rite-yubikey` | `yubikey` | the above plus `Yubikey` (attestation) and `Attestation` | + +Both are pure backends: they depend only on `rite-sdk` and the vendor `yubikey` +crate. The ceremony actions that drive them live in `rite-stdlib` and reach the +device only through the `rite-sdk` capability traits. + +Build them in with the matching Cargo features on the `rite` binary: + +```sh +cargo build -p rite --features piv,yubikey +``` + +`yubikey` implies `piv`. Both are **off in the default feature set**: the +`yubikey` crate links the PC/SC system library (`libpcsclite` on Linux; built in +on macOS and Windows), so a plain `cargo build` and the statically linked musl +release artifacts leave them out. CI installs `libpcsclite-dev` for the +`--workspace` lint and test jobs. + +The prebuilt distributions enable them where the system can link PC/SC: + +| Distribution | Hardware backends | +|--------------|-------------------| +| macOS / Windows release binaries (and Homebrew) | yes (system PC/SC framework) | +| Docker image (`ghcr.io/rite-ly/rite`, glibc) | yes (`libpcsclite`) | +| Linux release tarballs (static musl) | no (PC/SC cannot static-link) | + +So only the static musl Linux tarballs are software-only; everything else ships +the backends. See [`docs/docker.md`](../docker.md) for the image build split. + +## Actions + +| Action | Feature | Backend capability used | +|------------------------|-----------|------------------------------------------| +| `piv_read_certificate` | `piv` | `CertStoreBackend::read_cert` | +| `piv_sign` | `piv` | `PivBackend` (PIN) + `SignBackend::sign` | +| `yubikey_attest_slot` | `yubikey` | `YubikeyBackend::attest_slot` | + +A ceremony selects a backend by provider string: + +```yaml +backends: + token: + provider: yubikey # or: piv +``` + +## Slots + +Slot identifiers in ceremony YAML accept the four standard PIV slots (`9a`, +`9c`, `9d`, `9e`), the retired key-management slots by their hex key reference +(`82`..`95`), and any of these with a `piv:` prefix. `rite-sdk`'s `PivSlot` +stores retired slots as a 0-based index (`0..=19`); `rite-piv` converts to and +from the `yubikey` crate's raw key references (`0x82..=0x95`). + +## Operations left unimplemented + +These return a clear `UnsupportedOperation` error rather than a silent no-op, +because they need the `yubikey` crate's `untested` write paths and are not part +of the initial scope: key import, change PIN, unblock PIN, change management +key, and block PUK. + +## Verification + +CI cannot touch a physical device, so: + +- **Unit tests** cover the pure logic (slot parsing, type conversion, error + mapping, signing-algorithm parsing) and each action's `execute` against the + `MockBackend`, using the `ReporterHarness` to assert the produced artifact and + the emitted `BackendOperation` fact. The `piv_sign` test answers the PIN + prompt via `ReporterHarness::enqueue_response`. These run in CI behind the + `piv`/`yubikey` features (`cargo test -p rite-stdlib --features piv,yubikey`). +- **Dry run** (`rite run --dry-run --frontend headless`) substitutes the mock + backend and walks the whole ceremony, including `piv_sign`: the mock lazily + mints a synthetic stand-in key for any slot reference it was never asked to + generate, so a rehearsal of a pre-provisioned-slot ceremony completes without + hardware. The signatures and attestation certificates are clearly synthetic. +- **Manual hardware check** with a real YubiKey, run before a release that + touches this code: + + 1. Provision a signing key and certificate in slot 9C + (`ykman piv keys generate`, `ykman piv certificates generate`). + 2. Run `examples/piv/yubikey_signing.rite.yaml` with the device inserted. + 3. Confirm `piv_sign` prompts for the PIN and produces a signature, and that + `yubikey_attest_slot` emits an attestation certificate that chains to the + Yubico attestation root. diff --git a/docs/docker.md b/docs/docker.md index 13c209a..4c05dee 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -2,14 +2,33 @@ ## Quick start -Build and run from source. The default image ships a musl-static `rite` -binary cross-compiled for the host's platform. +Build and run from source. The image ships a glibc `rite` binary built with the +`piv` and `yubikey` hardware backends, so it can drive a smart card as well as +the software backends. ```sh docker buildx build --load -t rite . docker run --rm -it --init -v "$PWD:/workspace" rite check ceremony.rite.yaml ``` +## Image vs. release tarballs + +The image and the downloadable Linux release tarballs are built differently and +serve different needs: + +| Artifact | Toolchain | OpenSSL | Hardware backends | +|---|---|---|---| +| Release tarballs (`rite-*-linux-*.tar.gz`) | musl, fully static | vendored | no (`piv`/`yubikey` cannot static-link PC/SC) | +| Docker image (`ghcr.io/rite-ly/rite`) | glibc | system (`libssl3`) | yes (`piv`, `yubikey`) | + +The tarballs stay static for portability across distros (Alpine, older glibc, +Remote SSH targets). The image runs on a known Debian base, so it links +`libpcsclite` and ships the smart-card backends. + +Driving a real card from the container additionally needs the reader passed +through (`--device`) or the host `pcscd` socket mounted; `check`, `--dry-run`, +and authoring work without any of that. + ## Build orchestration: `docker-bake.hcl` All multi-target builds are declared in [`docker-bake.hcl`](../docker-bake.hcl). @@ -33,34 +52,37 @@ docker buildx bake image # multi-arch runtime image (cache only loca | Bake target | Dockerfile stage | Output | |---|---|---| -| `binaries-amd64` | `binaries-amd64` | `dist/amd64/{rite, rite-ls}` | -| `binaries-arm64` | `binaries-arm64` | `dist/arm64/{rite, rite-ls}` | -| `image` | `release` | multi-arch runtime image (`linux/amd64,linux/arm64`) with SBOM + provenance attestations | - -## Cross-compilation - -Linux builds use [`ghcr.io/rust-cross/rust-musl-cross`](https://github.com/rust-cross/rust-musl-cross) -base images (one per target triple). Each image ships a pre-built musl cross -toolchain, Rust with `CARGO_BUILD_TARGET` pre-set, and the correct linker -config. No QEMU emulation when the build host architecture matches the image -(amd64). The Dockerfile dispatches between the two builder stages via -buildx's `TARGETARCH` so a single bake invocation produces the multi-arch -published image. - -The base images are linux/amd64. On amd64 build hosts (CI, most workstations) -they run natively. On arm64 build hosts (Apple Silicon) Docker uses QEMU -emulation for the C compile steps. - -All `rite` Linux binaries are musl-static (vendored OpenSSL, no libc runtime -dependency). glibc-linked builds are not supported. +| `binaries-amd64` | `binaries-amd64` | `dist/amd64/{rite, rite-ls}` (musl static) | +| `binaries-arm64` | `binaries-arm64` | `dist/arm64/{rite, rite-ls}` (musl static) | +| `image` | `release` | multi-arch runtime image (`linux/amd64,linux/arm64`), glibc + hardware backends, with SBOM + provenance attestations | + +## Build stages and toolchains + +The single `Dockerfile` carries two independent builder chains; a bake target +only triggers the stages it needs: + +- **musl chain** (`builder-amd64` / `builder-arm64`) feeds the `binaries-*` + targets. It uses [`ghcr.io/rust-cross/rust-musl-cross`](https://github.com/rust-cross/rust-musl-cross) + base images (one per target triple) with a pre-built musl cross toolchain and + `CARGO_BUILD_TARGET` pre-set, so a single amd64 host cross-compiles both + arches with no QEMU for the Rust step. Output is fully static (vendored + OpenSSL, no libc runtime dependency). +- **glibc chain** (`builder-image`) feeds the `image` target. It sets no + `--platform`, so buildx compiles it once per target platform: amd64 natively, + arm64 emulated via QEMU. It dynamically links system OpenSSL and `libpcsclite`, which is what + lets the image carry the `piv`/`yubikey` backends. The emulated arm64 compile + is the slow part of a release build. ## Custom Cargo features -The `CARGO_BUILD_ARGS` build arg flows through to all targets: +The `CARGO_BUILD_ARGS` build arg overrides the feature set per target. The +`binaries-*` targets default to `--features openssl-vendored`; the `image` +target defaults to `--features piv,yubikey`. To build the image without the +hardware backends: ```sh -docker buildx bake binaries-amd64 \ - --set "*.args.CARGO_BUILD_ARGS=--no-default-features --features openssl-vendored" +docker buildx bake image \ + --set "image.args.CARGO_BUILD_ARGS=--features openssl" ``` ## Hardened runtime flags diff --git a/examples/piv/README.md b/examples/piv/README.md new file mode 100644 index 0000000..c6ec3e3 --- /dev/null +++ b/examples/piv/README.md @@ -0,0 +1,63 @@ +# PIV / YubiKey Ceremonies + +Examples that drive a hardware PIV smart card (such as a YubiKey) where the +private key never leaves the device. + +These ceremonies require a `rite` binary with the hardware backends. The +prebuilt macOS and Windows release binaries (including `brew install rite`) and +the Docker image (`ghcr.io/rite-ly/rite`) already include them. Only the static +musl Linux tarballs leave them out, so on Linux from a tarball build from source: + +```sh +cargo build -p rite --features piv,yubikey +``` + +The `piv` and `yubikey` features link the PC/SC system library (`libpcsclite` +on Linux; built in on macOS and Windows) and are off in the default feature set. + +## Ceremonies + +### `yubikey_signing.rite.yaml` — Release Signing with YubiKey PIV + +Signs a release manifest with an on-device key in PIV slot 9C. Demonstrates: + +- Reading the signing certificate from the slot (`piv_read_certificate`) +- Proving the key was generated on-device, never imported + (`yubikey_attest_slot`, the Yubico F9 attestation) +- A PIN-protected detached signature over the manifest (`piv_sign`) +- Witness and operator attestation in the closing act + +The ceremony assumes slot 9C is already provisioned with the signing key and its +certificate. + +## Running + +Validate the ceremony (no hardware needed): + +```sh +cargo run -p rite --features piv,yubikey -- check examples/piv/yubikey_signing.rite.yaml +``` + +Execute with a real YubiKey inserted: + +```sh +cargo run -p rite --features piv,yubikey -- run examples/piv/yubikey_signing.rite.yaml +``` + +### Dry run + +`--dry-run` substitutes a mock backend so the ceremony can be rehearsed without a +device: + +```sh +cargo run -p rite --features piv,yubikey -- run --dry-run --frontend headless \ + examples/piv/yubikey_signing.rite.yaml +``` + +The dry run walks the whole ceremony, including `piv_sign`: the mock returns +placeholder certificates and lazily mints a synthetic stand-in signing key for +the slot, so the rehearsal completes without a device. The signatures and +attestation certificates it produces are clearly synthetic, never real evidence. +See [`docs/development/hardware-backends.md`](../../docs/development/hardware-backends.md) +for the manual hardware verification procedure (real on-card signing and +attestation-root verification). diff --git a/examples/piv/test_data/release_manifest.txt b/examples/piv/test_data/release_manifest.txt new file mode 100644 index 0000000..bd9d040 --- /dev/null +++ b/examples/piv/test_data/release_manifest.txt @@ -0,0 +1,9 @@ +rite release manifest +====================== + +artifact: rite-cli +version: 0.3.0 +sha256: 0000000000000000000000000000000000000000000000000000000000000000 + +This file stands in for the real artifact a ceremony would sign. The bytes +here are what `piv_sign` reads and signs with the on-device PIV key. diff --git a/examples/piv/yubikey_signing.rite.yaml b/examples/piv/yubikey_signing.rite.yaml new file mode 100644 index 0000000..d4bd7f5 --- /dev/null +++ b/examples/piv/yubikey_signing.rite.yaml @@ -0,0 +1,127 @@ +version: "0.2" +name: "Release Signing with YubiKey PIV" +description: | + Sign a release manifest with an on-device YubiKey PIV key (slot 9C). + The private key never leaves the device: the ceremony reads the signing + certificate from the slot, attests that the key was generated on-device + using the YubiKey F9 attestation key, and produces a detached signature + over the manifest. + +backends: + token: + provider: yubikey + +materials: + manifest: + type: digital + path: "test_data/release_manifest.txt" + description: "Release manifest to be signed." + +output: + signing_cert: + type: certificate + description: "Signing key certificate read from PIV slot 9C" + attestation_cert: + type: certificate + description: "YubiKey attestation certificate for slot 9C" + manifest_signature: + type: document + description: "Detached ECDSA signature over the release manifest" + +roles: + signer: + name: "Release Signer" + person: "Alice Smith" + witness: + person: "Bob Jones" + +acts: + - id: opening + name: "Opening" + description: "Verify the operator and the inserted token before signing." + - id: signing + name: "Signing" + description: "Read the certificate, attest the key, and sign the manifest." + - id: closing + name: "Closing" + +prerequisites: + - "A YubiKey is inserted and slot 9C holds the release signing key and its certificate" + +sections: + environment: + act: opening + name: "Environment Verification" + role: ${role.signer} + steps: + verify_time: + action: clock_check + with: + message: "Verify the system clock before recording timestamps" + confirm_token: + action: confirm + with: + message: "Confirm the correct YubiKey is inserted" + + read_and_attest: + act: signing + name: "Read Certificate and Attest Key" + role: ${role.signer} + steps: + read_signing_cert: + action: piv_read_certificate + backend: token + with: + slot: "9c" + message: "Read the signing certificate from slot 9C" + creates: signing_cert + description: "Read the X.509 certificate stored alongside the signing key." + attest_signing_key: + action: yubikey_attest_slot + backend: token + with: + slot: "9c" + creates: attestation_cert + description: "Prove the slot 9C key was generated on-device, never imported." + + sign: + act: signing + name: "Sign the Manifest" + role: ${role.signer} + steps: + sign_manifest: + action: piv_sign + backend: token + reads: "${artifact.manifest}" + with: + slot: "9c" + algorithm: ecdsa_sha256 + message: "Enter the PIV PIN to sign the release manifest" + creates: manifest_signature + description: "Produce a detached ECDSA-P256 signature over the manifest." + + attestation: + act: closing + name: "Witness Attestation" + steps: + witness_attest: + action: attest + role: ${role.witness} + with: + statement: "I witnessed the release manifest being signed with the YubiKey." + signer_attest: + action: attest + role: ${role.signer} + with: + statement: "Release manifest signed with the on-device PIV key in slot 9C." + +after: + archive: + type: archive_materials + role: signer + items: + - "Ceremony transcript" + - "Signing certificate (signing_cert.pem)" + - "Attestation certificate (attestation_cert.pem)" + - "Manifest signature (manifest_signature.bin)" + location: "Secure document archive"