Skip to content

Commit 744e325

Browse files
rayketchamclaude
andcommitted
fix: detect PQC keys via PKCS#8 OID + parity test gaps (#97)
pki-client-output's SanEntry is a serde-tagged enum, so `pki cert show -f json` emits `{"Dns":"x"}` in the SAN array. The openssl parity script was asserting on the flat "DNS:x" form produced by the Display impl, so every SAN assertion diverged. Normalize via jq (`to_entries | key+:+value`, uppercasing Dns/Ip to DNS/IP). Subject DN assertions broke under openssl 3.5+ which drops spaces around `=`. Normalize both sides with `sed 's/ = /=/g'` before matching. EKU assertions used openssl's short names (serverAuth/clientAuth); pki emits RFC 5280 long-form labels ("TLS Web Server Authentication"). Assert on the long form — that's the canonical text, not the openssl shorthand. Issue #97: ML-DSA/SLH-DSA private keys were misreported as "EC P-256, 256 bits, Strong" because spork-core emitted raw seeds and the detector fell through to size-based heuristics. Fix at the source: * vendor/spork-core/src/algo/pqc_pkcs8.rs — RFC 5958 wrap/unwrap helpers. * mldsa_impl + slhdsa_impl now export PKCS#8 PrivateKeyInfo with the correct AlgorithmIdentifier OID; `from_pkcs8_der` transparently extracts the raw seed (and still accepts legacy raw-seed blobs for backward compat with keys on disk). * crates/pki-client/src/compat.rs — PKCS#8 OID detection runs first, routing PQC OIDs to `KeyAlgorithm::MlDsa` / `SlhDsa`. Also wires build_with_key through spork-core's PQC AlgorithmIds so `pki csr create` no longer errors on PQC keys. Plumbing: * Box the ML-DSA-65/87 round-trip test keys — debug builds overflowed the test stack holding three ExpandedSigningKeys simultaneously. * crates/pki-client/tests/key_show_algorithms.rs — regression guard: generate each algorithm, assert detector output, specifically catch any PQC→EcP256 misidentification. * .github/workflows/interop.yml — openssl-parity job + broader PR path triggers so parity regressions block merges. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d138a05 commit 744e325

8 files changed

Lines changed: 492 additions & 58 deletions

File tree

.github/workflows/interop.yml

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ on:
77
pull_request:
88
branches: [main]
99
paths:
10-
- 'crates/pki-client/src/commands/probe.rs'
10+
- 'crates/pki-client/**'
11+
- 'crates/pki-client-output/**'
1112
- 'crates/pki-probe/**'
1213
- 'vendor/spork-core/**'
1314
- 'tests/interop/**'
1415
- '**/Cargo.toml'
16+
- '.github/workflows/interop.yml'
1517

1618
env:
1719
CARGO_TERM_COLOR: always
@@ -100,3 +102,28 @@ jobs:
100102
env:
101103
PKI_BIN: ./pki
102104
run: bash tests/interop/cert_roundtrip.sh
105+
106+
openssl-parity:
107+
name: OpenSSL Parity
108+
needs: [build]
109+
runs-on: [self-hosted, Linux]
110+
timeout-minutes: 10
111+
steps:
112+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
113+
with:
114+
persist-credentials: false
115+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
116+
with:
117+
name: pki-binary
118+
- name: Prepare binary
119+
run: chmod +x pki
120+
- name: Install openssl + jq
121+
run: |
122+
if ! command -v jq >/dev/null; then
123+
sudo apt-get update
124+
sudo apt-get install -y jq openssl
125+
fi
126+
- name: Run openssl ↔ pki parity tests
127+
env:
128+
PKI_BIN: ./pki
129+
run: bash tests/interop/openssl_parity.sh

crates/pki-client/src/compat.rs

Lines changed: 148 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,116 @@ fn is_pkcs8_private_key(data: &[u8]) -> bool {
264264
body[3] == 0x30
265265
}
266266

267+
/// Decode a DER OID's content bytes into a dotted string (e.g. "1.2.840.113549.1.1.1").
268+
/// Input is the OID *content* — not including the 0x06 tag or length.
269+
fn decode_oid_bytes(oid: &[u8]) -> Option<String> {
270+
let first = *oid.first()?;
271+
let mut parts = Vec::with_capacity(8);
272+
parts.push(format!("{}", first / 40));
273+
parts.push(format!("{}", first % 40));
274+
let mut acc: u64 = 0;
275+
for &b in &oid[1..] {
276+
acc = acc.checked_shl(7)?.checked_add((b & 0x7F) as u64)?;
277+
if b & 0x80 == 0 {
278+
parts.push(acc.to_string());
279+
acc = 0;
280+
}
281+
}
282+
Some(parts.join("."))
283+
}
284+
285+
/// Extract the AlgorithmIdentifier OID (dotted form) from a PKCS#8 PrivateKeyInfo.
286+
fn extract_pkcs8_oid(der: &[u8]) -> Option<String> {
287+
if !is_pkcs8_private_key(der) {
288+
return None;
289+
}
290+
// Skip outer SEQUENCE tag + length → body of PrivateKeyInfo
291+
let body = skip_der_tag_and_length(der)?;
292+
// Skip version INTEGER(0): 02 01 00 (3 bytes)
293+
if body.len() < 4 {
294+
return None;
295+
}
296+
let after_version = &body[3..];
297+
// Expect AlgorithmIdentifier SEQUENCE (tag 0x30)
298+
if *after_version.first()? != 0x30 {
299+
return None;
300+
}
301+
let alg_body = skip_der_tag_and_length(after_version)?;
302+
// First field is the OID
303+
if *alg_body.first()? != 0x06 {
304+
return None;
305+
}
306+
let oid_len = *alg_body.get(1)? as usize;
307+
if oid_len == 0 || oid_len >= 0x80 {
308+
return None;
309+
}
310+
let oid_content = alg_body.get(2..2 + oid_len)?;
311+
decode_oid_bytes(oid_content)
312+
}
313+
314+
/// Decode PEM → DER bytes. Returns None on parse errors or non-PEM input.
315+
fn pem_to_der(pem_str: &str) -> Option<Vec<u8>> {
316+
pem::parse(pem_str).ok().map(|p| p.contents().to_vec())
317+
}
318+
319+
/// Identify a PKCS#8 private key's algorithm by parsing its AlgorithmIdentifier OID.
320+
/// Returns the `KeyAlgorithm` variant plus a reasonable bit-count hint for display.
321+
/// Accepts either PEM or raw DER input as text.
322+
fn detect_pkcs8_algorithm(pem_or_der: &str) -> Option<(KeyAlgorithm, u32)> {
323+
let der = pem_to_der(pem_or_der)?;
324+
let oid = extract_pkcs8_oid(&der)?;
325+
match oid.as_str() {
326+
// Classical
327+
"1.2.840.113549.1.1.1" => {
328+
// rsaEncryption — size must be inferred from the inner RSAPrivateKey;
329+
// fall back to PEM size heuristic here since we only need a hint.
330+
let bits = rsa_bits_from_pem_len(pem_or_der.len());
331+
Some((KeyAlgorithm::Rsa(bits), bits))
332+
}
333+
"1.2.840.10045.2.1" => {
334+
// ecPublicKey — curve is a nested OID in the AlgorithmIdentifier parameters.
335+
// Check the PEM text for the two curves we support.
336+
if pem_or_der.contains("BgUrgQQAIg") || pem_or_der.contains("GBSuBBAAi") {
337+
Some((KeyAlgorithm::EcP384, 384))
338+
} else {
339+
Some((KeyAlgorithm::EcP256, 256))
340+
}
341+
}
342+
"1.3.101.112" => Some((KeyAlgorithm::Ed25519, 256)),
343+
"1.3.101.113" => Some((KeyAlgorithm::Ed448, 456)),
344+
345+
// PQC — ML-DSA (FIPS 204). `bits` reports the claimed classical-equivalent
346+
// security level so the generic "Size: N bits" UI stays meaningful.
347+
#[cfg(feature = "pqc")]
348+
"2.16.840.1.101.3.4.3.17" => Some((KeyAlgorithm::MlDsa(44), 128)),
349+
#[cfg(feature = "pqc")]
350+
"2.16.840.1.101.3.4.3.18" => Some((KeyAlgorithm::MlDsa(65), 192)),
351+
#[cfg(feature = "pqc")]
352+
"2.16.840.1.101.3.4.3.19" => Some((KeyAlgorithm::MlDsa(87), 256)),
353+
354+
// PQC — SLH-DSA (FIPS 205)
355+
#[cfg(feature = "pqc")]
356+
"2.16.840.1.101.3.4.3.20" => Some((KeyAlgorithm::SlhDsa("SHA2-128s".to_string()), 128)),
357+
#[cfg(feature = "pqc")]
358+
"2.16.840.1.101.3.4.3.22" => Some((KeyAlgorithm::SlhDsa("SHA2-192s".to_string()), 192)),
359+
#[cfg(feature = "pqc")]
360+
"2.16.840.1.101.3.4.3.24" => Some((KeyAlgorithm::SlhDsa("SHA2-256s".to_string()), 256)),
361+
362+
_ => None,
363+
}
364+
}
365+
366+
/// Heuristic: infer RSA key size from PKCS#8 PEM character length.
367+
fn rsa_bits_from_pem_len(len: usize) -> u32 {
368+
if len > 3000 {
369+
4096
370+
} else if len > 2200 {
371+
3072
372+
} else {
373+
2048
374+
}
375+
}
376+
267377
/// Skip the DER tag and length bytes, returning the body.
268378
fn skip_der_tag_and_length(data: &[u8]) -> Option<&[u8]> {
269379
if data.len() < 2 {
@@ -1724,11 +1834,22 @@ impl CsrBuilder {
17241834
));
17251835
}
17261836
#[cfg(feature = "pqc")]
1727-
KeyAlgorithm::MlDsa(_) | KeyAlgorithm::SlhDsa(_) => {
1728-
return Err(anyhow!(
1729-
"ML-DSA/SLH-DSA not yet supported for CSR generation."
1730-
));
1837+
KeyAlgorithm::MlDsa(44) => spork_core::AlgorithmId::MlDsa44,
1838+
#[cfg(feature = "pqc")]
1839+
KeyAlgorithm::MlDsa(65) => spork_core::AlgorithmId::MlDsa65,
1840+
#[cfg(feature = "pqc")]
1841+
KeyAlgorithm::MlDsa(87) => spork_core::AlgorithmId::MlDsa87,
1842+
#[cfg(feature = "pqc")]
1843+
KeyAlgorithm::MlDsa(level) => {
1844+
return Err(anyhow!("Unsupported ML-DSA level: {level}"));
17311845
}
1846+
#[cfg(feature = "pqc")]
1847+
KeyAlgorithm::SlhDsa(ref variant) => match variant.as_str() {
1848+
"SHA2-128s" => spork_core::AlgorithmId::SlhDsaSha2_128s,
1849+
"SHA2-192s" => spork_core::AlgorithmId::SlhDsaSha2_192s,
1850+
"SHA2-256s" => spork_core::AlgorithmId::SlhDsaSha2_256s,
1851+
other => return Err(anyhow!("Unsupported SLH-DSA variant: {other}")),
1852+
},
17321853
};
17331854

17341855
// Load key pair from PEM
@@ -1943,6 +2064,29 @@ pub fn load_private_key(path: &Path) -> Result<PrivateKey> {
19432064
// Check if encrypted
19442065
let encrypted = data.contains("ENCRYPTED");
19452066

2067+
// First, try the authoritative path: decode PKCS#8 DER and match the
2068+
// AlgorithmIdentifier OID. This is the only way to reliably distinguish
2069+
// ML-DSA / SLH-DSA (FIPS 204/205) from classical keys, since PQC PKCS#8
2070+
// containers can be smaller than classical RSA PEMs and otherwise land
2071+
// in the size-based RSA/EC heuristics below. Issue #97.
2072+
if !encrypted {
2073+
if let Some((algo, bits_hint)) = detect_pkcs8_algorithm(&data) {
2074+
let curve = match algo {
2075+
KeyAlgorithm::EcP256 => Some("P-256".to_string()),
2076+
KeyAlgorithm::EcP384 => Some("P-384".to_string()),
2077+
_ => None,
2078+
};
2079+
return Ok(PrivateKey {
2080+
algorithm: algo,
2081+
key_size: Some(bits_hint),
2082+
curve,
2083+
pem: data,
2084+
bits: bits_hint,
2085+
encrypted,
2086+
});
2087+
}
2088+
}
2089+
19462090
// Detect key type from PEM header and content
19472091
// For PKCS#8 keys, we need to check the base64-encoded OID markers:
19482092
// - P-256: contains "BggqhkjOPQMBBw" (OID 1.2.840.10045.3.1.7 = secp256r1)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! Algorithm-detection regression tests for `pki key show`.
2+
//!
3+
//! Guards against issue #97, where ML-DSA / SLH-DSA keys were misreported as
4+
//! "EC P-256" because the detector fell through to size-based heuristics when
5+
//! the PKCS#8 OID was an unrecognised PQC identifier.
6+
7+
use std::path::PathBuf;
8+
use std::process::Command;
9+
use tempfile::TempDir;
10+
11+
fn pki_binary() -> PathBuf {
12+
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
13+
path.pop();
14+
path.pop();
15+
path.push("target");
16+
path.push("debug");
17+
path.push("pki");
18+
path
19+
}
20+
21+
fn binary_exists() -> bool {
22+
pki_binary().exists()
23+
}
24+
25+
fn gen_and_show(alg: &str) -> Option<(String, String)> {
26+
if !binary_exists() {
27+
return None;
28+
}
29+
let tmp = TempDir::new().ok()?;
30+
let key_path = tmp.path().join(format!("{alg}.key"));
31+
let keygen = Command::new(pki_binary())
32+
.args(["key", "gen", alg, "-o"])
33+
.arg(&key_path)
34+
.output()
35+
.ok()?;
36+
if !keygen.status.success() {
37+
let stderr = String::from_utf8_lossy(&keygen.stderr);
38+
if stderr.contains("Unknown algorithm") || stderr.contains("not supported") {
39+
return None; // pqc feature off → skip
40+
}
41+
panic!("keygen {alg} failed: {stderr}");
42+
}
43+
let show = Command::new(pki_binary())
44+
.args(["key", "show", "-f", "json"])
45+
.arg(&key_path)
46+
.output()
47+
.ok()?;
48+
assert!(
49+
show.status.success(),
50+
"key show failed for {alg}: {}",
51+
String::from_utf8_lossy(&show.stderr)
52+
);
53+
Some((
54+
String::from_utf8_lossy(&show.stdout).into_owned(),
55+
String::from_utf8_lossy(&show.stderr).into_owned(),
56+
))
57+
}
58+
59+
fn assert_algo(alg: &str, expected_substrings: &[&str]) {
60+
let Some((stdout, _)) = gen_and_show(alg) else {
61+
return;
62+
};
63+
for needle in expected_substrings {
64+
assert!(
65+
stdout.contains(needle),
66+
"`pki key show` output for {alg} missing `{needle}`:\n{stdout}"
67+
);
68+
}
69+
// Catch the #97 regression directly: PQC keys must NOT be reported as EcP256.
70+
if !alg.starts_with("ec-") && !alg.starts_with("rsa") && !alg.starts_with("ed") {
71+
assert!(
72+
!stdout.contains("\"EcP256\""),
73+
"PQC key {alg} misidentified as EcP256 (regression of #97):\n{stdout}"
74+
);
75+
}
76+
}
77+
78+
#[test]
79+
fn key_show_detects_ec_p256() {
80+
assert_algo("ec-p256", &["\"EcP256\"", "\"P-256\""]);
81+
}
82+
83+
#[test]
84+
fn key_show_detects_rsa() {
85+
// Default RSA size is 4096 (2048 is refused under the secure-defaults policy).
86+
assert_algo("rsa", &["\"Rsa\"", "4096"]);
87+
}
88+
89+
#[test]
90+
fn key_show_detects_mldsa44() {
91+
assert_algo("ml-dsa-44", &["MlDsa"]);
92+
}
93+
94+
#[test]
95+
fn key_show_detects_mldsa65() {
96+
assert_algo("ml-dsa-65", &["MlDsa"]);
97+
}
98+
99+
#[test]
100+
fn key_show_detects_mldsa87() {
101+
assert_algo("ml-dsa-87", &["MlDsa"]);
102+
}
103+
104+
#[test]
105+
fn key_show_detects_slhdsa_128s() {
106+
assert_algo("slh-dsa-128s", &["SlhDsa"]);
107+
}

tests/interop/openssl_parity.sh

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,16 +145,23 @@ assert_eq "cert/signature_algorithm_name" "sha256WithRSAEncryption" "$PKI_SIGALG
145145
# SAN: openssl decodes 4 entries (2 DNS, 1 IP, 1 email)
146146
PKI_SAN_COUNT=$(echo "$PKI_CERT_JSON" | jq -r '.san | length')
147147
assert_eq "cert/SAN count" "4" "$PKI_SAN_COUNT"
148-
PKI_SAN_ALL=$(echo "$PKI_CERT_JSON" | jq -r '.san[]' | tr '\n' ',' | sed 's/,$//')
148+
# SAN is serialized as a tagged enum: [{"Dns":"x"}, {"Ip":"y"}, …]. Flatten to
149+
# "TAG:value" strings (Dns→DNS, Ip→IP) so assertions stay algorithm-readable.
150+
PKI_SAN_ALL=$(echo "$PKI_CERT_JSON" | jq -r '
151+
.san[] | to_entries[]
152+
| ((.key | ascii_upcase | sub("EMAIL"; "email")) + ":" + .value)
153+
' | tr '\n' ',' | sed 's/,$//')
149154
assert_contains "cert/SAN DNS:ee.parity.test" "DNS:ee.parity.test" "$PKI_SAN_ALL"
150155
assert_contains "cert/SAN DNS:alt.parity.test" "DNS:alt.parity.test" "$PKI_SAN_ALL"
151156
assert_contains "cert/SAN IP:10.0.0.1" "IP:10.0.0.1" "$PKI_SAN_ALL"
152157
assert_contains "cert/SAN email:admin@parity.test" "admin@parity.test" "$PKI_SAN_ALL"
153158

154-
# EKU
159+
# EKU — pki emits RFC 5280 long-form labels ("TLS Web Server Authentication");
160+
# openssl's short names are a display quirk, not a canonical format. Assert on
161+
# the long form here.
155162
PKI_EKU=$(echo "$PKI_CERT_JSON" | jq -r '.extended_key_usage[]' | tr '\n' ',' | sed 's/,$//')
156-
assert_contains "cert/EKU serverAuth" "serverAuth" "$PKI_EKU"
157-
assert_contains "cert/EKU clientAuth" "clientAuth" "$PKI_EKU"
163+
assert_contains "cert/EKU serverAuth" "TLS Web Server Authentication" "$PKI_EKU"
164+
assert_contains "cert/EKU clientAuth" "TLS Web Client Authentication" "$PKI_EKU"
158165

159166
echo ""
160167

@@ -177,7 +184,10 @@ assert_eq "csr/key_size" "2048" "$PKI_CSR_KEYSIZE"
177184
PKI_CSR_SAN_COUNT=$(echo "$PKI_CSR_JSON" | jq -r '.san | length')
178185
assert_eq "csr/SAN count" "4" "$PKI_CSR_SAN_COUNT"
179186

180-
PKI_CSR_SAN_ALL=$(echo "$PKI_CSR_JSON" | jq -r '.san[]' | tr '\n' ',' | sed 's/,$//')
187+
PKI_CSR_SAN_ALL=$(echo "$PKI_CSR_JSON" | jq -r '
188+
.san[] | to_entries[]
189+
| ((.key | ascii_upcase | sub("EMAIL"; "email")) + ":" + .value)
190+
' | tr '\n' ',' | sed 's/,$//')
181191
assert_contains "csr/SAN DNS:ee.parity.test" "DNS:ee.parity.test" "$PKI_CSR_SAN_ALL"
182192
assert_contains "csr/SAN IP:10.0.0.1" "IP:10.0.0.1" "$PKI_CSR_SAN_ALL"
183193
assert_contains "csr/SAN email:admin@parity.test" "admin@parity.test" "$PKI_CSR_SAN_ALL"
@@ -288,9 +298,11 @@ $PKI csr create --key "$WORK/pki-rsa.key" --cn "pki.parity.test" \
288298
--san "dns:pki.parity.test,dns:alt.pki.parity.test,ip:10.0.0.2,email:pki@parity.test" \
289299
-o "$WORK/pki.csr" -q
290300

291-
# openssl must parse pki's CSR
292-
OSL_CSR_TEXT=$(openssl req -in "$WORK/pki.csr" -noout -text 2>&1)
293-
assert_contains "reverse/openssl parses pki CSR" "CN = pki.parity.test" "$OSL_CSR_TEXT"
301+
# openssl must parse pki's CSR — normalize DN spacing: openssl ≤3.4 emits
302+
# "CN = X" (spaces) while 3.5+ emits "CN=X". Collapse so the assertion works
303+
# on either.
304+
OSL_CSR_TEXT=$(openssl req -in "$WORK/pki.csr" -noout -text 2>&1 | sed 's/ = /=/g')
305+
assert_contains "reverse/openssl parses pki CSR" "CN=pki.parity.test" "$OSL_CSR_TEXT"
294306
assert_contains "reverse/openssl sees pki DNS SAN" "pki.parity.test" "$OSL_CSR_TEXT"
295307
assert_contains "reverse/openssl sees pki IP SAN" "10.0.0.2" "$OSL_CSR_TEXT"
296308
assert_contains "reverse/openssl sees pki email SAN" "pki@parity.test" "$OSL_CSR_TEXT"

0 commit comments

Comments
 (0)