Skip to content

feat!: sign and verify data as first-class ceremony steps - #166

Open
lomigmegard wants to merge 1 commit into
mainfrom
feat/sign-algorithm-representation
Open

feat!: sign and verify data as first-class ceremony steps#166
lomigmegard wants to merge 1 commit into
mainfrom
feat/sign-algorithm-representation

Conversation

@lomigmegard

Copy link
Copy Markdown
Contributor

Signing arbitrary data needed a smart card (piv_sign), and verifying a signature happened only as a side effect of issue_certificate checking a CSR. A ceremony that wanted to sign a release manifest with a software or HSM key, or to check a signature it was handed, had no step for it.

This adds sign_data and verify_signature, plus the groundwork they turned out to need.

Signature algorithms gain DSL names

SignAlgorithm was the only algorithm enum without Display/FromStr, so its DSL spelling came from serde's snake_case derive and a hand-rolled table in piv_sign. That put algorithm: ecdsa_sha256 next to algorithm: ECDSA-P256 in the same ceremony file. It now spells the same way as KeyAlgorithm and WrapAlgorithm, with a wire-contract test pinning the eight strings, and the ML-DSA parameter sets gain a DSL spelling for the first time.

SignAlgorithm::accepts_key replaces the compatibility guard each backend arm reinvented. key_algorithm() could not serve as that predicate: it is lossy for RSA, mapping both schemes to Rsa2048, so an RSA-4096 key failed its own equality check.

Software verification consolidates onto OpenSSL

It ran on three mechanisms at once: the rsa crate for RSA CSRs, p256 for ECDSA, and OpenSSL for ML-DSA, while OpenSSL produced all three signatures.

  • rite_openssl::verify_signature covers every family, and sign/verify share its primitives, so the backend has one signing path and one verification path rather than a match arm each.
  • rite-stdlib's signatures module is the seam actions go through, so swapping the provider behind it touches one file.
  • rsa and p256 leave the workspace. They remain only as transitive dependencies of yubikey, absent from default and musl builds, so the RUSTSEC-2023-0071 rationale is rewritten around where the crate now actually appears. pki and crypto gain openssl, which they verify through.

Verification is told which algorithm to use rather than inferring one from the key, so a CSR naming ECDSA while carrying an RSA key is refused instead of quietly verified as RSA. The CSR allowlist stays, folded into the same table as the identifiers Rite emits, so Rite cannot generate a CSR its own issue_certificate refuses.

Ed25519 and ECDSA-P384 were declared in both algorithm enums and implemented nowhere: generate_keypair accepted them and the OpenSSL backend then refused. Both now work end to end, with their RFC 5480 and RFC 8410 signature identifiers.

The actions

sign_data is the generic counterpart to piv_sign: any backend implementing SignBackend, key taken by artifact reference rather than device slot. The algorithm follows from the key, with an algorithm: override for the one case where a key does not determine it, an RSA key choosing between PKCS#1 v1.5 and PSS. An override the key cannot perform is refused up front rather than at the backend.

verify_signature needs no backend, which is the point. Verification takes only a public key, so the step works on evidence the ceremony did not produce: a signature made on a card that will never expose its key, or one that arrived with a document from outside. Naming a backend: delegates the check instead; doing so with a key that backend does not hold is an error rather than a silent fall back to software, which would misreport who checked the evidence. A failed check fails the step.

That made ActionType::requires_backend() too coarse, since an action can also accept a backend without needing one. It becomes backend_usage() -> BackendUsage with three states, so rite check no longer warns that a delegated verification "does not use a backend".

examples/showcase/sign_and_verify.rite.yaml signs and verifies a manifest, contrasting the two step shapes. Its assertion is that verification succeeded, never that the signature equals fixed bytes, since ML-DSA signing is hedged. The negative cases are integration tests.

Also

  • docs/development/cryptographic-dependencies.md states which library performs which class of work, so a contributor adding an algorithm has a rule to follow rather than a guess. The OpenSSL 3.5 requirement moves into CONTRIBUTING.md, where someone hits it.
  • ActionType::ALL ties the editor's action catalogue to the enum. Nothing did before, so a new action was invisible in completion while working perfectly at run time; the sync test caught gather_entropy, missing since it shipped.

BREAKING CHANGE: piv_sign takes ECDSA-SHA256, ECDSA-SHA384, and RSA-PKCS1-SHA256; the snake_case spellings are no longer accepted. rite_openssl::verify_ml_dsa_signature is replaced by verify_signature, which takes a SignAlgorithm. ActionType::requires_backend is replaced by ActionType::backend_usage, returning BackendUsage.

Signing arbitrary data needed a smart card (`piv_sign`), and verifying a
signature happened only as a side effect of `issue_certificate` checking a
CSR. A ceremony that wanted to sign a release manifest with a software or
HSM key, or to check a signature it was handed, had no step for it.

This adds `sign_data` and `verify_signature`, plus the groundwork they
turned out to need.

## Signature algorithms gain DSL names

`SignAlgorithm` was the only algorithm enum without `Display`/`FromStr`, so
its DSL spelling came from serde's snake_case derive and a hand-rolled table
in `piv_sign`. That put `algorithm: ecdsa_sha256` next to
`algorithm: ECDSA-P256` in the same ceremony file. It now spells the same way
as `KeyAlgorithm` and `WrapAlgorithm`, with a wire-contract test pinning the
eight strings, and the ML-DSA parameter sets gain a DSL spelling for the
first time.

`SignAlgorithm::accepts_key` replaces the compatibility guard each backend
arm reinvented. `key_algorithm()` could not serve as that predicate: it is
lossy for RSA, mapping both schemes to `Rsa2048`, so an RSA-4096 key failed
its own equality check.

## Software verification consolidates onto OpenSSL

It ran on three mechanisms at once: the `rsa` crate for RSA CSRs, `p256` for
ECDSA, and OpenSSL for ML-DSA, while OpenSSL produced all three signatures.

- `rite_openssl::verify_signature` covers every family, and `sign`/`verify`
  share its primitives, so the backend has one signing path and one
  verification path rather than a match arm each.
- `rite-stdlib`'s `signatures` module is the seam actions go through, so
  swapping the provider behind it touches one file.
- `rsa` and `p256` leave the workspace. They remain only as transitive
  dependencies of `yubikey`, absent from default and musl builds, so the
  RUSTSEC-2023-0071 rationale is rewritten around where the crate now
  actually appears. `pki` and `crypto` gain `openssl`, which they verify
  through.

Verification is told which algorithm to use rather than inferring one from
the key, so a CSR naming ECDSA while carrying an RSA key is refused instead
of quietly verified as RSA. The CSR allowlist stays, folded into the same
table as the identifiers Rite emits, so Rite cannot generate a CSR its own
`issue_certificate` refuses.

Ed25519 and ECDSA-P384 were declared in both algorithm enums and implemented
nowhere: `generate_keypair` accepted them and the OpenSSL backend then
refused. Both now work end to end, with their RFC 5480 and RFC 8410
signature identifiers.

## The actions

`sign_data` is the generic counterpart to `piv_sign`: any backend
implementing `SignBackend`, key taken by artifact reference rather than
device slot. The algorithm follows from the key, with an `algorithm:`
override for the one case where a key does not determine it, an RSA key
choosing between PKCS#1 v1.5 and PSS. An override the key cannot perform is
refused up front rather than at the backend.

`verify_signature` needs no backend, which is the point. Verification takes
only a public key, so the step works on evidence the ceremony did not
produce: a signature made on a card that will never expose its key, or one
that arrived with a document from outside. Naming a `backend:` delegates the
check instead; doing so with a key that backend does not hold is an error
rather than a silent fall back to software, which would misreport who
checked the evidence. A failed check fails the step.

That made `ActionType::requires_backend()` too coarse, since an action can
also *accept* a backend without needing one. It becomes
`backend_usage() -> BackendUsage` with three states, so `rite check` no
longer warns that a delegated verification "does not use a backend".

`examples/showcase/sign_and_verify.rite.yaml` signs and verifies a manifest,
contrasting the two step shapes. Its assertion is that verification
succeeded, never that the signature equals fixed bytes, since ML-DSA signing
is hedged. The negative cases are integration tests.

## Also

- `docs/development/cryptographic-dependencies.md` states which library
  performs which class of work, so a contributor adding an algorithm has a
  rule to follow rather than a guess. The OpenSSL 3.5 requirement moves into
  CONTRIBUTING.md, where someone hits it.
- `ActionType::ALL` ties the editor's action catalogue to the enum. Nothing
  did before, so a new action was invisible in completion while working
  perfectly at run time; the sync test caught `gather_entropy`, missing
  since it shipped.

BREAKING CHANGE: `piv_sign` takes `ECDSA-SHA256`, `ECDSA-SHA384`, and
`RSA-PKCS1-SHA256`; the snake_case spellings are no longer accepted.
`rite_openssl::verify_ml_dsa_signature` is replaced by `verify_signature`,
which takes a `SignAlgorithm`. `ActionType::requires_backend` is replaced by
`ActionType::backend_usage`, returning `BackendUsage`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant