Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/api-model/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -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 }

Expand Down
39 changes: 35 additions & 4 deletions crates/api-model/src/tenant/identity_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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();
}
}
33 changes: 0 additions & 33 deletions crates/secrets/src/key_encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand All @@ -192,8 +181,6 @@ pub fn generate_es256_key_pair() -> Result<(Vec<u8>, String), KeyEncryptionError

#[cfg(test)]
mod tests {
use p256::pkcs8::{DecodePrivateKey, DecodePublicKey};

use super::*;

fn test_aes256_key() -> Aes256Key {
Expand All @@ -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]);
Expand Down
Loading