Skip to content

Commit 69c303f

Browse files
committed
fix(crypto): use real HMAC-SHA256 for attestations; refuse fake asymmetric keys
Two honesty/security fixes flagged by the threat model and STATE.a2ml. B3 — attestation MAC: - Replace the homerolled `SHA256(key || data || previous_hash)` (not a MAC; banned by the Trustfile `no-homerolled-hmac` invariant) with a real HMAC-SHA256 via the `hmac` crate (RFC 2104), with a versioned scheme label bound in as a domain separator (januskey.attestation.HMAC-SHA256.v1). - `compute_attestation` now returns Result and hard-errors when no key is set, removing the `unwrap_or([0u8; 32])` all-zero-key fallback that silently produced forgeable, unkeyed attestations. - Producer (log_event) and verifier (verify_integrity) both propagate the error, so they stay consistent. B4 — fake asymmetric crypto: - `SecretKey::generate()` only ever produces 32 random bytes and ignores the algorithm, so selecting ed25519/x25519 yielded a random blob merely labelled "Ed25519"/"X25519" — not a keypair. Refuse generation of these types at the CLI with a clear "not implemented" error. Enum variants are retained (documented as reserved/unimplemented) so historical serialized metadata and audit entries still deserialize. Regression tests added: test_attestation_refused_without_key, test_attestation_is_genuinely_keyed (wrong key fails verification — a plain hash would not catch this, proving the MAC is genuinely keyed). Verified: `cargo build -p januskey` clean; `cargo test -p januskey --lib` 26 passed / 0 failed; attestation E2E + P2P chain-integrity tests pass. STATE.a2ml blockers updated (homerolled-hmac RESOLVED). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qwVESTcbfanY2iJPQNoSz
1 parent acd9b8c commit 69c303f

6 files changed

Lines changed: 122 additions & 19 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ compatibility = false
5353

5454
[blockers]
5555
threat-model-signoff = "threat-model.a2ml status = draft-pending-human-review (since 2026-04-19); blocks any januskey-mcp cartridge"
56-
homerolled-hmac = "attestation.rs SHA256(key||data) pattern flagged by threat model; must be replaced with a real HMAC primitive before MCP exposure (Trustfile no-homerolled-hmac)"
57-
metadata-only-key-types = "Ed25519/X25519 enum entries lack real sign/DH implementations (threat-model flag)"
56+
homerolled-hmac = "RESOLVED: attestation.rs now uses a real HMAC-SHA256 (hmac crate, RFC 2104) with a versioned domain separator, and hard-errors instead of the unwrap_or([0u8;32]) unkeyed fallback. Regression tests: test_attestation_refused_without_key, test_attestation_is_genuinely_keyed. Satisfies Trustfile no-homerolled-hmac."
57+
metadata-only-key-types = "PARTIALLY MITIGATED: Ed25519/X25519 key generation is now refused at the CLI (was silently producing 32 random bytes mislabelled as an asymmetric key); enum variants retained only for backward-compatible deserialization. Real dalek-backed sign/DH still unimplemented."
5858
claude-md-maintainer-edits = ".claude/CLAUDE.md is guardrail-blocked for agents: line 46 still says 'Julia/Rust/ReScript' (leftover from #49); package-management section still cites flake.nix, removed by the estate wave"
5959
idris2-in-ci = "just test-proofs requires the idris2 binary; proof check not yet wired as a CI gate (READINESS D→C promotion item)"
6060

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/januskey-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ clap = { version = "4", features = ["derive"] }
1818
serde = { version = "1", features = ["derive"] }
1919
serde_json = "1"
2020
sha2 = "0.10"
21+
hmac = "0.12"
2122
chrono = { version = "0.4", features = ["serde"] }
2223
uuid = { version = "1", features = ["v4", "serde"] }
2324
thiserror = "1"

crates/januskey-cli/src/attestation.rs

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// Tamper-evident logging with cryptographic attestations
77

88
use chrono::{DateTime, Utc};
9+
use hmac::{Hmac, Mac};
910
use serde::{Deserialize, Serialize};
1011
use sha2::{Digest, Sha256};
1112
use std::fs::{self, File, OpenOptions};
@@ -118,6 +119,10 @@ pub struct AuditLog {
118119
}
119120

120121
impl AuditLog {
122+
/// Domain-separation / version label bound into every attestation MAC.
123+
/// Bumping this string invalidates prior attestations by construction.
124+
const ATTESTATION_SCHEME: &'static str = "januskey.attestation.HMAC-SHA256.v1";
125+
121126
/// Create audit log manager for a directory
122127
pub fn new(root: &Path) -> Self {
123128
let log_path = root.join(".januskey").join("keys").join("audit.log");
@@ -179,16 +184,43 @@ impl AuditLog {
179184
.unwrap_or_else(|| "0".repeat(64)))
180185
}
181186

182-
/// Compute HMAC-SHA256 attestation
183-
fn compute_attestation(&self, data: &str, previous_hash: &str) -> String {
184-
let key = self.attestation_key.unwrap_or([0u8; 32]);
185-
186-
// Simple HMAC-SHA256: H(key || data || previous_hash)
187-
let mut hasher = Sha256::new();
188-
hasher.update(&key);
189-
hasher.update(data.as_bytes());
190-
hasher.update(previous_hash.as_bytes());
191-
hex::encode(hasher.finalize())
187+
/// Compute the attestation MAC for a log entry.
188+
///
189+
/// Uses a real HMAC-SHA256 (RFC 2104) via the `hmac` crate, keyed on the
190+
/// store-derived attestation key. This replaces the former homerolled
191+
/// `SHA256(key || data || previous_hash)` construction, which was not a
192+
/// MAC (length-extension–exposed and banned by the Trustfile
193+
/// `no-homerolled-hmac` invariant).
194+
///
195+
/// The scheme label is bound into the MAC as a domain separator; it
196+
/// versions the construction, so entries written under the old homerolled
197+
/// scheme will not verify under this one (an intentional, alpha-stage
198+
/// breaking change — see the module changelog / STATE.a2ml).
199+
///
200+
/// Errors if no attestation key is set: an unkeyed attestation is
201+
/// forgeable and must never be silently produced (the previous
202+
/// `unwrap_or([0u8; 32])` all-zero-key fallback did exactly that).
203+
fn compute_attestation(
204+
&self,
205+
data: &str,
206+
previous_hash: &str,
207+
) -> std::io::Result<String> {
208+
let key = self.attestation_key.ok_or_else(|| {
209+
std::io::Error::new(
210+
std::io::ErrorKind::PermissionDenied,
211+
"attestation key not set; refusing to produce an unkeyed \
212+
(forgeable) attestation — call init()/set_attestation_key() first",
213+
)
214+
})?;
215+
216+
let mut mac = <Hmac<Sha256>>::new_from_slice(&key)
217+
.expect("HMAC accepts keys of any length");
218+
mac.update(Self::ATTESTATION_SCHEME.as_bytes());
219+
mac.update(b"\x00");
220+
mac.update(data.as_bytes());
221+
mac.update(b"\x00");
222+
mac.update(previous_hash.as_bytes());
223+
Ok(hex::encode(mac.finalize().into_bytes()))
192224
}
193225

194226
/// Log an event
@@ -211,7 +243,7 @@ impl AuditLog {
211243
event_type,
212244
actor
213245
);
214-
let attestation = self.compute_attestation(&attestation_data, &previous_hash);
246+
let attestation = self.compute_attestation(&attestation_data, &previous_hash)?;
215247

216248
let entry = AuditEntry {
217249
event_id,
@@ -408,7 +440,7 @@ impl AuditLog {
408440
entry.actor
409441
);
410442
let expected_attestation =
411-
self.compute_attestation(&attestation_data, &entry.previous_hash);
443+
self.compute_attestation(&attestation_data, &entry.previous_hash)?;
412444

413445
if entry.attestation != expected_attestation {
414446
return Ok(IntegrityReport {
@@ -552,4 +584,49 @@ mod tests {
552584
.expect("failed to get key history");
553585
assert_eq!(history.len(), 3);
554586
}
587+
588+
#[test]
589+
fn test_attestation_refused_without_key() {
590+
// An AuditLog with no attestation key must refuse to produce an
591+
// attestation rather than silently sign with an all-zero key
592+
// (the former unwrap_or([0u8; 32]) forgery hazard).
593+
let tmp = TempDir::new().expect("failed to create temp dir");
594+
let log = AuditLog::new(tmp.path()); // note: no init()/set_attestation_key()
595+
596+
let err = log
597+
.log_store_init()
598+
.expect_err("logging without a key must fail");
599+
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
600+
}
601+
602+
#[test]
603+
fn test_attestation_is_genuinely_keyed() {
604+
// Verifying with the wrong key must fail. A plain unkeyed hash would
605+
// still "verify" here; only a real keyed MAC catches this, proving the
606+
// attestation is HMAC-keyed and not the old homerolled concatenation.
607+
let tmp = TempDir::new().expect("failed to create temp dir");
608+
609+
{
610+
let mut log = AuditLog::new(tmp.path());
611+
log.init([7u8; 32]).expect("init");
612+
log.log_store_init().expect("log");
613+
log.log_key_generated(
614+
Uuid::new_v4(),
615+
"fp",
616+
KeyAlgorithm::Aes256Gcm,
617+
KeyPurpose::Encryption,
618+
)
619+
.expect("log");
620+
assert!(log.verify_integrity().expect("verify").valid);
621+
}
622+
623+
// Re-open the same log with a DIFFERENT attestation key.
624+
let mut wrong = AuditLog::new(tmp.path());
625+
wrong.set_attestation_key([9u8; 32]);
626+
let report = wrong.verify_integrity().expect("verify");
627+
assert!(
628+
!report.valid,
629+
"verification must fail under a different key (keyed MAC)"
630+
);
631+
}
555632
}

crates/januskey-cli/src/keys.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,15 @@ const KEY_LENGTH: usize = 32;
6363
/// Key algorithm types
6464
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6565
pub enum KeyAlgorithm {
66+
/// AES-256-GCM symmetric key — the only algorithm actually implemented.
6667
Aes256Gcm,
68+
/// Reserved / NOT IMPLEMENTED. No Ed25519 keypair is generated; key
69+
/// generation for this variant is refused at the CLI. Retained so
70+
/// historical serialized metadata/audit entries still deserialize.
6771
Ed25519,
72+
/// Reserved / NOT IMPLEMENTED. No X25519 keypair is generated; key
73+
/// generation for this variant is refused at the CLI. Retained so
74+
/// historical serialized metadata/audit entries still deserialize.
6875
X25519,
6976
}
7077

crates/januskey-cli/src/keys_cli.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -279,15 +279,23 @@ fn cmd_generate(
279279

280280
let algorithm = match key_type.to_lowercase().as_str() {
281281
"aes256" | "aes-256" | "aes256gcm" => KeyAlgorithm::Aes256Gcm,
282-
"ed25519" => KeyAlgorithm::Ed25519,
283-
"x25519" => KeyAlgorithm::X25519,
284-
_ => {
282+
// Ed25519/X25519 are NOT implemented: SecretKey::generate() only
283+
// produces 32 random bytes and ignores the algorithm, so generating
284+
// one would yield a random blob merely labelled "Ed25519"/"X25519",
285+
// not a real keypair. Refuse rather than advertise a capability that
286+
// does not exist. (Re-enable once dalek-backed signing/DH lands.)
287+
"ed25519" | "x25519" => {
285288
return Err(format!(
286-
"Unknown key type: {}. Use: aes256, ed25519, x25519",
287-
key_type
289+
"Key type '{}' is not implemented yet: JanusKey has no \
290+
asymmetric key generation (only AES-256-GCM symmetric keys). \
291+
Refusing to generate a random blob mislabelled as {}.",
292+
key_type, key_type
288293
)
289294
.into())
290295
}
296+
_ => {
297+
return Err(format!("Unknown key type: {}. Use: aes256", key_type).into())
298+
}
291299
};
292300

293301
let key_purpose = match purpose.to_lowercase().as_str() {

0 commit comments

Comments
 (0)