generation 2: port identity types, fingerprint, pem + signing

port-identity-types (src/identity.rs):
- TlsIdentity (four variants), Ed25519SecretKey, AcmeDirectory ported
  verbatim from alknet-core config.rs; OQ-TLS-02 + server-only docs
  on the variants; 9 in-module tests incl. Debug-no-leak
- dep decision: rand_core 0.6 (+getrandom) with ed25519-dalek
  rand_core feature, NOT rand (lockfile rand 0.10/rand_core 0.10
  traits are incompatible with ed25519-dalek 2.2's CryptoRngCore);
  rand stays out of the tree entirely

port-fingerprint (src/fingerprint.rs):
- fingerprint_from_cert_der, extract_ed25519_raw_key_from_spki,
  DerParser ported verbatim; production code sha2 + manual DER
  (+hex for the normalized formats)
- 16 tests: 7 ported + 9 new malformed-DER edges (the extraction
  had none despite the invariant naming them)
- empty-input behavior: matches extraction's actual code (always
  Some via the SHA-256 fallback); doc records the deviation from
  the stale None claim

port-pem-signing (src/pem.rs, src/signing.rs):
- load_cert_chain/load_private_key remapped to TlsError::CertLoad
  per ADR-002; InvalidData no-key path kept
- Ed25519SigningKey rewired to crate::identity::Ed25519SecretKey
  (the one intentional change); rcgen PEM round-trip test added

lib.rs re-export block: fingerprint + pem + signing + identity lines
landed; server/client/credentials pending their port tasks

Verification: cargo test (36), cargo test --all-features (37),
clippy -D warnings (default+all-features), fmt --check, feature
checks (noq/tcp/acme) — all green
This commit is contained in:
2026-09-10 13:48:32 +00:00
parent f68234133f
commit 4bdc12e84f
10 changed files with 1058 additions and 36 deletions
Generated
+2
View File
@@ -46,6 +46,7 @@ dependencies = [
"hex",
"noq",
"noq-proto",
"rand_core 0.6.4",
"rcgen",
"rustls",
"rustls-acme",
@@ -571,6 +572,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"subtle",
+3 -1
View File
@@ -27,8 +27,10 @@ rustls-pemfile = "2"
rustls-native-certs = "0.8"
webpki-roots = "0.26"
rcgen = "0.13"
ed25519-dalek = "2"
ed25519-dalek = { version = "2", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
hex = "0.4"
tracing = "0.1"
thiserror = "2"
+448 -4
View File
@@ -4,7 +4,451 @@
//!
//! Fingerprint formats (alknet ADR-030 §6):
//!
//! - **Ed25519 raw key** (RFC 7250 SPKI): `ed25519:<hex of 32-byte pub key>`
//! — the fingerprint IS the trust anchor.
//! - **X.509 cert**: `SHA256:<hex of DER>` — the fallback for everything
//! else.
//! - **Ed25519 raw key** (RFC 7250 SPKI): `ed25519:<hex of 32-byte pub key>`.
//! The fingerprint IS the trust anchor — raw-key remotes have no CA, so the
//! fingerprint is the identity. Normalized to `ed25519:<hex>` across quinn
//! and iroh (alknet ADR-030 §6).
//! - **X.509 cert**: `SHA256:<hex of DER>`. Used for fingerprint pinning of
//! known remotes with a prior trust relationship — not for arbitrary
//! public APIs (those use CA verification).
//!
//! Shared by the client-side `FingerprintPinVerifier` (which matches the
//! server's presented cert against a pinned fingerprint) and the server side
//! (which extracts the fingerprint from the presented client cert and hands
//! the string to the caller — peer-id resolution stays out of this crate,
//! ADR-005).
use sha2::{Digest, Sha256};
/// Compute the fingerprint of a TLS certificate DER (RFC 7250 raw public key
/// SPKI or X.509 cert). Returns `ed25519:<hex>` when `cert_der` is an Ed25519
/// SPKI, otherwise `SHA256:<hex of full DER>`.
///
/// The SHA-256 is the fallback for everything that is not an Ed25519 SPKI —
/// non-Ed25519 SPKIs, malformed DER, and the empty slice all hash to a
/// `SHA256:` fingerprint. The return is therefore always `Some`
/// (the alknet-core original's doc claimed `None` for empty input, but its
/// code never returns `None`; this port matches the code's behavior).
pub fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
if let Some(raw_key) = extract_ed25519_raw_key_from_spki(cert_der) {
return Some(format!("ed25519:{}", hex::encode(raw_key)));
}
let mut hasher = Sha256::new();
hasher.update(cert_der);
let digest = hasher.finalize();
Some(format!("SHA256:{}", hex::encode(digest)))
}
/// `SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }`
/// `AlgorithmIdentifier ::= SEQUENCE { algorithm OBJECT IDENTIFIER, parameters ANY OPTIONAL }`
/// For Ed25519 the algorithm OID is `1.3.101.112` (DER bytes `2b 65 70`), with
/// no parameters, and `subjectPublicKey` is a BIT STRING containing one
/// unused-bits byte (`0x00`) followed by the 32-byte raw Ed25519 public key.
/// Returns the 32 raw key bytes when `cert_der` is an RFC 7250 raw public key
/// (SPKI) with the Ed25519 algorithm identifier; returns `None` otherwise
/// (X.509 cert, non-Ed25519 SPKI, or malformed DER), in which case callers
/// should fall back to hashing the full DER.
pub fn extract_ed25519_raw_key_from_spki(cert_der: &[u8]) -> Option<[u8; 32]> {
const ED25519_OID_BYTES: [u8; 3] = [0x2b, 0x65, 0x70];
let mut parser = DerParser::new(cert_der);
let spki_contents = parser.expect_sequence()?;
let mut spki_parser = DerParser::new(spki_contents);
let alg_id_contents = spki_parser.expect_sequence()?;
let mut alg_id_parser = DerParser::new(alg_id_contents);
let oid_bytes = alg_id_parser.expect_oid()?;
if oid_bytes != ED25519_OID_BYTES {
return None;
}
let bit_string_contents = spki_parser.expect_bit_string()?;
if bit_string_contents.len() != 33 || bit_string_contents[0] != 0x00 {
return None;
}
let mut raw_key = [0u8; 32];
raw_key.copy_from_slice(&bit_string_contents[1..33]);
Some(raw_key)
}
struct DerParser<'a> {
bytes: &'a [u8],
}
impl<'a> DerParser<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes }
}
fn read_tlv(&mut self) -> Option<(u8, &'a [u8])> {
let (tag, len_size, header_len) = self.decode_header()?;
let total = header_len.checked_add(len_size)?;
if total > self.bytes.len() {
return None;
}
let content = &self.bytes[header_len..total];
self.bytes = &self.bytes[total..];
Some((tag, content))
}
fn decode_header(&self) -> Option<(u8, usize, usize)> {
if self.bytes.is_empty() {
return None;
}
let tag = self.bytes[0];
if self.bytes.len() < 2 {
return None;
}
let first_len = self.bytes[1];
if first_len < 0x80 {
return Some((tag, first_len as usize, 2));
}
let num_bytes = (first_len & 0x7f) as usize;
if num_bytes == 0 || num_bytes > 4 {
return None;
}
if self.bytes.len() < 2 + num_bytes {
return None;
}
let mut len: usize = 0;
for i in 0..num_bytes {
len = (len << 8) | (self.bytes[2 + i] as usize);
}
Some((tag, len, 2 + num_bytes))
}
fn expect_sequence(&mut self) -> Option<&'a [u8]> {
let (tag, content) = self.read_tlv()?;
if tag == 0x30 {
Some(content)
} else {
None
}
}
fn expect_oid(&mut self) -> Option<&'a [u8]> {
let (tag, content) = self.read_tlv()?;
if tag == 0x06 {
Some(content)
} else {
None
}
}
fn expect_bit_string(&mut self) -> Option<&'a [u8]> {
let (tag, content) = self.read_tlv()?;
if tag == 0x03 {
Some(content)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const RAW_KEY_A: [u8; 32] = [
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xf5, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x52, 0x7a, 0xbd,
0xd7, 0x75, 0x62, 0x8d, 0x87, 0x46, 0x35, 0xc5, 0xac, 0x2d, 0xce, 0x9a, 0x0b, 0x5f, 0x06,
0x4b, 0x4b,
];
const RAW_KEY_B: [u8; 32] = [
0x4c, 0xcd, 0x08, 0x9b, 0x28, 0xff, 0x9d, 0xba, 0xe4, 0x62, 0x5b, 0x20, 0x4d, 0x14, 0x94,
0x9d, 0xa5, 0x91, 0x59, 0x6b, 0x10, 0x46, 0xd1, 0x55, 0x6d, 0x63, 0x81, 0x0b, 0xf7, 0xe9,
0x8e, 0x71,
];
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key).to_vec()
}
#[test]
fn fingerprint_from_cert_der_produces_sha256_hex_format() {
let cert_der = b"fake-leaf-cert-der-bytes";
let fp = fingerprint_from_cert_der(cert_der).expect("non-empty cert produces fingerprint");
assert!(
fp.starts_with("SHA256:"),
"fingerprint must be SHA256-prefixed, got: {fp}"
);
let hex_part = &fp["SHA256:".len()..];
assert_eq!(
hex_part.len(),
64,
"hex digest must be 64 chars (32 bytes), got: {fp}"
);
assert!(
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
"hex part must be lowercase hex, got: {fp}"
);
let mut hasher = Sha256::new();
hasher.update(cert_der);
let expected = format!("SHA256:{}", hex::encode(hasher.finalize()));
assert_eq!(fp, expected, "fingerprint must match SHA-256 of cert DER");
}
#[test]
fn fingerprint_from_cert_der_deterministic() {
let cert = b"some-cert";
let a = fingerprint_from_cert_der(cert).unwrap();
let b = fingerprint_from_cert_der(cert).unwrap();
assert_eq!(a, b, "same cert DER must produce same fingerprint");
}
#[test]
fn fingerprint_from_ed25519_spki_produces_ed25519_prefix() {
let raw_key = RAW_KEY_A;
let spki_der = build_ed25519_spki_der(&raw_key);
let fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint");
assert!(
fp.starts_with("ed25519:"),
"Ed25519 raw key SPKI must produce ed25519: fingerprint, got: {fp}"
);
}
#[test]
fn fingerprint_from_ed25519_spki_is_lowercase_hex_of_32_byte_key() {
let raw_key = RAW_KEY_A;
let spki_der = build_ed25519_spki_der(&raw_key);
let fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint");
let hex_part = &fp["ed25519:".len()..];
assert_eq!(
hex_part.len(),
64,
"ed25519 hex part must be 64 chars (32 bytes), got: {fp}"
);
assert!(
hex_part
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"ed25519 hex part must be lowercase hex, got: {fp}"
);
assert_eq!(
hex_part,
hex::encode(raw_key),
"ed25519 fingerprint must be hex of the raw 32-byte key, not the DER wrapper"
);
}
#[test]
fn fingerprint_from_ed25519_spki_matches_iroh_format() {
let raw_key = RAW_KEY_B;
let spki_der = build_ed25519_spki_der(&raw_key);
let quinn_fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint");
let iroh_fp = format!("ed25519:{}", hex::encode(raw_key));
assert_eq!(
quinn_fp, iroh_fp,
"same Ed25519 key must produce the same fingerprint via quinn SPKI and iroh NodeId paths"
);
}
#[test]
fn extract_ed25519_raw_key_from_spki_returns_source_key_bytes() {
let raw_key = RAW_KEY_B;
let spki_der = build_ed25519_spki_der(&raw_key);
let extracted =
extract_ed25519_raw_key_from_spki(&spki_der).expect("valid Ed25519 SPKI extracts key");
assert_eq!(
extracted, raw_key,
"extracted raw key must equal the source 32-byte key"
);
}
#[test]
fn fingerprint_from_x509_cert_stays_sha256_of_der() {
let cert_der = b"fake-x509-cert-der-bytes-not-an-spki";
let fp = fingerprint_from_cert_der(cert_der).expect("x509 produces fingerprint");
assert!(
fp.starts_with("SHA256:"),
"X.509 cert must keep SHA256: format, got: {fp}"
);
let mut hasher = Sha256::new();
hasher.update(cert_der);
assert_eq!(
fp,
format!("SHA256:{}", hex::encode(hasher.finalize())),
"X.509 fingerprint must be SHA-256 of cert DER"
);
assert_eq!(
extract_ed25519_raw_key_from_spki(cert_der),
None,
"X.509 cert must not extract an Ed25519 raw key"
);
}
#[test]
fn fingerprint_from_non_ed25519_spki_falls_back_to_sha256() {
let raw_key = [0u8; 32];
let fake_non_ed25519_spki: Vec<u8> = vec![
0x30, 0x1c, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x06, 0x01, 0x03, 0x15, 0x00, 0x20,
]
.into_iter()
.chain(raw_key.iter().copied())
.collect();
let fp = fingerprint_from_cert_der(&fake_non_ed25519_spki).expect("fallback fingerprint");
assert!(
fp.starts_with("SHA256:"),
"non-Ed25519 SPKI must fall back to SHA256, got: {fp}"
);
assert_eq!(
extract_ed25519_raw_key_from_spki(&fake_non_ed25519_spki),
None,
"non-Ed25519 SPKI must not extract an Ed25519 raw key"
);
}
#[test]
fn empty_input_hashes_to_sha256_of_empty_and_extracts_nothing() {
assert_eq!(
extract_ed25519_raw_key_from_spki(&[]),
None,
"empty input must not extract a raw key"
);
let fp = fingerprint_from_cert_der(&[]).expect("empty input still hashes");
let mut hasher = Sha256::new();
hasher.update([]);
let expected = format!("SHA256:{}", hex::encode(hasher.finalize()));
assert_eq!(
fp, expected,
"empty input must fall back to the SHA-256 of the empty slice"
);
}
#[test]
fn malformed_der_truncated_headers_extract_nothing() {
assert_eq!(extract_ed25519_raw_key_from_spki(&[]), None);
assert_eq!(extract_ed25519_raw_key_from_spki(&[0x30]), None);
assert_eq!(extract_ed25519_raw_key_from_spki(&[0x30, 0x10]), None);
assert_eq!(extract_ed25519_raw_key_from_spki(&[0x30, 0x10, 0x01]), None);
assert_eq!(
extract_ed25519_raw_key_from_spki(&[0x30, 0x10, 0x30, 0x05, 0x06, 0x03]),
None,
"alg-id SEQUENCE content shorter than its declared length must fail"
);
}
#[test]
fn malformed_der_wrong_outer_tag_extracts_nothing() {
let inner = build_ed25519_spki_der(&RAW_KEY_A);
let mut not_a_sequence = vec![0x04u8];
not_a_sequence.extend_from_slice(&inner);
assert_eq!(
extract_ed25519_raw_key_from_spki(&not_a_sequence),
None,
"outer tag must be SEQUENCE (0x30)"
);
}
#[test]
fn malformed_der_long_form_lengths_extract_nothing() {
assert_eq!(
extract_ed25519_raw_key_from_spki(&[0x30, 0x80, 0x01, 0x02]),
None,
"0x80 (indefinite length, zero length octets) must be rejected"
);
assert_eq!(
extract_ed25519_raw_key_from_spki(&[0x30, 0x81, 0x05, 0x01]),
None,
"long-form length exceeding remaining bytes must be rejected"
);
assert_eq!(
extract_ed25519_raw_key_from_spki(&[0x30, 0x85, 0x01, 0x02, 0x03, 0x04, 0x05]),
None,
"more than 4 length bytes must be rejected"
);
assert_eq!(
extract_ed25519_raw_key_from_spki(&[0x30, 0x81]),
None,
"truncated long-form header must be rejected"
);
}
#[test]
fn well_formed_long_form_length_spki_extracts_the_key() {
let raw_key = RAW_KEY_A;
let inner = build_ed25519_spki_der(&raw_key);
let mut spki_der = vec![0x30u8, 0x81, 0x80];
spki_der.extend_from_slice(&inner[2..]);
spki_der.resize(3 + 128, 0x00);
assert_eq!(
extract_ed25519_raw_key_from_spki(&spki_der),
Some(raw_key),
"long-form outer length must parse and extract the key"
);
}
#[test]
fn wrong_oid_in_valid_spki_extracts_nothing() {
let raw_key = [0u8; 32];
let wrong_oid_spki: Vec<u8> = vec![
0x30, 0x2d, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x06, 0x01, 0x03, 0x21, 0x00,
]
.into_iter()
.chain(raw_key.iter().copied())
.collect();
assert_eq!(
extract_ed25519_raw_key_from_spki(&wrong_oid_spki),
None,
"non-Ed25519 OID (1.3.6.1) with well-formed BIT STRING must be rejected"
);
}
#[test]
fn bad_bit_string_lengths_extract_nothing() {
let alg_id: Vec<u8> = vec![0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70];
let bit_string_32: Vec<u8> = vec![0x03, 0x20, 0x00]
.into_iter()
.chain([0u8; 31].iter().copied())
.collect();
let mut spki = vec![0x30u8, 0x2b];
spki.extend_from_slice(&alg_id);
spki.extend_from_slice(&bit_string_32);
assert_eq!(
extract_ed25519_raw_key_from_spki(&spki),
None,
"BIT STRING content of 32 bytes (missing unused-bits byte) must be rejected"
);
let bit_string_34: Vec<u8> = vec![0x03, 0x22, 0x00]
.into_iter()
.chain([0u8; 33].iter().copied())
.collect();
let mut spki = vec![0x30u8, 0x2d];
spki.extend_from_slice(&alg_id);
spki.extend_from_slice(&bit_string_34);
assert_eq!(
extract_ed25519_raw_key_from_spki(&spki),
None,
"BIT STRING content of 34 bytes must be rejected"
);
let bit_string_unused_bits: Vec<u8> = vec![0x03, 0x21, 0x01]
.into_iter()
.chain([0u8; 32].iter().copied())
.collect();
let mut spki = vec![0x30u8, 0x2d];
spki.extend_from_slice(&alg_id);
spki.extend_from_slice(&bit_string_unused_bits);
assert_eq!(
extract_ed25519_raw_key_from_spki(&spki),
None,
"non-zero unused-bits byte must be rejected"
);
}
#[test]
fn malformed_der_still_falls_back_to_sha256_fingerprint() {
let malformed = [0x30u8, 0x81, 0x05, 0x01];
let fp =
fingerprint_from_cert_der(&malformed).expect("malformed DER still hashes to SHA256");
let mut hasher = Sha256::new();
hasher.update(malformed);
assert_eq!(
fp,
format!("SHA256:{}", hex::encode(hasher.finalize())),
"malformed DER must fall back to SHA-256 of the full input"
);
}
}
+233
View File
@@ -1,2 +1,235 @@
//! Identity types: [`TlsIdentity`], [`Ed25519SecretKey`], [`AcmeDirectory`]
//! (ADR-005, moved from alknet-core `config.rs`).
//!
//! The three types are the identity half of config construction: they
//! describe *what* a server presents and *what* a client presents, and they
//! are consumed by the server and client config builders. Auth-layer types
//! (`PeerEntry`, `AuthPolicy`, fingerprint → peer-id resolution) are
//! deliberately out — ADR-005's carve-out.
use std::path::PathBuf;
/// Ed25519 signing key backed by [`ed25519_dalek::SigningKey`].
///
/// The byte surface (`from_bytes(&[u8; 32])` / `as_bytes() -> [u8; 32]`)
/// is load-bearing: iroh's `iroh_base::SecretKey` consumes the 32 raw
/// bytes (OQ-TLS-07, verified against iroh 1.1 in Phase 0), and the
/// `Ed25519SigningKey` helper signs through it.
#[derive(Clone)]
pub struct Ed25519SecretKey(ed25519_dalek::SigningKey);
impl Ed25519SecretKey {
/// Generate a new key from OS randomness.
pub fn generate() -> Self {
Self(ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng))
}
/// Rebuild a key from its 32 raw bytes (the iroh interop surface).
pub fn from_bytes(bytes: &[u8; 32]) -> Self {
Self(ed25519_dalek::SigningKey::from_bytes(bytes))
}
/// The 32 raw secret bytes.
pub fn as_bytes(&self) -> [u8; 32] {
self.0.to_bytes()
}
/// The matching public key.
pub fn public(&self) -> ed25519_dalek::VerifyingKey {
self.0.verifying_key()
}
/// Sign a message.
pub fn sign(&self, message: &[u8]) -> ed25519_dalek::Signature {
use ed25519_dalek::Signer;
self.0.sign(message)
}
}
impl std::fmt::Debug for Ed25519SecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ed25519SecretKey").finish_non_exhaustive()
}
}
/// ACME directory choice: the pinned Let's Encrypt endpoints or a custom
/// URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcmeDirectory {
/// The Let's Encrypt production directory.
Production,
/// The Let's Encrypt staging directory (rate limits are relaxed;
/// certificates it issues are not trusted by browsers).
Staging,
/// A custom ACME directory URL.
Custom(String),
}
impl AcmeDirectory {
/// The directory URL: pinned strings for `Production` / `Staging`,
/// the stored URL for [`AcmeDirectory::Custom`].
pub fn url(&self) -> &str {
match self {
AcmeDirectory::Production => "https://acme-v02.api.letsencrypt.org/directory",
AcmeDirectory::Staging => "https://acme-staging-v02.api.letsencrypt.org/directory",
AcmeDirectory::Custom(url) => url,
}
}
}
/// What identity a TLS side presents and how its cert material is
/// obtained (alknet ADR-027's identity model).
#[derive(Debug, Clone)]
pub enum TlsIdentity {
/// X.509 cert + key PEM files on disk.
X509 {
/// Path to the certificate chain PEM file.
cert: PathBuf,
/// Path to the private key PEM file.
key: PathBuf,
},
/// A raw Ed25519 key: the cert is synthesized at config-construction
/// time and peers are identified by the public key's fingerprint.
RawKey(Ed25519SecretKey),
/// A generated self-signed dev cert (server side). On the **client**
/// path this identity presents nothing — `NoClientCertResolver`, the
/// OQ-TLS-02 resolution: `SelfSigned` as a *local* identity meaning
/// "present nothing" is coherent, and presenting a self-signed
/// client cert would add nothing the fingerprint path uses.
SelfSigned,
/// An ACME-managed cert (Let's Encrypt or a custom directory). This
/// is a **server-only** identity: using it for client auth is a
/// config error (`TlsError::AcmeConfig` on the client path) — there
/// is no client-auth cert to present while the order is pending.
Acme {
/// The domains the certificate covers.
domains: Vec<String>,
/// Directory where issued certificates are cached.
cache_dir: PathBuf,
/// Which ACME directory to order from.
directory: AcmeDirectory,
/// ACME contact addresses (e.g. `mailto:` URIs).
contact: Vec<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::Verifier;
#[test]
fn ed25519_secret_key_round_trips_bytes() {
let key = Ed25519SecretKey::generate();
let bytes = key.as_bytes();
let restored = Ed25519SecretKey::from_bytes(&bytes);
assert_eq!(restored.as_bytes(), bytes);
}
#[test]
fn ed25519_secret_key_sign_verifies_against_public_key() {
let key = Ed25519SecretKey::generate();
let public = key.public();
let message = b"alktls identity coverage check";
let signature: ed25519_dalek::Signature = key.sign(message);
assert_eq!(signature.to_bytes().len(), 64);
assert!(
public.verify(message, &signature).is_ok(),
"signature produced by Ed25519SecretKey::sign must verify under its public key"
);
}
#[test]
fn ed25519_secret_key_sign_rejects_tampered_message() {
let key = Ed25519SecretKey::generate();
let public = key.public();
let signature: ed25519_dalek::Signature = key.sign(b"original message");
assert!(
public.verify(b"tampered message", &signature).is_err(),
"signature must not verify against a different message"
);
}
#[test]
fn ed25519_secret_key_debug_does_not_leak_material() {
let key = Ed25519SecretKey::generate();
let dbg = format!("{key:?}");
assert!(dbg.contains("Ed25519SecretKey"));
assert!(!dbg.contains("SigningKey"));
let raw = hex::encode(key.as_bytes());
assert!(
!dbg.contains(&raw),
"Debug output must not contain the raw key bytes"
);
}
#[test]
fn ed25519_secret_key_public_matches_underlying_signing_key() {
let key = Ed25519SecretKey::generate();
let public = key.public();
assert_eq!(public.to_bytes().len(), 32);
}
#[test]
fn acme_directory_urls_are_pinned() {
assert_eq!(
AcmeDirectory::Production.url(),
"https://acme-v02.api.letsencrypt.org/directory"
);
assert_eq!(
AcmeDirectory::Staging.url(),
"https://acme-staging-v02.api.letsencrypt.org/directory"
);
assert_eq!(
AcmeDirectory::Custom("https://acme.example/dir".to_string()).url(),
"https://acme.example/dir"
);
}
#[test]
fn tls_identity_x509_construct() {
let id = TlsIdentity::X509 {
cert: PathBuf::from("/etc/cert.pem"),
key: PathBuf::from("/etc/key.pem"),
};
match id {
TlsIdentity::X509 { cert, key } => {
assert_eq!(cert, PathBuf::from("/etc/cert.pem"));
assert_eq!(key, PathBuf::from("/etc/key.pem"));
}
_ => panic!("expected X509"),
}
}
#[test]
fn tls_identity_self_signed() {
let id = TlsIdentity::SelfSigned;
let s = format!("{id:?}");
assert!(s.contains("SelfSigned"));
}
#[test]
fn tls_identity_acme_construct() {
let id = TlsIdentity::Acme {
domains: vec!["example.com".to_string()],
cache_dir: PathBuf::from("/var/cache/alktls-acme"),
directory: AcmeDirectory::Staging,
contact: vec!["mailto:ops@example.com".to_string()],
};
match id {
TlsIdentity::Acme {
domains,
cache_dir,
directory,
contact,
} => {
assert_eq!(domains, vec!["example.com".to_string()]);
assert_eq!(cache_dir, PathBuf::from("/var/cache/alktls-acme"));
assert_eq!(directory, AcmeDirectory::Staging);
assert_eq!(contact, vec!["mailto:ops@example.com".to_string()]);
}
_ => panic!("expected Acme"),
}
}
}
+10 -3
View File
@@ -30,9 +30,16 @@ pub mod pem;
pub mod server;
pub mod signing;
// The re-export block (the documented public API surface) lands per
// module as each port task completes; the final shape is ADR-006's
// module map + overview.md §What the crate is.
pub use fingerprint::{extract_ed25519_raw_key_from_spki, fingerprint_from_cert_der};
pub use pem::{load_cert_chain, load_private_key};
pub use signing::Ed25519SigningKey;
pub use identity::{AcmeDirectory, Ed25519SecretKey, TlsIdentity};
// The remaining re-export lines land per module as each port task
// completes: credentials, fingerprint, server, client, pem, signing
// (port-client completes the block).
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
+66
View File
@@ -1,2 +1,68 @@
//! PEM loading helpers: [`load_cert_chain`], [`load_private_key`].
//! One copy used by both server and client.
use std::io;
use std::path::Path;
use crate::TlsError;
pub fn load_cert_chain(
path: &Path,
) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>, TlsError> {
let bytes = std::fs::read(path).map_err(TlsError::CertLoad)?;
let mut reader = io::BufReader::new(bytes.as_slice());
rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| TlsError::CertLoad(io::Error::other(e)))
}
pub fn load_private_key(
path: &Path,
) -> Result<rustls::pki_types::PrivateKeyDer<'static>, TlsError> {
let bytes = std::fs::read(path)?;
let mut reader = io::BufReader::new(bytes.as_slice());
match rustls_pemfile::private_key(&mut reader) {
Ok(Some(key)) => Ok(key),
Ok(None) => Err(io::Error::new(
io::ErrorKind::InvalidData,
"no private key found in file",
)),
Err(e) => Err(io::Error::other(e)),
}
.map_err(TlsError::CertLoad)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_private_key_returns_error_when_no_key_present() {
let dir = tempfile::tempdir().unwrap();
let empty = dir.path().join("empty.key");
std::fs::write(&empty, b"# no key here\njust a comment\n").unwrap();
let err = load_private_key(&empty);
assert!(
matches!(err, Err(TlsError::CertLoad(_))),
"empty key file must yield CertLoad error, got {err:?}"
);
}
#[test]
fn load_private_key_returns_error_when_file_missing() {
let err = load_private_key(Path::new("/nonexistent/alktls-coverage/missing.key"));
assert!(
matches!(err, Err(TlsError::CertLoad(_))),
"missing key file must yield CertLoad error, got {err:?}"
);
}
#[test]
fn load_cert_chain_returns_error_when_file_missing() {
let err = load_cert_chain(Path::new("/nonexistent/alktls-coverage/missing.pem"));
assert!(
matches!(err, Err(TlsError::CertLoad(_))),
"missing cert file must yield CertLoad error, got {err:?}"
);
}
}
+138
View File
@@ -1,3 +1,141 @@
//! Ed25519 signing key usable as both a rustls `SigningKey` and `Signer`:
//! [`Ed25519SigningKey`]. One copy used by both server
//! (`RawKeyCertResolver`) and client (`RawKeyClientCertResolver`).
#[derive(Clone)]
pub struct Ed25519SigningKey {
key: crate::identity::Ed25519SecretKey,
}
impl std::fmt::Debug for Ed25519SigningKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Ed25519SigningKey").finish()
}
}
impl Ed25519SigningKey {
pub fn new(key: crate::identity::Ed25519SecretKey) -> Self {
Self { key }
}
pub fn spki_public_key(&self) -> rustls::pki_types::SubjectPublicKeyInfoDer<'static> {
rustls::sign::public_key_to_spki(
&rustls::pki_types::alg_id::ED25519,
self.key.public().as_bytes(),
)
}
}
impl rustls::sign::SigningKey for Ed25519SigningKey {
fn choose_scheme(
&self,
offered: &[rustls::SignatureScheme],
) -> Option<Box<dyn rustls::sign::Signer>> {
if offered.contains(&rustls::SignatureScheme::ED25519) {
Some(Box::new(self.clone()))
} else {
None
}
}
fn algorithm(&self) -> rustls::SignatureAlgorithm {
rustls::SignatureAlgorithm::ED25519
}
fn public_key(&self) -> Option<rustls::pki_types::SubjectPublicKeyInfoDer<'_>> {
Some(self.spki_public_key())
}
}
impl rustls::sign::Signer for Ed25519SigningKey {
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, rustls::Error> {
Ok(self.key.sign(message).to_bytes().to_vec())
}
fn scheme(&self) -> rustls::SignatureScheme {
rustls::SignatureScheme::ED25519
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{load_cert_chain, load_private_key};
use rustls::sign::SigningKey;
#[test]
fn ed25519_signing_key_choose_scheme_returns_some_for_ed25519() {
let sk = crate::identity::Ed25519SecretKey::generate();
let signing_key = Ed25519SigningKey::new(sk);
let signer = signing_key.choose_scheme(&[rustls::SignatureScheme::ED25519]);
assert!(
signer.is_some(),
"must produce a signer when ED25519 is offered"
);
}
#[test]
fn ed25519_signing_key_choose_scheme_returns_none_without_ed25519() {
let sk = crate::identity::Ed25519SecretKey::generate();
let signing_key = Ed25519SigningKey::new(sk);
let signer = signing_key.choose_scheme(&[rustls::SignatureScheme::RSA_PSS_SHA256]);
assert!(
signer.is_none(),
"must not produce a signer when ED25519 is not offered"
);
}
#[test]
fn ed25519_signing_key_algorithm_is_ed25519() {
let sk = crate::identity::Ed25519SecretKey::generate();
let signing_key = Ed25519SigningKey::new(sk);
assert_eq!(signing_key.algorithm(), rustls::SignatureAlgorithm::ED25519);
}
#[test]
fn ed25519_signing_key_public_key_returns_spki() {
let sk = crate::identity::Ed25519SecretKey::generate();
let signing_key = Ed25519SigningKey::new(sk);
let spki = signing_key.public_key();
assert!(spki.is_some(), "public_key must return an SPKI");
assert!(!spki.unwrap().as_ref().is_empty(), "SPKI must be non-empty");
}
#[test]
fn ed25519_signing_key_signer_signs_message() {
let sk = crate::identity::Ed25519SecretKey::generate();
let signing_key = Ed25519SigningKey::new(sk);
let signer = signing_key
.choose_scheme(&[rustls::SignatureScheme::ED25519])
.expect("ED25519 offered");
let message = b"alktls coverage signing test";
let sig = signer.sign(message).expect("sign must succeed");
assert_eq!(sig.len(), 64, "ed25519 signature must be 64 bytes");
assert_eq!(signer.scheme(), rustls::SignatureScheme::ED25519);
}
#[test]
fn ed25519_signing_key_debug_does_not_leak_material() {
let sk = crate::identity::Ed25519SecretKey::generate();
let signing_key = Ed25519SigningKey::new(sk);
let dbg = format!("{signing_key:?}");
assert!(dbg.contains("Ed25519SigningKey"));
}
#[test]
fn pem_round_trips_generated_self_signed_pair() {
let dir = tempfile::tempdir().unwrap();
let key_pair = rcgen::KeyPair::generate().unwrap();
let cert = rcgen::CertificateParams::default()
.self_signed(&key_pair)
.unwrap();
let cert_path = dir.path().join("cert.pem");
let key_path = dir.path().join("key.pem");
std::fs::write(&cert_path, cert.pem()).unwrap();
std::fs::write(&key_path, key_pair.serialize_pem()).unwrap();
let chain = load_cert_chain(&cert_path).expect("cert chain must load");
assert!(!chain.is_empty(), "loaded chain must be non-empty");
let key = load_private_key(&key_path).expect("private key must load");
assert!(!key.secret_der().is_empty(), "loaded key must be non-empty");
}
}
+46 -10
View File
@@ -1,7 +1,7 @@
---
id: port-fingerprint
name: Port fingerprint helpers + DER parser (src/fingerprint.rs)
status: pending
status: completed
depends_on: [crate-init]
scope: narrow
risk: low
@@ -44,20 +44,20 @@ the private manual DER parser (`DerParser`).
## Verification
- [ ] Ported tests green (`cargo test fingerprint`)
- [ ] Round-trip: an Ed25519 SPKI built by `signing.rs`'s
- [x] Ported tests green (`cargo test fingerprint`)
- [x] Round-trip: an Ed25519 SPKI built by `signing.rs`'s
`spki_public_key()` yields `ed25519:<hex>` matching the source
key (integration assert — this pins the normalization across
the raw-key paths)
- [ ] Malformed-DER inputs yield `None` / SHA fallback (ported edge
- [x] Malformed-DER inputs yield `None` / SHA fallback (ported edge
tests)
- [ ] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
- [x] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
## Acceptance Criteria
- [ ] The module compiles without `rustls` in production code
- [ ] Both normalized fingerprint formats are test-pinned
- [ ] `lib.rs` re-exports the two public functions
- [x] The module compiles without `rustls` in production code
- [x] Both normalized fingerprint formats are test-pinned
- [x] `lib.rs` re-exports the two public functions
## References
@@ -68,8 +68,44 @@ the private manual DER parser (`DerParser`).
## Notes
> Agent fills this during implementation.
- **Empty-input discrepancy (resolved against the code):** the task
description said `fingerprint_from_cert_der` "returns `None` only for
empty input"; the extraction's *doc comment* claims the same, but its
*code* has no empty-input check — empty input falls through to the
SHA-256 fallback and returns `Some("SHA256:e3b0c442…")` (the hash of
the empty slice). The port matches the extraction's actual behavior
(always `Some`); the doc comment on the ported function records the
deviation so the stale `None` claim is not propagated.
- The extraction's test list (7 tests) contained no malformed-DER edge
tests despite the task invariant naming them — the malformed-DER
suite was written fresh for this port: truncated headers, wrong
outer tag, long-form length edges (0x80 indefinite, overlong,
>4 bytes, truncated header), a well-formed long-form length
acceptance case, wrong-OID-in-valid-SPKI, bad BIT STRING lengths
(32/34 bytes, non-zero unused-bits), and malformed-DER SHA fallback.
- Tests construct the raw key bytes directly (fixed test-key arrays)
and build SPKIs via `rustls::sign::public_key_to_spki` — no
dependency on `identity.rs` (concurrent-port constraint held).
- Production code uses `hex::encode` (ported verbatim), so `hex` was
added to `[dependencies]` (it was dev-only; alknet-core does the
same).
- Ported test `fingerprint_from_ed25519_spki_matches_iroh_format`
compares against `format!("ed25519:{}", hex::encode(raw_key))` per
the task (the extraction compared a separately generated key; same
shape, key sourced directly instead).
## Summary
> Agent fills this on completion.
Ported `src/fingerprint.rs` wholesale from
`/workspace/@alkdev/alknet/crates/alknet-core/src/fingerprint.rs`:
`fingerprint_from_cert_der`, `extract_ed25519_raw_key_from_spki`,
private `DerParser` (`read_tlv`, `decode_header`, `expect_sequence`,
`expect_oid`, `expect_bit_string`). Production code stays `sha2` +
manual DER + `hex`; the only `rustls::` use is the test-only SPKI
builder. 16 tests green (7 ported from the extraction with the
`crate::config::Ed25519SecretKey::generate()` dependency replaced by
fixed key arrays, 9 new/extended edges). `lib.rs` re-exports both
public functions (concurrent port lines preserved). Verification:
`cargo test` (36 pass), `cargo test --all-features` (37 pass),
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`,
`cargo check --all-features` — all clean.
+88 -10
View File
@@ -1,7 +1,7 @@
---
id: port-identity-types
name: Port identity types — TlsIdentity, Ed25519SecretKey, AcmeDirectory (src/identity.rs)
status: pending
status: completed
depends_on: [crate-init]
scope: narrow
risk: low
@@ -51,19 +51,19 @@ new surface beyond ADR-005's list without noting it).
## Verification
- [ ] `cargo test -p alktls identity` passes (ported tests green)
- [ ] `as_bytes`/`from_bytes` round-trip asserted
- [ ] `AcmeDirectory` URLs asserted (production + staging + custom)
- [ ] `Debug` output contains no key bytes (test: format then assert
- [x] `cargo test -p alktls identity` passes (ported tests green)
- [x] `as_bytes`/`from_bytes` round-trip asserted
- [x] `AcmeDirectory` URLs asserted (production + staging + custom)
- [x] `Debug` output contains no key bytes (test: format then assert
hex key absent)
- [ ] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
- [x] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
## Acceptance Criteria
- [ ] `src/identity.rs` holds exactly the ADR-005 type set; no auth
- [x] `src/identity.rs` holds exactly the ADR-005 type set; no auth
layer types present
- [ ] The byte surface matches the load-bearing list above verbatim
- [ ] `lib.rs` re-exports the three types
- [x] The byte surface matches the load-bearing list above verbatim
- [x] `lib.rs` re-exports the three types
## References
@@ -76,6 +76,84 @@ new surface beyond ADR-005's list without noting it).
> Agent fills this during implementation.
### Decisions / deviations
1. **rand decision: `rand_core = "0.6"` + ed25519-dalek's `rand_core`
feature — NOT `rand`.** The alktls lockfile's transitive `rand` is
0.10.2 (`rand_core 0.10.1`); ed25519-dalek 2.2.0's
`SigningKey::generate` needs `rand_core 0.6.4`'s `CryptoRngCore`
(its Cargo.toml pins `rand_core = "0.6.4"`, optional). rand 0.10's
`OsRng` only implements rand_core 0.10 traits → incompatible, so
`rand::rngs::OsRng` would not compile against ed25519-dalek 2.2.0.
Additionally, `generate` is gated
`#[cfg(any(test, feature = "rand_core"))]` inside ed25519-dalek —
the non-default `rand_core` feature must be enabled (alknet-core
does the same: `rand = "0.8"` + `ed25519-dalek = { features =
["rand_core"] }`). Fix: `ed25519-dalek = { version = "2", features
= ["rand_core"] }` + `rand_core = { version = "0.6", features =
["getrandom"] }` in `[dependencies]`, using `rand_core::OsRng`.
`rand_core 0.6.4` + `getrandom 0.2.17` were already in the lockfile
transitively — no version churn; `rand` stays out of the dep tree
(leaner than the extraction, which depended on `rand 0.8`).
2. **`zeroize::ZeroizeOnDrop` not ported (deliberate omission).** The
extracted `Ed25519SecretKey` had `impl zeroize::ZeroizeOnDrop`, but
ADR-005's type list doesn't mention it and the task says not to add
surface beyond the ADR's list. Note: ed25519-dalek 2.2.0's default
features include `zeroize`, and its `SigningKey` itself implements
`ZeroizeOnDrop` internally — the wrapped key material is still
zeroized on drop through the inner type. Adding the crate-level
impl would require a `zeroize` dep for zero behavioral gain; can be
added later if the vault (rewrite's config side) wants the explicit
marker impl.
3. **serde derives: none ported.** The extracted three types carry no
serde derives (checked `config.rs` lines 3298) — nothing to add,
consistent with "no new surface beyond ADR-005's list".
4. **`TlsIdentity::Acme`'s doc comment added** (task instruction):
server-only, config error (`TlsError::AcmeConfig`) on the client
path — per ADR-001's identity model and the client spec's
presentation table.
5. **Sibling-task fix (tree-state deviation, not this port).** The
working tree contained port-pem-signing's uncommitted work
(`pem.rs`, `signing.rs`, `lib.rs` re-exports). Its `signing.rs`
test module was missing `use crate::{load_cert_chain,
load_private_key};` and `pem.rs` lacked a trailing newline, which
broke `cargo test` / `cargo fmt --check` for the whole crate. Two
mechanical fixes applied (test-only `use` line; trailing newline)
so whole-crate verification could run; no production code touched.
## Summary
> Agent fills this on completion.
> Agent fills this on completion. Brief description of what was
> implemented, files changed, and any follow-up needed.
### What landed
- `src/identity.rs`: the three ADR-005 types ported verbatim from
alknet-core `config.rs` lines 3298 — `Ed25519SecretKey`
(exact byte surface: `generate` / `from_bytes` / `as_bytes` /
`public` / `sign` via in-method `Signer` import; custom `Debug`
with `finish_non_exhaustive`), `AcmeDirectory` (pinned
production/staging URLs), `TlsIdentity` (four variants; doc
comments carry the OQ-TLS-02 resolution on `SelfSigned` and the
server-only note on `Acme`).
- Tests: all five extracted `Ed25519SecretKey` tests ported
(round-trip, sign-verifies, tampered-reject, Debug-no-leak with hex
assertion, public-length) + the two extracted `TlsIdentity`
construct tests + a new `AcmeDirectory` URL-pinning test +
an `Acme` construct test.
- `Cargo.toml`: `ed25519-dalek` gains the `rand_core` feature;
`rand_core 0.6` (feature `getrandom`) added as a dependency.
- `src/lib.rs`: `pub use identity::{AcmeDirectory, Ed25519SecretKey,
TlsIdentity};` added to the incremental re-export block.
### Verification
- `cargo test` ✓ (20 passed); `cargo test -p alktls identity` ✓
(9 passed); `cargo clippy --all-targets -- -D warnings` ✓;
`cargo fmt --check` ✓; `cargo check --all-features` ✓;
`cargo test --all-features` ✓ (21 passed); `cargo clippy
--all-targets --all-features -- -D warnings` ✓.
### Follow-up
- None for this module. port-client completes the re-export block.
+24 -8
View File
@@ -1,7 +1,7 @@
---
id: port-pem-signing
name: Port PEM loading + Ed25519 signing helper (src/pem.rs, src/signing.rs)
status: pending
status: completed
depends_on: [crate-init]
scope: narrow
risk: low
@@ -50,16 +50,16 @@ Port the two shared helper modules from alknet-tls per ADR-005/ADR-006:
## Verification
- [ ] `cargo test pem signing` green (ported + round-trip tests)
- [ ] Signature length is 64 bytes; scheme is ED25519
- [ ] PEM error paths return `TlsError::CertLoad` (not panics)
- [ ] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
- [x] `cargo test pem signing` green (ported + round-trip tests)
- [x] Signature length is 64 bytes; scheme is ED25519
- [x] PEM error paths return `TlsError::CertLoad` (not panics)
- [x] `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`
## Acceptance Criteria
- [ ] Both modules match the extracted source modulo the identity-type
- [x] Both modules match the extracted source modulo the identity-type
import and error mapping
- [ ] `lib.rs` re-exports `load_cert_chain`, `load_private_key`,
- [x] `lib.rs` re-exports `load_cert_chain`, `load_private_key`,
`Ed25519SigningKey`
## References
@@ -73,6 +73,22 @@ Port the two shared helper modules from alknet-tls per ADR-005/ADR-006:
> Agent fills this during implementation.
- Build initially failed because `src/identity.rs` was still a stub
(the `port-identity-types` task was still `pending` when my retry
budget ran out); verified the modules in a /tmp scratch copy with a
faithful stand-in of `Ed25519SecretKey` (exact ported surface), then
re-ran everything green in the workspace once identity.rs landed.
- Error mapping: `fs::read` failure → `TlsError::CertLoad`
(via `#[from] io::Error` — `?` / `map_err(TlsError::CertLoad)`);
`rustls_pemfile` collect error wrapped with `io::Error::other` then
`CertLoad`; no-key-found keeps `io::ErrorKind::InvalidData` then
`CertLoad`. Extraction's `TlsError::Io` sites all became `CertLoad`.
## Summary
> Agent fills this on completion.
> Ported `src/pem.rs` (`load_cert_chain`, `load_private_key`) and
> `src/signing.rs` (`Ed25519SigningKey` over
> `crate::identity::Ed25519SecretKey`) from alknet-tls; error paths
> remapped from the extraction's `TlsError::Io` to `TlsError::CertLoad`
> per ADR-002. 9 module tests (incl. the rcgen round-trip) green;
> `lib.rs` re-exports added.