Plan: JWS Determination Signing Infrastructure

On this page

Status

Step Description Status

1

Shared signing crate (crates/canopy-signing/) with ECDSA P-256 key generation, signing, and verification

Done (2026-03-28)

2

Key generation xtask command (cargo xtask gen-signing-keys)

Done (2026-03-28)

3

DeterminationSigner trait implementation using canopy-signing

Done (2026-03-28)

4

DeterminationVerifier implementation with multi-key registry in canopy-eligibility

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-signing shared crate: ECDSA P-256 key pair generation, JWS signing, JWS verification

  • Integration with DeterminationSigner and DeterminationVerifier traits 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-keys command 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_KEY environment variable

  • Public keys: SPKI PEM, loaded from CANOPY_VERIFY_KEY_{PROGRAM} environment variable in canopy-eligibility

  • During rotation, CANOPY_VERIFY_KEY_{PROGRAM}_PREV holds 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:

  1. Generate: Run cargo xtask gen-signing-keys --program snap to generate a new key pair.

  2. Deploy verifier first: Add the new public key as CANOPY_VERIFY_KEY_SNAP and move the old public key to CANOPY_VERIFY_KEY_SNAP_PREV in canopy-eligibility. Redeploy canopy-eligibility. It now accepts signatures from both keys.

  3. Deploy signer: Update CANOPY_SNAP_SIGNING_KEY in canopy-snap with the new private key. Redeploy canopy-snap. New determinations are signed with the new key.

  4. 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_PREV from 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:

  1. Construct JWS header JSON, base64url-encode it.

  2. Base64url-encode the payload.

  3. Compute ECDSA signature over base64url(header).base64url(payload).

  4. 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() returns Err(SigningError::NoKeyForProgram(_)). The orchestrator should treat this as a configuration error and log at error level.

  • If the JWS string is malformed (wrong number of segments, invalid base64), verify_detached returns Err(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

crates/canopy-signing/Cargo.toml

New: crate manifest with p256, base64, serde_json, thiserror

crates/canopy-signing/src/lib.rs

New: public API, re-exports

crates/canopy-signing/src/keygen.rs

New: ECDSA P-256 key pair generation

crates/canopy-signing/src/signer.rs

New: SigningKey, detached JWS signing

crates/canopy-signing/src/verifier.rs

New: VerifyingKey, VerifyingKeyRegistry, dual-key support

xtask/src/cmd/gen_signing_keys.rs

New: key generation CLI command

xtask/src/cmd/mod.rs

Add gen_signing_keys module

xtask/src/main.rs

Wire gen-signing-keys subcommand

services/canopy-eligibility/src/determination.rs

Add EcdsaDeterminationSigner, EcdsaDeterminationVerifier

services/canopy-eligibility/Cargo.toml

Add canopy-signing dependency

services/canopy-eligibility/src/main.rs

Wire verification key registry into startup

.gitignore

Add .keys/ directory

Verification

  1. cargo nextest run -p canopy-signing — all unit tests pass

  2. cargo xtask gen-signing-keys --program snap — generates key pair files

  3. Set CANOPY_SNAP_SIGNING_KEY and CANOPY_VERIFY_KEY_SNAP environment variables from generated files

  4. cargo nextest run -p canopy-eligibility — signing/verification integration tests pass

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

Edit this page · default