From 745d22c9ddb624269841a18e2a3c5c23f7c24910 Mon Sep 17 00:00:00 2001 From: jonathan hunsberger Date: Fri, 10 Jul 2026 19:31:45 -0400 Subject: [PATCH 1/2] Fix file-based OpenSSH key signing without ssh-agent When triton-cli falls back to ~/.ssh keys (no ssh-agent), OpenSSH-format keys failed with "namespace invalid" because signing used ssh-key's SSHSIG API. Sign raw key material with RustCrypto instead, matching the SSH agent wire format CloudAPI expects. Co-authored-by: Cursor --- libs/triton-auth/src/legacy_pem.rs | 365 ++++++++++++++++++++++------- libs/triton-auth/src/signature.rs | 42 +--- 2 files changed, 292 insertions(+), 115 deletions(-) diff --git a/libs/triton-auth/src/legacy_pem.rs b/libs/triton-auth/src/legacy_pem.rs index 2e775ba9..aad738ce 100644 --- a/libs/triton-auth/src/legacy_pem.rs +++ b/libs/triton-auth/src/legacy_pem.rs @@ -377,102 +377,202 @@ impl LegacyPrivateKey { } /// Sign data with this key + /// + /// Produces a *raw* signature over `data` in the wire format CloudAPI + /// (via sshpk) expects: PKCS#1 v1.5 for RSA, ASN.1/DER for ECDSA, raw + /// 64 bytes for Ed25519, and two 20-byte integers for DSA. This matches + /// what an SSH agent returns for the same key. + /// + /// OpenSSH-format keys are signed by extracting the underlying key + /// material and signing with the RustCrypto crates. We intentionally do + /// NOT use `ssh_key::PrivateKey::sign`, which produces an SSHSIG blob + /// (the `ssh-keygen -Y sign` format): it rejects the empty namespace with + /// `namespace invalid` and, even with a namespace, signs a wrapped digest + /// rather than the raw signing string, which CloudAPI rejects. pub fn sign(&self, data: &[u8]) -> Result, AuthError> { match self { - Self::OpenSsh(key) => { - // Use ssh-key's signing - let key_type = KeyType::from_private_key(key)?; - let hash_alg = match key_type { - KeyType::Rsa | KeyType::Dsa | KeyType::Ecdsa256 | KeyType::Ecdsa384 => { - ssh_key::HashAlg::Sha256 - } - KeyType::Ecdsa521 | KeyType::Ed25519 => ssh_key::HashAlg::Sha512, - }; - - let sig = key - .sign("", hash_alg, data) - .map_err(|e| AuthError::SigningError(format!("SSH signing failed: {}", e)))?; - - let sig_bytes = sig.signature_bytes(); + Self::OpenSsh(key) => sign_openssh(key, data), + Self::Rsa(key) => sign_rsa(key, data), + Self::EcdsaP256(key) => sign_ecdsa_p256(key, data), + Self::EcdsaP384(key) => sign_ecdsa_p384(key, data), + Self::Dsa(key) => sign_dsa(key, data), + } + } +} - // ECDSA: ssh-key returns SSH wire format (mpint r || mpint s), - // but CloudAPI expects ASN.1/DER format - match key_type { - KeyType::Ecdsa256 | KeyType::Ecdsa384 | KeyType::Ecdsa521 => { - crate::certgen::ssh_ecdsa_sig_to_der(sig_bytes) - } - _ => Ok(sig_bytes.to_vec()), +/// Sign with an OpenSSH-format key by extracting its key material. +fn sign_openssh(key: &ssh_key::PrivateKey, data: &[u8]) -> Result, AuthError> { + use ssh_key::private::KeypairData; + + match key.key_data() { + KeypairData::Ed25519(kp) => { + use ed25519_dalek::Signer; + // to_bytes() returns private(32) || public(32); the first 32 + // bytes are the seed ed25519-dalek expects. + let bytes = kp.to_bytes(); + let seed: [u8; 32] = bytes[..32].try_into().map_err(|_| { + AuthError::SigningError("Ed25519 private key has unexpected length".into()) + })?; + let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed); + Ok(signing_key.sign(data).to_bytes().to_vec()) + } + KeypairData::Rsa(kp) => { + let rsa_key = rsa_from_openssh(kp)?; + sign_rsa(&rsa_key, data) + } + KeypairData::Ecdsa(kp) => { + use ssh_key::EcdsaCurve; + match kp.curve() { + EcdsaCurve::NistP256 => { + let signing_key = p256::ecdsa::SigningKey::from_slice(kp.private_key_bytes()) + .map_err(|e| { + AuthError::SigningError(format!("Invalid P-256 key from OpenSSH: {}", e)) + })?; + sign_ecdsa_p256(&signing_key, data) + } + EcdsaCurve::NistP384 => { + let signing_key = p384::ecdsa::SigningKey::from_slice(kp.private_key_bytes()) + .map_err(|e| { + AuthError::SigningError(format!("Invalid P-384 key from OpenSSH: {}", e)) + })?; + sign_ecdsa_p384(&signing_key, data) } + EcdsaCurve::NistP521 => Err(AuthError::SigningError( + "ECDSA P-521 keys are not supported for signing".into(), + )), } - Self::Rsa(key) => { - // RSA-SHA256 signature using PKCS#1 v1.5 - use rsa::pkcs1v15::SigningKey; - use rsa::signature::Signer; - use sha2::Sha256; + } + KeypairData::Dsa(kp) => { + let dsa_key = dsa_from_openssh(kp)?; + sign_dsa(&dsa_key, data) + } + _ => Err(AuthError::SigningError( + "Unsupported OpenSSH key type for signing".into(), + )), + } +} - let signing_key = SigningKey::::new(key.clone()); - let signature = signing_key - .try_sign(data) - .map_err(|e| AuthError::SigningError(format!("RSA signing failed: {}", e)))?; +/// RSA-SHA256 signature using PKCS#1 v1.5 (raw modulus-width bytes). +fn sign_rsa(key: &rsa::RsaPrivateKey, data: &[u8]) -> Result, AuthError> { + use rsa::pkcs1v15::SigningKey; + use rsa::signature::Signer; + use sha2::Sha256; - Ok(signature.to_vec()) - } - Self::EcdsaP256(key) => { - // ECDSA-SHA256 signature in ASN.1/DER format (CloudAPI requirement) - use p256::ecdsa::signature::Signer; + let signing_key = SigningKey::::new(key.clone()); + let signature = signing_key + .try_sign(data) + .map_err(|e| AuthError::SigningError(format!("RSA signing failed: {}", e)))?; - let signature: p256::ecdsa::Signature = key - .try_sign(data) - .map_err(|e| AuthError::SigningError(format!("ECDSA signing failed: {}", e)))?; + Ok(signature.to_vec()) +} - Ok(signature.to_der().as_bytes().to_vec()) - } - Self::EcdsaP384(key) => { - // ECDSA-SHA384 signature in ASN.1/DER format (CloudAPI requirement) - use p384::ecdsa::signature::Signer; +/// ECDSA-SHA256 (P-256) signature in ASN.1/DER format (CloudAPI requirement). +fn sign_ecdsa_p256(key: &p256::ecdsa::SigningKey, data: &[u8]) -> Result, AuthError> { + use p256::ecdsa::signature::Signer; - let signature: p384::ecdsa::Signature = key - .try_sign(data) - .map_err(|e| AuthError::SigningError(format!("ECDSA signing failed: {}", e)))?; + let signature: p256::ecdsa::Signature = key + .try_sign(data) + .map_err(|e| AuthError::SigningError(format!("ECDSA signing failed: {}", e)))?; - Ok(signature.to_der().as_bytes().to_vec()) - } - Self::Dsa(key) => { - // DSA-SHA1 signature (DSA traditionally uses SHA-1) - use dsa::signature::DigestSigner; - use sha1::Sha1; - - let mut digest = Sha1::new(); - Sha1Digest::update(&mut digest, data); - - let signature: dsa::Signature = key - .try_sign_digest(digest) - .map_err(|e| AuthError::SigningError(format!("DSA signing failed: {}", e)))?; - - // DSA signature needs to be in SSH format: two 20-byte integers - // The dsa crate provides r() and s() accessors - let r_bytes = signature.r().to_bytes_be(); - let s_bytes = signature.s().to_bytes_be(); - - if r_bytes.len() > 20 || s_bytes.len() > 20 { - return Err(AuthError::SigningError(format!( - "DSA signature component exceeds 20 bytes (r={}, s={})", - r_bytes.len(), - s_bytes.len() - ))); - } + Ok(signature.to_der().as_bytes().to_vec()) +} - // Pad to 20 bytes each (SHA-1 output size) - let mut sig_bytes = vec![0u8; 40]; - let r_start = 20 - r_bytes.len(); - let s_start = 40 - s_bytes.len(); - sig_bytes[r_start..20].copy_from_slice(&r_bytes); - sig_bytes[s_start..40].copy_from_slice(&s_bytes); +/// ECDSA-SHA384 (P-384) signature in ASN.1/DER format (CloudAPI requirement). +fn sign_ecdsa_p384(key: &p384::ecdsa::SigningKey, data: &[u8]) -> Result, AuthError> { + use p384::ecdsa::signature::Signer; - Ok(sig_bytes) - } - } + let signature: p384::ecdsa::Signature = key + .try_sign(data) + .map_err(|e| AuthError::SigningError(format!("ECDSA signing failed: {}", e)))?; + + Ok(signature.to_der().as_bytes().to_vec()) +} + +/// DSA-SHA1 signature packed as two 20-byte big-endian integers (SSH format). +fn sign_dsa(key: &dsa::SigningKey, data: &[u8]) -> Result, AuthError> { + use dsa::signature::DigestSigner; + use sha1::Sha1; + + let mut digest = Sha1::new(); + Sha1Digest::update(&mut digest, data); + + let signature: dsa::Signature = key + .try_sign_digest(digest) + .map_err(|e| AuthError::SigningError(format!("DSA signing failed: {}", e)))?; + + // DSA signature needs to be in SSH format: two 20-byte integers. + // The dsa crate provides r() and s() accessors. + let r_bytes = signature.r().to_bytes_be(); + let s_bytes = signature.s().to_bytes_be(); + + if r_bytes.len() > 20 || s_bytes.len() > 20 { + return Err(AuthError::SigningError(format!( + "DSA signature component exceeds 20 bytes (r={}, s={})", + r_bytes.len(), + s_bytes.len() + ))); } + + // Pad to 20 bytes each (SHA-1 output size) + let mut sig_bytes = vec![0u8; 40]; + let r_start = 20 - r_bytes.len(); + let s_start = 40 - s_bytes.len(); + sig_bytes[r_start..20].copy_from_slice(&r_bytes); + sig_bytes[s_start..40].copy_from_slice(&s_bytes); + + Ok(sig_bytes) +} + +/// Reconstruct an `rsa::RsaPrivateKey` from an OpenSSH RSA keypair. +/// +/// We build the key from its components rather than using ssh-key's +/// `TryFrom<&RsaKeypair>` conversion, which has an upstream bug. +fn rsa_from_openssh(kp: &ssh_key::private::RsaKeypair) -> Result { + let positive = |m: &ssh_key::Mpint, name: &str| -> Result { + m.as_positive_bytes() + .map(rsa::BigUint::from_bytes_be) + .ok_or_else(|| { + AuthError::SigningError(format!("RSA component {} is not a positive integer", name)) + }) + }; + + let n = positive(&kp.public.n, "n")?; + let e = positive(&kp.public.e, "e")?; + let d = positive(&kp.private.d, "d")?; + let p = positive(&kp.private.p, "p")?; + let q = positive(&kp.private.q, "q")?; + + rsa::RsaPrivateKey::from_components(n, e, d, vec![p, q]).map_err(|e| { + AuthError::SigningError(format!("Invalid RSA key material from OpenSSH: {}", e)) + }) +} + +/// Reconstruct a `dsa::SigningKey` from an OpenSSH DSA keypair. +fn dsa_from_openssh(kp: &ssh_key::private::DsaKeypair) -> Result { + use dsa::Components; + + let positive = |m: &ssh_key::Mpint, name: &str| -> Result { + m.as_positive_bytes() + .map(dsa::BigUint::from_bytes_be) + .ok_or_else(|| { + AuthError::SigningError(format!("DSA component {} is not a positive integer", name)) + }) + }; + + let p = positive(&kp.public.p, "p")?; + let q = positive(&kp.public.q, "q")?; + let g = positive(&kp.public.g, "g")?; + let y = positive(&kp.public.y, "y")?; + let x = positive(kp.private.as_mpint(), "x")?; + + let components = Components::from_components(p, q, g).map_err(|e| { + AuthError::SigningError(format!("Invalid DSA components from OpenSSH: {}", e)) + })?; + let verifying_key = dsa::VerifyingKey::from_components(components, y).map_err(|e| { + AuthError::SigningError(format!("Invalid DSA public key from OpenSSH: {}", e)) + })?; + dsa::SigningKey::from_components(verifying_key, x) + .map_err(|e| AuthError::SigningError(format!("Invalid DSA key from OpenSSH: {}", e))) } /// Parse DSA private key from DER encoding @@ -883,4 +983,105 @@ mod tests { write_ssh_mpint(&mut buf, &[0x01, 0x02]); assert_eq!(buf, vec![0, 0, 0, 2, 0x01, 0x02]); } + + // ------------------------------------------------------------------ + // OpenSSH-format signing regression tests + // + // These guard against the "namespace invalid" bug: signing a file-based + // OpenSSH key previously went through `ssh_key::PrivateKey::sign("", ..)`, + // which produces an SSHSIG blob and rejects the empty namespace. The + // whole reason ssh-agent "worked" but `~/.ssh` keys did not. Every case + // below both signs AND verifies via the HTTP-Sig verifier to ensure the + // wire format matches what CloudAPI expects. + // ------------------------------------------------------------------ + + use crate::http_sig::verify_signature; + use rand_core::OsRng; + use ssh_key::private::{EcdsaKeypair, Ed25519Keypair, RsaKeypair}; + use ssh_key::{EcdsaCurve, LineEnding, PrivateKey}; + + const SIGNING_STRING: &[u8] = + b"(request-target): get /foo/machines\ndate: Mon, 15 Dec 2025 10:30:00 GMT"; + + #[test] + fn test_openssh_ed25519_sign_verifies() { + let ssh_priv = PrivateKey::from(Ed25519Keypair::random(&mut OsRng)); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key + .sign(SIGNING_STRING) + .expect("OpenSSH ed25519 signing must not fail with 'namespace invalid'"); + + assert_eq!(sig.len(), 64, "ed25519 signature must be raw 64 bytes"); + verify_signature(&public_key, "ed25519", SIGNING_STRING, &sig) + .expect("ed25519 signature must verify"); + } + + #[test] + fn test_openssh_ecdsa_p256_sign_verifies() { + let ssh_priv = PrivateKey::from( + EcdsaKeypair::random(&mut OsRng, EcdsaCurve::NistP256).expect("p256 keygen"), + ); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key.sign(SIGNING_STRING).expect("OpenSSH p256 signing"); + + assert_eq!(sig[0], 0x30, "ECDSA signature must be DER-encoded"); + verify_signature(&public_key, "ecdsa-sha256", SIGNING_STRING, &sig) + .expect("p256 signature must verify"); + } + + #[test] + fn test_openssh_ecdsa_p384_sign_verifies() { + let ssh_priv = PrivateKey::from( + EcdsaKeypair::random(&mut OsRng, EcdsaCurve::NistP384).expect("p384 keygen"), + ); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key.sign(SIGNING_STRING).expect("OpenSSH p384 signing"); + + assert_eq!(sig[0], 0x30, "ECDSA signature must be DER-encoded"); + verify_signature(&public_key, "ecdsa-sha384", SIGNING_STRING, &sig) + .expect("p384 signature must verify"); + } + + #[test] + fn test_openssh_rsa_sign_verifies() { + let ssh_priv = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).expect("rsa keygen")); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key.sign(SIGNING_STRING).expect("OpenSSH rsa signing"); + + assert_eq!(sig.len(), 256, "2048-bit RSA signature must be 256 bytes"); + verify_signature(&public_key, "rsa-sha256", SIGNING_STRING, &sig) + .expect("rsa signature must verify"); + } + + /// End-to-end regression for the reported failure: a key read from disk in + /// OpenSSH PEM format must sign successfully (no "namespace invalid") and + /// produce a verifiable signature. + #[test] + fn test_openssh_pem_roundtrip_sign_verifies() { + let ssh_priv = PrivateKey::from(Ed25519Keypair::random(&mut OsRng)); + let public_key = ssh_priv.public_key().clone(); + + // Serialize to the on-disk OpenSSH PEM format, then load it back the + // same way KeyLoader does for `~/.ssh` keys. + let pem = ssh_priv + .to_openssh(LineEnding::LF) + .expect("serialize to OpenSSH PEM"); + assert_eq!(PemKeyFormat::detect(&pem), PemKeyFormat::OpenSsh); + + let key = LegacyPrivateKey::from_pem(&pem, None).expect("load OpenSSH PEM"); + let sig = key + .sign(SIGNING_STRING) + .expect("signing a file-based OpenSSH key must succeed"); + + verify_signature(&public_key, "ed25519", SIGNING_STRING, &sig) + .expect("round-tripped signature must verify"); + } } diff --git a/libs/triton-auth/src/signature.rs b/libs/triton-auth/src/signature.rs index 45ac3400..e2090555 100644 --- a/libs/triton-auth/src/signature.rs +++ b/libs/triton-auth/src/signature.rs @@ -20,7 +20,7 @@ use crate::error::AuthError; use base64::Engine; use chrono::Utc; -use ssh_key::{HashAlg, PrivateKey}; +use ssh_key::PrivateKey; /// Key type for algorithm selection in HTTP signatures #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -93,18 +93,6 @@ impl KeyType { Self::Ed25519 => "ed25519-sha512", } } - - /// Get the hash algorithm to use for signing - /// - /// Note: ssh-key 0.6 only supports Sha256 and Sha512 for HashAlg - fn hash_alg(&self) -> HashAlg { - match self { - // Use SHA-256 for RSA, DSA, and ECDSA-256/384 - Self::Rsa | Self::Dsa | Self::Ecdsa256 | Self::Ecdsa384 => HashAlg::Sha256, - // Use SHA-512 for ECDSA-521 and Ed25519 - Self::Ecdsa521 | Self::Ed25519 => HashAlg::Sha512, - } - } } /// HTTP Signature request signer @@ -214,26 +202,14 @@ impl RequestSigner { /// # Returns /// The base64-encoded signature pub fn sign_with_key(key: &PrivateKey, data: &[u8]) -> Result { - let key_type = KeyType::from_private_key(key)?; - let hash_alg = key_type.hash_alg(); - - // Sign using the ssh-key crate - // The first argument is a namespace (empty string for SSH signatures) - let signature = key - .sign("", hash_alg, data) - .map_err(|e| AuthError::SigningError(format!("Failed to sign data: {}", e)))?; - - let raw_bytes = signature.signature_bytes(); - - // ECDSA: ssh-key returns SSH wire format (mpint r || mpint s), - // but CloudAPI expects ASN.1/DER format - let sig_bytes = match key_type { - KeyType::Ecdsa256 | KeyType::Ecdsa384 | KeyType::Ecdsa521 => { - crate::certgen::ssh_ecdsa_sig_to_der(raw_bytes)? - } - _ => raw_bytes.to_vec(), - }; - + // Delegate to the shared signing path. We deliberately avoid + // `ssh_key::PrivateKey::sign`, which produces an SSHSIG blob and rejects + // the empty namespace with `namespace invalid`; `LegacyPrivateKey::sign` + // extracts the raw key material and produces the wire-format signature + // CloudAPI expects (PKCS#1 v1.5 for RSA, DER for ECDSA, raw 64 bytes for + // Ed25519, two 20-byte integers for DSA). + let legacy_key = crate::legacy_pem::LegacyPrivateKey::OpenSsh(key.clone()); + let sig_bytes = legacy_key.sign(data)?; Ok(base64::engine::general_purpose::STANDARD.encode(&sig_bytes)) } From 286147dd70c00d477cfedead4b35326beb61e4ed Mon Sep 17 00:00:00 2001 From: jonathan hunsberger Date: Fri, 10 Jul 2026 20:30:59 -0400 Subject: [PATCH 2/2] move tests to test area --- libs/triton-auth/src/legacy_pem.rs | 101 ----------------- .../triton-auth/tests/openssh_signing_test.rs | 105 ++++++++++++++++++ 2 files changed, 105 insertions(+), 101 deletions(-) create mode 100644 libs/triton-auth/tests/openssh_signing_test.rs diff --git a/libs/triton-auth/src/legacy_pem.rs b/libs/triton-auth/src/legacy_pem.rs index aad738ce..15df54ce 100644 --- a/libs/triton-auth/src/legacy_pem.rs +++ b/libs/triton-auth/src/legacy_pem.rs @@ -983,105 +983,4 @@ mod tests { write_ssh_mpint(&mut buf, &[0x01, 0x02]); assert_eq!(buf, vec![0, 0, 0, 2, 0x01, 0x02]); } - - // ------------------------------------------------------------------ - // OpenSSH-format signing regression tests - // - // These guard against the "namespace invalid" bug: signing a file-based - // OpenSSH key previously went through `ssh_key::PrivateKey::sign("", ..)`, - // which produces an SSHSIG blob and rejects the empty namespace. The - // whole reason ssh-agent "worked" but `~/.ssh` keys did not. Every case - // below both signs AND verifies via the HTTP-Sig verifier to ensure the - // wire format matches what CloudAPI expects. - // ------------------------------------------------------------------ - - use crate::http_sig::verify_signature; - use rand_core::OsRng; - use ssh_key::private::{EcdsaKeypair, Ed25519Keypair, RsaKeypair}; - use ssh_key::{EcdsaCurve, LineEnding, PrivateKey}; - - const SIGNING_STRING: &[u8] = - b"(request-target): get /foo/machines\ndate: Mon, 15 Dec 2025 10:30:00 GMT"; - - #[test] - fn test_openssh_ed25519_sign_verifies() { - let ssh_priv = PrivateKey::from(Ed25519Keypair::random(&mut OsRng)); - let public_key = ssh_priv.public_key().clone(); - - let key = LegacyPrivateKey::OpenSsh(ssh_priv); - let sig = key - .sign(SIGNING_STRING) - .expect("OpenSSH ed25519 signing must not fail with 'namespace invalid'"); - - assert_eq!(sig.len(), 64, "ed25519 signature must be raw 64 bytes"); - verify_signature(&public_key, "ed25519", SIGNING_STRING, &sig) - .expect("ed25519 signature must verify"); - } - - #[test] - fn test_openssh_ecdsa_p256_sign_verifies() { - let ssh_priv = PrivateKey::from( - EcdsaKeypair::random(&mut OsRng, EcdsaCurve::NistP256).expect("p256 keygen"), - ); - let public_key = ssh_priv.public_key().clone(); - - let key = LegacyPrivateKey::OpenSsh(ssh_priv); - let sig = key.sign(SIGNING_STRING).expect("OpenSSH p256 signing"); - - assert_eq!(sig[0], 0x30, "ECDSA signature must be DER-encoded"); - verify_signature(&public_key, "ecdsa-sha256", SIGNING_STRING, &sig) - .expect("p256 signature must verify"); - } - - #[test] - fn test_openssh_ecdsa_p384_sign_verifies() { - let ssh_priv = PrivateKey::from( - EcdsaKeypair::random(&mut OsRng, EcdsaCurve::NistP384).expect("p384 keygen"), - ); - let public_key = ssh_priv.public_key().clone(); - - let key = LegacyPrivateKey::OpenSsh(ssh_priv); - let sig = key.sign(SIGNING_STRING).expect("OpenSSH p384 signing"); - - assert_eq!(sig[0], 0x30, "ECDSA signature must be DER-encoded"); - verify_signature(&public_key, "ecdsa-sha384", SIGNING_STRING, &sig) - .expect("p384 signature must verify"); - } - - #[test] - fn test_openssh_rsa_sign_verifies() { - let ssh_priv = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).expect("rsa keygen")); - let public_key = ssh_priv.public_key().clone(); - - let key = LegacyPrivateKey::OpenSsh(ssh_priv); - let sig = key.sign(SIGNING_STRING).expect("OpenSSH rsa signing"); - - assert_eq!(sig.len(), 256, "2048-bit RSA signature must be 256 bytes"); - verify_signature(&public_key, "rsa-sha256", SIGNING_STRING, &sig) - .expect("rsa signature must verify"); - } - - /// End-to-end regression for the reported failure: a key read from disk in - /// OpenSSH PEM format must sign successfully (no "namespace invalid") and - /// produce a verifiable signature. - #[test] - fn test_openssh_pem_roundtrip_sign_verifies() { - let ssh_priv = PrivateKey::from(Ed25519Keypair::random(&mut OsRng)); - let public_key = ssh_priv.public_key().clone(); - - // Serialize to the on-disk OpenSSH PEM format, then load it back the - // same way KeyLoader does for `~/.ssh` keys. - let pem = ssh_priv - .to_openssh(LineEnding::LF) - .expect("serialize to OpenSSH PEM"); - assert_eq!(PemKeyFormat::detect(&pem), PemKeyFormat::OpenSsh); - - let key = LegacyPrivateKey::from_pem(&pem, None).expect("load OpenSSH PEM"); - let sig = key - .sign(SIGNING_STRING) - .expect("signing a file-based OpenSSH key must succeed"); - - verify_signature(&public_key, "ed25519", SIGNING_STRING, &sig) - .expect("round-tripped signature must verify"); - } } diff --git a/libs/triton-auth/tests/openssh_signing_test.rs b/libs/triton-auth/tests/openssh_signing_test.rs new file mode 100644 index 00000000..eda1b5c4 --- /dev/null +++ b/libs/triton-auth/tests/openssh_signing_test.rs @@ -0,0 +1,105 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright 2026 Edgecast Cloud LLC. + +//! OpenSSH-format signing regression tests for triton-auth +//! +//! These guard against the "namespace invalid" bug: signing a file-based +//! OpenSSH key previously went through `ssh_key::PrivateKey::sign("", ..)`, +//! which produces an SSHSIG blob and rejects the empty namespace. That is the +//! whole reason ssh-agent "worked" but `~/.ssh` keys did not. Every case below +//! both signs AND verifies via the HTTP-Sig verifier to ensure the wire format +//! matches what CloudAPI expects. + +use rand_core::OsRng; +use ssh_key::private::{EcdsaKeypair, Ed25519Keypair, RsaKeypair}; +use ssh_key::{EcdsaCurve, LineEnding, PrivateKey}; +use triton_auth::http_sig::verify_signature; +use triton_auth::legacy_pem::{LegacyPrivateKey, PemKeyFormat}; + +const SIGNING_STRING: &[u8] = + b"(request-target): get /foo/machines\ndate: Mon, 15 Dec 2025 10:30:00 GMT"; + +#[test] +fn test_openssh_ed25519_sign_verifies() { + let ssh_priv = PrivateKey::from(Ed25519Keypair::random(&mut OsRng)); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key + .sign(SIGNING_STRING) + .expect("OpenSSH ed25519 signing must not fail with 'namespace invalid'"); + + assert_eq!(sig.len(), 64, "ed25519 signature must be raw 64 bytes"); + verify_signature(&public_key, "ed25519", SIGNING_STRING, &sig) + .expect("ed25519 signature must verify"); +} + +#[test] +fn test_openssh_ecdsa_p256_sign_verifies() { + let ssh_priv = PrivateKey::from( + EcdsaKeypair::random(&mut OsRng, EcdsaCurve::NistP256).expect("p256 keygen"), + ); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key.sign(SIGNING_STRING).expect("OpenSSH p256 signing"); + + assert_eq!(sig[0], 0x30, "ECDSA signature must be DER-encoded"); + verify_signature(&public_key, "ecdsa-sha256", SIGNING_STRING, &sig) + .expect("p256 signature must verify"); +} + +#[test] +fn test_openssh_ecdsa_p384_sign_verifies() { + let ssh_priv = PrivateKey::from( + EcdsaKeypair::random(&mut OsRng, EcdsaCurve::NistP384).expect("p384 keygen"), + ); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key.sign(SIGNING_STRING).expect("OpenSSH p384 signing"); + + assert_eq!(sig[0], 0x30, "ECDSA signature must be DER-encoded"); + verify_signature(&public_key, "ecdsa-sha384", SIGNING_STRING, &sig) + .expect("p384 signature must verify"); +} + +#[test] +fn test_openssh_rsa_sign_verifies() { + let ssh_priv = PrivateKey::from(RsaKeypair::random(&mut OsRng, 2048).expect("rsa keygen")); + let public_key = ssh_priv.public_key().clone(); + + let key = LegacyPrivateKey::OpenSsh(ssh_priv); + let sig = key.sign(SIGNING_STRING).expect("OpenSSH rsa signing"); + + assert_eq!(sig.len(), 256, "2048-bit RSA signature must be 256 bytes"); + verify_signature(&public_key, "rsa-sha256", SIGNING_STRING, &sig) + .expect("rsa signature must verify"); +} + +/// End-to-end regression for the reported failure: a key read from disk in +/// OpenSSH PEM format must sign successfully (no "namespace invalid") and +/// produce a verifiable signature. +#[test] +fn test_openssh_pem_roundtrip_sign_verifies() { + let ssh_priv = PrivateKey::from(Ed25519Keypair::random(&mut OsRng)); + let public_key = ssh_priv.public_key().clone(); + + // Serialize to the on-disk OpenSSH PEM format, then load it back the + // same way KeyLoader does for `~/.ssh` keys. + let pem = ssh_priv + .to_openssh(LineEnding::LF) + .expect("serialize to OpenSSH PEM"); + assert_eq!(PemKeyFormat::detect(&pem), PemKeyFormat::OpenSsh); + + let key = LegacyPrivateKey::from_pem(&pem, None).expect("load OpenSSH PEM"); + let sig = key + .sign(SIGNING_STRING) + .expect("signing a file-based OpenSSH key must succeed"); + + verify_signature(&public_key, "ed25519", SIGNING_STRING, &sig) + .expect("round-tripped signature must verify"); +}