Plan: JWS Determination Signing Infrastructure
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Shared signing crate ( |
Done (2026-03-28) |
2 |
Key generation xtask command ( |
Done (2026-03-28) |
3 |
|
Done (2026-03-28) |
4 |
|
Done (2026-03-28) |
5 |
Key rotation support (dual-key verification window) |
Done (2026-03-28) |
6 |
Unit and integration tests |
Done (2026-03-28) |
Epic: &32, &38
Branch: feature/determination-signing
MR: committed directly
Context
ADR-002 defines the black-box determination contract: program services return signed determination objects, and canopy-eligibility verifies signatures before accepting any determination.
The Determination struct and DeterminationSigner / DeterminationVerifier traits already exist as stubs in services/canopy-eligibility/src/determination.rs.
No actual cryptographic implementation exists yet.
The signing algorithm is ECDSA P-256 with detached JWS, the same algorithm CRAIG uses for JWS intake signing (CRAIG ADR-010).
Each program service holds a private key; canopy-eligibility holds all program service public keys in a verification registry.
Private keys are loaded from environment variables or mounted secrets — never committed to the repository.
This plan is infrastructure-only. It has no dependencies on other plans and can be built in parallel with everything else. Every program service plan (snap-eligibility, tanf-eligibility, medicaid-eligibility) depends on this plan being complete.
Scope
In scope:
-
canopy-signingshared crate: ECDSA P-256 key pair generation, JWS signing, JWS verification -
Integration with
DeterminationSignerandDeterminationVerifiertraits in canopy-eligibility -
Verification key registry: canopy-eligibility loads one public key per program service from configuration
-
Key rotation plan: dual-key window allowing old and new keys to coexist during rotation
-
cargo xtask gen-signing-keyscommand for developer key generation -
Environment variable convention for key loading
Out of scope:
-
HSM integration — may be revisited per ADR-004 if required by a future Pub 1075 audit finding
-
Automated key rotation orchestration — this plan defines the rotation protocol; automation is a future plan
-
Network transport security (TLS) — handled at the infrastructure layer, not the application layer
Design
Cryptographic Approach
Detached JWS (RFC 7515 Appendix F) with ECDSA P-256 (ES256).
The JWS payload is the canonical JSON serialization of the Determination struct with the signature field set to an empty string.
The detached signature is stored in the signature field of the determination object.
Canonical serialization: fields serialized in struct definition order via serde_json::to_vec.
This is deterministic because Determination uses named fields (not HashMap) and serde serializes struct fields in declaration order.
Key Format
-
Private keys: PKCS#8 PEM, loaded from
CANOPY_{PROGRAM}_SIGNING_KEYenvironment variable -
Public keys: SPKI PEM, loaded from
CANOPY_VERIFY_KEY_{PROGRAM}environment variable in canopy-eligibility -
During rotation,
CANOPY_VERIFY_KEY_{PROGRAM}_PREVholds the outgoing key
Crate Structure
crates/canopy-signing/
├── Cargo.toml
└── src/
├── lib.rs -- public API re-exports
├── keygen.rs -- ECDSA P-256 key pair generation
├── signer.rs -- JwsSigner: signs canonical payloads
└── verifier.rs -- JwsVerifier: verifies detached JWS signatures
Core Types
/// A loaded ECDSA P-256 signing key.
pub struct SigningKey {
inner: p256::ecdsa::SigningKey,
key_id: String,
}
impl SigningKey {
/// Load from PKCS#8 PEM string.
pub fn from_pem(pem: &str, key_id: impl Into<String>) -> Result<Self, SigningError>;
/// Sign a payload and return a detached JWS compact serialization.
pub fn sign_detached(&self, payload: &[u8]) -> Result<String, SigningError>;
}
/// A loaded ECDSA P-256 verification key.
pub struct VerifyingKey {
inner: p256::ecdsa::VerifyingKey,
key_id: String,
}
impl VerifyingKey {
/// Load from SPKI PEM string.
pub fn from_pem(pem: &str, key_id: impl Into<String>) -> Result<Self, SigningError>;
/// Verify a detached JWS signature against a payload.
pub fn verify_detached(&self, payload: &[u8], jws: &str) -> Result<bool, SigningError>;
}
/// Registry of verification keys, one or two per program (current + previous during rotation).
pub struct VerifyingKeyRegistry {
keys: HashMap<Program, Vec<VerifyingKey>>,
}
impl VerifyingKeyRegistry {
/// Load from environment variables.
/// Reads CANOPY_VERIFY_KEY_{PROGRAM} and optionally CANOPY_VERIFY_KEY_{PROGRAM}_PREV.
pub fn from_env() -> Result<Self, SigningError>;
/// Verify a determination signature against the program's registered keys.
/// Returns Ok(true) if any registered key for the program verifies the signature.
pub fn verify(&self, program: Program, payload: &[u8], jws: &str) -> Result<bool, SigningError>;
}
Detached JWS Structure
The JWS compact serialization has three parts: header.payload.signature.
For detached JWS, the payload portion is empty: header..signature.
JWS header:
{
"alg": "ES256",
"kid": "canopy-snap-2026-03",
"typ": "canopy-determination+jwt"
}
The kid (key ID) follows the convention canopy-{program}-{YYYY-MM} where the date is the key generation month.
Integration with Determination Traits
The existing traits in services/canopy-eligibility/src/determination.rs are implemented using canopy-signing:
/// Concrete signer used by program services.
pub struct EcdsaDeterminationSigner {
signing_key: canopy_signing::SigningKey,
}
impl DeterminationSigner for EcdsaDeterminationSigner {
fn sign(&self, determination: &Determination) -> Result<String, anyhow::Error> {
let mut d = determination.clone();
d.signature = String::new();
let payload = serde_json::to_vec(&d)?;
Ok(self.signing_key.sign_detached(&payload)?)
}
}
/// Concrete verifier used by canopy-eligibility.
pub struct EcdsaDeterminationVerifier {
registry: canopy_signing::VerifyingKeyRegistry,
}
impl DeterminationVerifier for EcdsaDeterminationVerifier {
fn verify(&self, determination: &Determination) -> Result<bool, anyhow::Error> {
let mut d = determination.clone();
let jws = std::mem::take(&mut d.signature);
let payload = serde_json::to_vec(&d)?;
Ok(self.registry.verify(d.program, &payload, &jws)?)
}
}
Key Rotation Protocol
Key rotation uses a dual-key window:
-
Generate: Run
cargo xtask gen-signing-keys --program snapto generate a new key pair. -
Deploy verifier first: Add the new public key as
CANOPY_VERIFY_KEY_SNAPand move the old public key toCANOPY_VERIFY_KEY_SNAP_PREVin canopy-eligibility. Redeploy canopy-eligibility. It now accepts signatures from both keys. -
Deploy signer: Update
CANOPY_SNAP_SIGNING_KEYin canopy-snap with the new private key. Redeploy canopy-snap. New determinations are signed with the new key. -
Remove old key: After all in-flight determinations signed with the old key have been processed (configurable window, default 24 hours), remove
CANOPY_VERIFY_KEY_SNAP_PREVfrom canopy-eligibility.
The dual-key window ensures zero-downtime rotation with no rejected determinations.
Steps
Step 1: Create canopy-signing Crate
Files: crates/canopy-signing/Cargo.toml, crates/canopy-signing/src/lib.rs, crates/canopy-signing/src/keygen.rs, crates/canopy-signing/src/signer.rs, crates/canopy-signing/src/verifier.rs
Create the crate with dependencies:
[dependencies]
p256 = { version = "0.13", features = ["ecdsa", "pem", "jwk"] }
base64 = "0.22"
serde_json = "1"
thiserror = "2"
canopy-reference = { path = "../canopy-reference" }
Key generation implementation using the p256 crate API:
// crates/canopy-signing/src/keygen.rs
use p256::ecdsa::SigningKey;
use p256::pkcs8::EncodePrivateKey;
use p256::elliptic_curve::sec1::ToEncodedPoint;
use p256::pkcs8::EncodePublicKey;
use crate::error::SigningError;
/// Generate an ECDSA P-256 key pair.
/// Returns (private_key_pem, public_key_pem).
pub fn generate_key_pair() -> Result<(String, String), SigningError> {
let signing_key = SigningKey::random(&mut rand::rngs::OsRng);
let private_pem = signing_key
.to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
.map_err(|e| SigningError::KeyGeneration(format!("failed to encode private key: {e}")))?;
let verifying_key = signing_key.verifying_key();
let public_pem = verifying_key
.to_public_key_pem(p256::pkcs8::LineEnding::LF)
.map_err(|e| SigningError::KeyGeneration(format!("failed to encode public key: {e}")))?;
Ok((private_pem.to_string(), public_pem))
}
/// Generate a key pair and return the key ID following the convention
/// `canopy-{program}-{YYYY-MM}`.
pub fn generate_key_pair_with_id(
program: &str,
) -> Result<(String, String, String), SigningError> {
let (private_pem, public_pem) = generate_key_pair()?;
let now = chrono::Utc::now();
let key_id = format!("canopy-{}-{}", program, now.format("%Y-%m"));
Ok((private_pem, public_pem, key_id))
}
Implement SigningKey::from_pem, SigningKey::sign_detached, VerifyingKey::from_pem, VerifyingKey::verify_detached.
The detached JWS implementation:
-
Construct JWS header JSON, base64url-encode it.
-
Base64url-encode the payload.
-
Compute ECDSA signature over
base64url(header).base64url(payload). -
Return
base64url(header)..base64url(signature)(payload portion empty for detached).
For verification, reconstruct the signing input from the header, the provided payload, and the signature from the JWS.
Error type:
// crates/canopy-signing/src/error.rs
#[derive(Debug, thiserror::Error)]
pub enum SigningError {
#[error("key generation failed: {0}")]
KeyGeneration(String),
#[error("key loading failed: {0}")]
KeyLoading(String),
#[error("signing failed: {0}")]
Signing(String),
#[error("verification failed: {0}")]
Verification(String),
#[error("invalid JWS format: {0}")]
InvalidJws(String),
#[error("base64 decoding failed: {0}")]
Base64(#[from] base64::DecodeError),
#[error("JSON serialization failed: {0}")]
Json(#[from] serde_json::Error),
#[error("no verification key registered for program: {0}")]
NoKeyForProgram(String),
}
Add VerifyingKeyRegistry with from_env() and verify() methods.
Unit tests: key generation round-trip, sign-then-verify, tampered payload rejection, wrong key rejection, dual-key verification.
Step 2: Key Generation Xtask
Files: xtask/src/cmd/gen_signing_keys.rs, xtask/src/cmd/mod.rs, xtask/src/main.rs
Add a gen-signing-keys subcommand:
#[derive(Parser)]
pub struct Args {
/// Program to generate keys for (snap, tanf, medicaid, chip, caps, wic)
#[arg(long)]
pub program: String,
/// Output directory for key files (default: .keys/)
#[arg(long, default_value = ".keys")]
pub output_dir: String,
}
The command generates a P-256 key pair, writes {program}-private.pem and {program}-public.pem to the output directory, and prints the environment variable names:
Generated ECDSA P-256 key pair for snap Private key: .keys/snap-private.pem → CANOPY_SNAP_SIGNING_KEY Public key: .keys/snap-public.pem → CANOPY_VERIFY_KEY_SNAP
Add .keys/ to .gitignore.
Implementation:
// xtask/src/cmd/gen_signing_keys.rs
use canopy_signing::keygen::generate_key_pair_with_id;
use clap::Parser;
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
pub struct Args {
#[arg(long)]
pub program: String,
#[arg(long, default_value = ".keys")]
pub output_dir: String,
}
pub fn run(args: Args) -> anyhow::Result<()> {
let (private_pem, public_pem, key_id) =
generate_key_pair_with_id(&args.program)?;
let dir = PathBuf::from(&args.output_dir);
fs::create_dir_all(&dir)?;
let private_path = dir.join(format!("{}-private.pem", args.program));
let public_path = dir.join(format!("{}-public.pem", args.program));
fs::write(&private_path, &private_pem)?;
fs::write(&public_path, &public_pem)?;
let program_upper = args.program.to_uppercase();
println!("Generated ECDSA P-256 key pair for {}", args.program);
println!(" Key ID: {key_id}");
println!(
" Private key: {} → CANOPY_{}_SIGNING_KEY",
private_path.display(),
program_upper
);
println!(
" Public key: {} → CANOPY_VERIFY_KEY_{}",
public_path.display(),
program_upper
);
Ok(())
}
Error handling: file write failures produce a clear error via anyhow.
If the output directory cannot be created (permissions), the error message includes the path.
Step 3: DeterminationSigner Implementation
Files: services/canopy-eligibility/src/determination.rs, services/canopy-eligibility/Cargo.toml
Implement EcdsaDeterminationSigner as shown in the Design section.
Add canopy-signing dependency to canopy-eligibility’s Cargo.toml.
This implementation will also be used by program services.
Since determination.rs is in canopy-eligibility (which is a library dependency via pub mod determination), program services depend on canopy-eligibility for the Determination struct and can use EcdsaDeterminationSigner directly.
Alternatively, move the Determination struct and signer into canopy-signing to avoid program services depending on canopy-eligibility.
Decision: keep Determination in canopy-eligibility (it is the domain owner) but put EcdsaDeterminationSigner in canopy-signing with an optional feature flag determination that brings in the canopy-eligibility dependency.
Full EcdsaDeterminationSigner implementation:
// services/canopy-eligibility/src/determination.rs (additions)
use canopy_signing::SigningKey;
pub struct EcdsaDeterminationSigner {
signing_key: SigningKey,
}
impl EcdsaDeterminationSigner {
/// Create from a PEM-encoded private key loaded from the environment.
/// Reads CANOPY_{PROGRAM}_SIGNING_KEY.
pub fn from_env(program: &str) -> Result<Self, anyhow::Error> {
let env_var = format!("CANOPY_{}_SIGNING_KEY", program.to_uppercase());
let pem = std::env::var(&env_var)
.with_context(|| format!("{env_var} not set"))?;
let now = chrono::Utc::now();
let key_id = format!("canopy-{}-{}", program, now.format("%Y-%m"));
let signing_key = SigningKey::from_pem(&pem, key_id)?;
Ok(Self { signing_key })
}
}
impl DeterminationSigner for EcdsaDeterminationSigner {
fn sign(&self, determination: &Determination) -> Result<String, anyhow::Error> {
let mut d = determination.clone();
d.signature = String::new();
let payload = serde_json::to_vec(&d)?;
Ok(self.signing_key.sign_detached(&payload)?)
}
}
Error handling: if CANOPY_{PROGRAM}_SIGNING_KEY is not set or contains invalid PEM, from_env fails with a descriptive anyhow::Error.
The service should fail to start rather than running without signing capability.
Step 4: DeterminationVerifier with Key Registry
Files: services/canopy-eligibility/src/determination.rs, services/canopy-eligibility/src/main.rs
Implement EcdsaDeterminationVerifier using VerifyingKeyRegistry.
Wire the registry into canopy-eligibility’s startup:
// services/canopy-eligibility/src/main.rs (startup additions)
let registry = canopy_signing::VerifyingKeyRegistry::from_env()
.context("failed to load verification key registry")?;
let verifier = EcdsaDeterminationVerifier { registry };
Full verifier implementation:
// services/canopy-eligibility/src/determination.rs (additions)
use canopy_signing::VerifyingKeyRegistry;
pub struct EcdsaDeterminationVerifier {
pub registry: VerifyingKeyRegistry,
}
impl DeterminationVerifier for EcdsaDeterminationVerifier {
fn verify(&self, determination: &Determination) -> Result<bool, anyhow::Error> {
if determination.signature.is_empty() {
return Ok(false);
}
let mut d = determination.clone();
let jws = std::mem::take(&mut d.signature);
let payload = serde_json::to_vec(&d)?;
Ok(self.registry.verify(d.program, &payload, &jws)?)
}
}
Add the verifier to AppState or as an Axum Extension so the orchestrator can access it in request handlers.
Error handling:
-
If no verification key is registered for a given program,
registry.verify()returnsErr(SigningError::NoKeyForProgram(_)). The orchestrator should treat this as a configuration error and log aterrorlevel. -
If the JWS string is malformed (wrong number of segments, invalid base64),
verify_detachedreturnsErr(SigningError::InvalidJws(_)). -
Signature mismatch (valid format but wrong content) returns
Ok(false), not an error.
Step 5: Key Rotation Support
Files: crates/canopy-signing/src/verifier.rs
The VerifyingKeyRegistry::from_env() method already loads _PREV keys.
Add a rotation_status() method that reports which programs have dual keys active:
pub fn rotation_status(&self) -> Vec<(Program, RotationState)> {
self.keys
.iter()
.map(|(program, keys)| {
let state = if keys.len() > 1 {
RotationState::DualKeyRotation
} else {
RotationState::SingleKey
};
(*program, state)
})
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum RotationState {
SingleKey,
DualKeyRotation,
}
Add a health check endpoint in canopy-eligibility that reports rotation status so operators can confirm rotation is safe to finalize:
// services/canopy-eligibility/src/api/health.rs
/// GET /internal/signing-status
/// Returns the rotation state for each program's verification keys.
pub async fn signing_status(
State(state): State<EligibilityState>,
) -> Json<Vec<ProgramRotationStatus>> {
let statuses = state.verifier.registry.rotation_status();
let response: Vec<ProgramRotationStatus> = statuses
.into_iter()
.map(|(program, state)| ProgramRotationStatus {
program: program.to_string(),
rotation_state: state,
})
.collect();
Json(response)
}
#[derive(serde::Serialize)]
pub struct ProgramRotationStatus {
pub program: String,
pub rotation_state: RotationState,
}
JSON response example:
[
{ "program": "snap", "rotation_state": "DualKeyRotation" },
{ "program": "tanf", "rotation_state": "SingleKey" }
]
Step 6: Tests
Files: crates/canopy-signing/src/lib.rs (unit tests), services/canopy-eligibility/tests/signing.rs (integration test)
Unit tests in canopy-signing:
// crates/canopy-signing/src/lib.rs
#[cfg(test)]
mod tests {
use super::*;
use crate::keygen::generate_key_pair;
/// Verify that generate_key_pair produces valid PEM strings that
/// can be loaded back into SigningKey and VerifyingKey.
#[test]
fn generate_key_pair_produces_valid_pem() {
let (private_pem, public_pem) = generate_key_pair().unwrap();
assert!(private_pem.starts_with("-----BEGIN PRIVATE KEY-----"));
assert!(public_pem.starts_with("-----BEGIN PUBLIC KEY-----"));
let sk = SigningKey::from_pem(&private_pem, "test-key").unwrap();
let vk = VerifyingKey::from_pem(&public_pem, "test-key").unwrap();
// Round-trip: sign something, verify it
let payload = b"test payload";
let jws = sk.sign_detached(payload).unwrap();
assert!(vk.verify_detached(payload, &jws).unwrap());
}
/// Happy path: sign a payload and verify the signature.
#[test]
fn sign_then_verify() {
let (private_pem, public_pem) = generate_key_pair().unwrap();
let sk = SigningKey::from_pem(&private_pem, "test-key").unwrap();
let vk = VerifyingKey::from_pem(&public_pem, "test-key").unwrap();
let payload = br#"{"program":"snap","status":"approved"}"#;
let jws = sk.sign_detached(payload).unwrap();
assert!(vk.verify_detached(payload, &jws).unwrap());
}
/// Tampered payload must fail verification.
#[test]
fn tampered_payload_fails_verification() {
let (private_pem, public_pem) = generate_key_pair().unwrap();
let sk = SigningKey::from_pem(&private_pem, "test-key").unwrap();
let vk = VerifyingKey::from_pem(&public_pem, "test-key").unwrap();
let payload = br#"{"program":"snap","status":"approved"}"#;
let jws = sk.sign_detached(payload).unwrap();
let tampered = br#"{"program":"snap","status":"denied"}"#;
assert!(!vk.verify_detached(tampered, &jws).unwrap());
}
/// Signature from key A must not verify with key B.
#[test]
fn wrong_key_fails_verification() {
let (private_a, _public_a) = generate_key_pair().unwrap();
let (_private_b, public_b) = generate_key_pair().unwrap();
let sk_a = SigningKey::from_pem(&private_a, "key-a").unwrap();
let vk_b = VerifyingKey::from_pem(&public_b, "key-b").unwrap();
let payload = b"test";
let jws = sk_a.sign_detached(payload).unwrap();
assert!(!vk_b.verify_detached(payload, &jws).unwrap());
}
/// Registry with two keys (current + previous) verifies signatures
/// from both keys during a rotation window.
#[test]
fn dual_key_registry_verifies_both_keys() {
let (priv_old, pub_old) = generate_key_pair().unwrap();
let (priv_new, pub_new) = generate_key_pair().unwrap();
let sk_old = SigningKey::from_pem(&priv_old, "snap-old").unwrap();
let sk_new = SigningKey::from_pem(&priv_new, "snap-new").unwrap();
let vk_old = VerifyingKey::from_pem(&pub_old, "snap-old").unwrap();
let vk_new = VerifyingKey::from_pem(&pub_new, "snap-new").unwrap();
let mut registry = VerifyingKeyRegistry::empty();
registry.add_keys(Program::Snap, vec![vk_new, vk_old]);
let payload = b"determination payload";
let jws_old = sk_old.sign_detached(payload).unwrap();
let jws_new = sk_new.sign_detached(payload).unwrap();
assert!(registry.verify(Program::Snap, payload, &jws_old).unwrap());
assert!(registry.verify(Program::Snap, payload, &jws_new).unwrap());
}
/// Registry with no keys for a program returns an error.
#[test]
fn empty_registry_rejects() {
let registry = VerifyingKeyRegistry::empty();
let payload = b"test";
let result = registry.verify(Program::Snap, payload, "header..sig");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
SigningError::NoKeyForProgram(_)
));
}
}
Integration test in canopy-eligibility:
// services/canopy-eligibility/tests/signing.rs
use canopy_eligibility::determination::{
Determination, DeterminationSigner, DeterminationVerifier,
EcdsaDeterminationSigner, EcdsaDeterminationVerifier,
};
use canopy_signing::keygen::generate_key_pair;
use canopy_signing::{SigningKey, VerifyingKey, VerifyingKeyRegistry};
/// End-to-end: generate key, construct determination, sign, verify.
#[test]
fn sign_and_verify_determination() {
let (private_pem, public_pem) = generate_key_pair().unwrap();
let signing_key = SigningKey::from_pem(&private_pem, "snap-test").unwrap();
let verifying_key = VerifyingKey::from_pem(&public_pem, "snap-test").unwrap();
let signer = EcdsaDeterminationSigner { signing_key };
let mut registry = VerifyingKeyRegistry::empty();
registry.add_keys(Program::Snap, vec![verifying_key]);
let verifier = EcdsaDeterminationVerifier { registry };
let mut determination = test_determination();
determination.signature = signer.sign(&determination).unwrap();
assert!(verifier.verify(&determination).unwrap());
}
/// Modify the determination after signing — verification must fail.
#[test]
fn tampered_determination_fails_verification() {
let (private_pem, public_pem) = generate_key_pair().unwrap();
let signing_key = SigningKey::from_pem(&private_pem, "snap-test").unwrap();
let verifying_key = VerifyingKey::from_pem(&public_pem, "snap-test").unwrap();
let signer = EcdsaDeterminationSigner { signing_key };
let mut registry = VerifyingKeyRegistry::empty();
registry.add_keys(Program::Snap, vec![verifying_key]);
let verifier = EcdsaDeterminationVerifier { registry };
let mut determination = test_determination();
determination.signature = signer.sign(&determination).unwrap();
// Tamper: change benefit amount after signing
determination.benefit_amount = Some(Decimal::new(99999, 2));
assert!(!verifier.verify(&determination).unwrap());
}
/// Sign with key A, verify with registry containing only key B — must fail.
#[test]
fn wrong_key_determination_fails() {
let (private_a, _public_a) = generate_key_pair().unwrap();
let (_private_b, public_b) = generate_key_pair().unwrap();
let signing_key = SigningKey::from_pem(&private_a, "snap-a").unwrap();
let verifying_key_b = VerifyingKey::from_pem(&public_b, "snap-b").unwrap();
let signer = EcdsaDeterminationSigner { signing_key };
let mut registry = VerifyingKeyRegistry::empty();
registry.add_keys(Program::Snap, vec![verifying_key_b]);
let verifier = EcdsaDeterminationVerifier { registry };
let mut determination = test_determination();
determination.signature = signer.sign(&determination).unwrap();
assert!(!verifier.verify(&determination).unwrap());
}
/// Sign with old key, verify with registry containing old (as _PREV) and new — succeeds.
#[test]
fn rotation_window_verification() {
let (priv_old, pub_old) = generate_key_pair().unwrap();
let (_priv_new, pub_new) = generate_key_pair().unwrap();
let signing_key = SigningKey::from_pem(&priv_old, "snap-old").unwrap();
let vk_old = VerifyingKey::from_pem(&pub_old, "snap-old").unwrap();
let vk_new = VerifyingKey::from_pem(&pub_new, "snap-new").unwrap();
let signer = EcdsaDeterminationSigner { signing_key };
let mut registry = VerifyingKeyRegistry::empty();
registry.add_keys(Program::Snap, vec![vk_new, vk_old]);
let verifier = EcdsaDeterminationVerifier { registry };
let mut determination = test_determination();
determination.signature = signer.sign(&determination).unwrap();
// Old key signature verifies because old key is in registry as _PREV
assert!(verifier.verify(&determination).unwrap());
}
/// An unsigned determination (empty signature) must fail verification.
#[test]
fn unsigned_determination_fails() {
let (_private, public_pem) = generate_key_pair().unwrap();
let vk = VerifyingKey::from_pem(&public_pem, "snap-test").unwrap();
let mut registry = VerifyingKeyRegistry::empty();
registry.add_keys(Program::Snap, vec![vk]);
let verifier = EcdsaDeterminationVerifier { registry };
let determination = test_determination(); // signature is empty string
assert!(!verifier.verify(&determination).unwrap());
}
fn test_determination() -> Determination {
Determination {
id: Uuid::new_v4(),
program: Program::Snap,
application_id: Uuid::new_v4(),
household_id: Uuid::new_v4(),
status: "approved".to_string(),
benefit_amount: Some(Decimal::new(84700, 2)),
benefit_unit: Some("monthly_usd".to_string()),
effective_date: Some(NaiveDate::from_ymd_opt(2026, 3, 26).unwrap()),
expiration_date: Some(NaiveDate::from_ymd_opt(2026, 9, 26).unwrap()),
renewal_date: Some(NaiveDate::from_ymd_opt(2026, 8, 26).unwrap()),
basis: Some("Eligible per gross and net income tests".to_string()),
signature: String::new(),
program_service_version: "0.1.0".to_string(),
determined_at: Utc::now(),
}
}
Files Touched
| File | Change |
|---|---|
|
New: crate manifest with p256, base64, serde_json, thiserror |
|
New: public API, re-exports |
|
New: ECDSA P-256 key pair generation |
|
New: |
|
New: |
|
New: key generation CLI command |
|
Add |
|
Wire |
|
Add |
|
Add canopy-signing dependency |
|
Wire verification key registry into startup |
|
Add |
Verification
-
cargo nextest run -p canopy-signing— all unit tests pass -
cargo xtask gen-signing-keys --program snap— generates key pair files -
Set
CANOPY_SNAP_SIGNING_KEYandCANOPY_VERIFY_KEY_SNAPenvironment variables from generated files -
cargo nextest run -p canopy-eligibility— signing/verification integration tests pass -
Manual: sign a determination with the generated key, verify it, tamper with it, verify rejection
Documentation Updates
-
.claude/docs/services.md— document signing infrastructure, environment variable conventions -
CHANGELOG.adoc— entry under== Unreleased -
.claude/docs/security.md— document key management protocol, rotation procedure