diff --git a/Cargo.lock b/Cargo.lock index da1c25ea20..ce14770caa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,6 +1404,7 @@ dependencies = [ "mac_address", "nras", "once_cell", + "p256 0.14.0-rc.7", "prost", "rand 0.10.1", "regex", diff --git a/crates/api-model/Cargo.toml b/crates/api-model/Cargo.toml index e0be35cf44..a859b9663a 100644 --- a/crates/api-model/Cargo.toml +++ b/crates/api-model/Cargo.toml @@ -35,7 +35,6 @@ carbide-network = { path = "../network", features = ["sqlx"] } carbide-version = { path = "../version" } carbide-health-report = { path = "../health-report" } carbide-ipxe-renderer = { path = "../ipxe-renderer" } -carbide-secrets = { path = "../secrets" } carbide-utils = { path = "../utils", features = ["sqlx"] } carbide-uuid = { path = "../uuid", features = ["sqlx"] } dns-record = { path = "../dns-record" } @@ -89,7 +88,9 @@ carbide-version = { path = "../version" } [dev-dependencies] carbide-sqlx-testing = { path = "../sqlx-testing", default-features = false } +carbide-secrets = { path = "../secrets" } lazy_static = { workspace = true } +p256 = { workspace = true } prost = { workspace = true } toml = { workspace = true } diff --git a/crates/api-model/src/tenant/identity_config.rs b/crates/api-model/src/tenant/identity_config.rs index f0944cfa0a..e3a2ecc0e7 100644 --- a/crates/api-model/src/tenant/identity_config.rs +++ b/crates/api-model/src/tenant/identity_config.rs @@ -19,6 +19,7 @@ use std::marker::PhantomData; use std::str::FromStr; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; /// JWT `alg` for per-tenant signing keys. Only ES256 (ECDSA P-256) is implemented end-to-end. pub const TENANT_IDENTITY_SIGNING_JWT_ALG: &str = "ES256"; @@ -289,10 +290,18 @@ impl KeyId { /// Delegates to [`forge_secrets::key_encryption::key_id_from_public_key`] (e.g. SPKI PEM from /// ES256 key generation). Infallible: that function always yields 64 hex characters. pub fn from_public_key_material(public_key_material: &str) -> Self { - Self::try_from(forge_secrets::key_encryption::key_id_from_public_key( - public_key_material, - )) - .expect("key_id_from_public_key yields 64 hex chars, always non-empty") + Self::try_from(Self::key_id_from_public_key(public_key_material)) + .expect("key_id_from_public_key yields 64 hex chars, always non-empty") + } + + /// Computes key_id as hex(sha256(public_key)). + /// Works with any public key representation (PEM, DER, etc.). + /// + /// API domain code should prefer `KeyId::from_public_key_material` in `carbide-api-model`, which + /// delegates to this function (one implementation). + fn key_id_from_public_key(public_key: &str) -> String { + let hash = Sha256::digest(public_key.as_bytes()); + hex::encode(hash) } } @@ -467,6 +476,7 @@ pub type EncryptedTokenDelegationAuthConfig = #[cfg(test)] mod key_id_tests { + use p256::pkcs8::{DecodePrivateKey, DecodePublicKey}; use serde_json::json; use super::{KeyId, SigningKeyPublicV1}; @@ -505,4 +515,25 @@ mod key_id_tests { assert_eq!(doc.kid(), canonical.kid()); assert_eq!(doc, canonical); } + + #[test] + fn key_id_from_public_ke_yis_deterministic() { + let pub_key = "-----BEGIN PUBLIC KEY-----\nMFkw...\n-----END PUBLIC KEY-----"; + let id1 = KeyId::key_id_from_public_key(pub_key); + let id2 = KeyId::key_id_from_public_key(pub_key); + assert_eq!(id1, id2); + assert_eq!(id1.len(), 64); + } + + #[test] + fn generate_es256_key_pair_produces_valid_outputs() { + let (private_pem, public_pem) = + forge_secrets::key_encryption::generate_es256_key_pair().unwrap(); + assert!(private_pem.starts_with(b"-----BEGIN")); + assert!(public_pem.contains("PUBLIC KEY")); + let key_id = KeyId::key_id_from_public_key(&public_pem); + assert_eq!(key_id.len(), 64); + p256::PublicKey::from_public_key_pem(public_pem.trim()).unwrap(); + p256::SecretKey::from_pkcs8_pem(std::str::from_utf8(&private_pem).unwrap()).unwrap(); + } } diff --git a/crates/secrets/src/key_encryption.rs b/crates/secrets/src/key_encryption.rs index 086184e7a8..511e2d1742 100644 --- a/crates/secrets/src/key_encryption.rs +++ b/crates/secrets/src/key_encryption.rs @@ -42,7 +42,6 @@ use p256::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; use rand::rngs::SysRng; use rand_core::TryRng; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; /// Scheme version 1: AES-256-GCM, 32-byte key from base64-decoded encryption secret, envelope below. pub const SCHEME_VERSION_V1: u8 = 1; @@ -161,16 +160,6 @@ pub fn decrypt( .map_err(|e| KeyEncryptionError::Decrypt(e.to_string())) } -/// Computes key_id as hex(sha256(public_key)). -/// Works with any public key representation (PEM, DER, etc.). -/// -/// API domain code should prefer `KeyId::from_public_key_material` in `carbide-api-model`, which -/// delegates to this function (one implementation). -pub fn key_id_from_public_key(public_key: &str) -> String { - let hash = Sha256::digest(public_key.as_bytes()); - hex::encode(hash) -} - /// Generates an ES256 (ECDSA P-256) signing key pair (PKCS#8 private + SPKI public PEM via `p256`). /// /// The public PEM matches `p256::PublicKey::from_public_key_pem` (same as carbide-api JWKS). @@ -192,8 +181,6 @@ pub fn generate_es256_key_pair() -> Result<(Vec, String), KeyEncryptionError #[cfg(test)] mod tests { - use p256::pkcs8::{DecodePrivateKey, DecodePublicKey}; - use super::*; fn test_aes256_key() -> Aes256Key { @@ -211,26 +198,6 @@ mod tests { assert_eq!(raw.first(), Some(&b'{')); } - #[test] - fn key_id_from_public_key_is_deterministic() { - let pub_key = "-----BEGIN PUBLIC KEY-----\nMFkw...\n-----END PUBLIC KEY-----"; - let id1 = key_id_from_public_key(pub_key); - let id2 = key_id_from_public_key(pub_key); - assert_eq!(id1, id2); - assert_eq!(id1.len(), 64); - } - - #[test] - fn generate_es256_key_pair_produces_valid_outputs() { - let (private_pem, public_pem) = generate_es256_key_pair().unwrap(); - assert!(private_pem.starts_with(b"-----BEGIN")); - assert!(public_pem.contains("PUBLIC KEY")); - let key_id = key_id_from_public_key(&public_pem); - assert_eq!(key_id.len(), 64); - p256::PublicKey::from_public_key_pem(public_pem.trim()).unwrap(); - p256::SecretKey::from_pkcs8_pem(std::str::from_utf8(&private_pem).unwrap()).unwrap(); - } - #[test] fn stored_secret_wrong_length_errors() { let short = BASE64.encode([0u8; 16]);