cratestack_cose/auth/enroll.rs
1//! COSE-signed enrolment challenge responses, and the Ed25519 key used to
2//! sign and verify them.
3//!
4//! **Moved, not rewritten** (cratestack#1005 part B, ADR 0006 §11 and
5//! "Decisions taken while scoping P0"): this is `cratestack-auth`'s
6//! `src/cose_enroll.rs` (and its tests), which was the crate's only `coset`
7//! user. The code is unchanged apart from its import paths (and two doc
8//! links that pointed at `crate::`). The golden vector in `tests.rs` was
9//! pinned in `cratestack-auth` before the move and passes here unchanged,
10//! so the bytes are the same. No behaviour was deliberately changed, and
11//! no bug was fixed on the way.
12//!
13//! **Not the ADR 0006 wire format, on purpose.** An enrolment challenge
14//! is a COSE_Sign1 with alg `-8` (EdDSA), the 35-byte `kid`
15//! [`ENROLL_CHALLENGE_COSE_KID`] and an empty external AAD, built and
16//! parsed by `coset`. The envelope's opener (`crate::open`) accepts only
17//! `-19`/`-9`, an 8-byte thumbprint `kid` and a bound AAD, and it is not
18//! loosened for this: the two paths share no parsing code, so the legacy
19//! shape cannot become acceptable to the strict one. Pinning the
20//! response-signing keys at enrolment (§8) is P1.
21
22use coset::{CoseSign1, CoseSign1Builder, HeaderBuilder, TaggedCborSerializable, iana};
23use cratestack_auth::{
24 AuthError, CHALLENGE_SIGNING_KEY_ENV, ENROLL_CHALLENGE_COSE_KID, EnrollResponse,
25 decode_signing_key,
26};
27use ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
28
29#[cfg(test)]
30mod tests;
31
32/// Sign `response` as an enrolment challenge with the key in
33/// [`CHALLENGE_SIGNING_KEY_ENV`]. Fails closed without that key (see
34/// `challenge_signing_key`). Was `cratestack_auth::build_cose_enroll_response`.
35pub fn build_cose_enroll_response(response: &EnrollResponse) -> Result<Vec<u8>, AuthError> {
36 let signing_key = challenge_signing_key()?;
37 build_cose_enroll_response_with_key(response, &signing_key)
38}
39
40fn build_cose_enroll_response_with_key(
41 response: &EnrollResponse,
42 signing_key: &SigningKey,
43) -> Result<Vec<u8>, AuthError> {
44 let payload = minicbor_serde::to_vec(response)
45 .map_err(|error| AuthError::ChallengeEncoding(error.to_string()))?;
46 let protected = HeaderBuilder::new()
47 .algorithm(iana::Algorithm::EdDSA)
48 .key_id(ENROLL_CHALLENGE_COSE_KID.as_bytes().to_vec())
49 .build();
50 let sign1 = CoseSign1Builder::new()
51 .protected(protected)
52 .payload(payload)
53 .create_signature(b"", |to_sign| signing_key.sign(to_sign).to_bytes().to_vec())
54 .build();
55
56 sign1
57 .to_tagged_vec()
58 .map_err(|error| AuthError::ChallengeEncoding(error.to_string()))
59}
60
61/// Verify and decode an enrolment challenge this service signed. The
62/// verifying key is derived from the same secret seed
63/// ([`CHALLENGE_SIGNING_KEY_ENV`]): the server checks its own challenge on
64/// the way back. It does **not** check `expires_at`; the caller must. Was
65/// `cratestack_auth::parse_cose_enroll_response`.
66pub fn parse_cose_enroll_response(bytes: &[u8]) -> Result<EnrollResponse, AuthError> {
67 let signing_key = challenge_signing_key()?;
68 parse_cose_enroll_response_with_key(bytes, &signing_key)
69}
70
71fn parse_cose_enroll_response_with_key(
72 bytes: &[u8],
73 signing_key: &SigningKey,
74) -> Result<EnrollResponse, AuthError> {
75 let sign1 = CoseSign1::from_tagged_slice(bytes)
76 .map_err(|error| AuthError::ChallengeDecoding(error.to_string()))?;
77 let verifying_key = signing_key.verifying_key();
78
79 sign1
80 .verify_signature(b"", |signature, data| {
81 verify_signature(&verifying_key, signature, data)
82 })
83 .map_err(|error| AuthError::ChallengeDecoding(error.to_string()))?;
84
85 let payload = sign1.payload.ok_or(AuthError::MissingChallengePayload)?;
86 minicbor_serde::from_slice(&payload)
87 .map_err(|error| AuthError::ChallengeDecoding(error.to_string()))
88}
89
90/// Loads the Ed25519 signing key used to sign and verify COSE enrolment
91/// challenge responses (`build_cose_enroll_response` /
92/// `parse_cose_enroll_response`) from [`CHALLENGE_SIGNING_KEY_ENV`]
93/// (URL-safe-base64-no-pad-encoded 32-byte seed — same encoding as
94/// [`ServiceSigningKey::from_env`][cratestack_auth::ServiceSigningKey::from_env] and
95/// [`encode_signing_key`][cratestack_auth::encode_signing_key]/[`decode_signing_key`]).
96///
97/// **Security history:** the downstream crate this was absorbed from used
98/// to return a hardcoded Ed25519 seed literal here. That seed was committed
99/// to that repository's git history and MUST be treated as permanently
100/// compromised — anyone with read access to the repo (or its history,
101/// forks, or CI logs) can reconstruct it and forge COSE enrolment challenge
102/// responses. It must never be reused as a default, fallback, or "example"
103/// value anywhere, including in tests — this crate's own tests generate
104/// their own key material (see `tests::test_challenge_signing_key_b64`).
105///
106/// Fails closed: an absent or whitespace-only env var is a hard error, not
107/// a silently-generated ephemeral key or a fallback to any default — a
108/// service that can't load its challenge signing key must not be able to
109/// sign or verify enrolment challenges at all.
110fn challenge_signing_key() -> Result<SigningKey, AuthError> {
111 challenge_signing_key_from(|name| std::env::var(name))
112}
113
114fn challenge_signing_key_from(
115 lookup_env: impl Fn(&str) -> Result<String, std::env::VarError>,
116) -> Result<SigningKey, AuthError> {
117 let raw = lookup_env(CHALLENGE_SIGNING_KEY_ENV)
118 .ok()
119 .filter(|value| !value.trim().is_empty())
120 .ok_or_else(|| AuthError::MissingSigningKeyEnv(CHALLENGE_SIGNING_KEY_ENV.to_string()))?;
121 decode_signing_key(&raw)
122}
123
124fn verify_signature(
125 verifying_key: &ed25519_dalek::VerifyingKey,
126 signature: &[u8],
127 data: &[u8],
128) -> Result<(), String> {
129 let signature = Signature::from_slice(signature).map_err(|error| error.to_string())?;
130 verifying_key
131 .verify(data, &signature)
132 .map_err(|error| error.to_string())
133}