Skip to content

Commit c8de13c

Browse files
scottdhughesclaude
andcommitted
feat: demo-ready improvements — fingerprint words, compare tool, sender TTL
Feature 1 — Visual Fingerprint Words (PGP Word List): Add _fingerprint_words() using 256 even + 256 odd PGP Word List. Returns 6-word human-readable fingerprints alongside hex, e.g. "snapline-Eskimo-tumor-informant-deadbolt-detector". Included in pqc_fingerprint, hybrid_keygen, and keygen handler responses. Added fingerprint_words to key store schema allowlist. Feature 2 — Classical vs PQC Comparison Tool: New pqc_compare tool benchmarks a PQC algorithm against its classical counterpart (ML-KEM-768 vs X25519, ML-DSA-65 vs Ed25519). Shows side-by-side latency, key sizes, and ratios. Feature 3 — Sender-Controlled TTL (Self-Destruct): Optional max_decrypt_time parameter on pqc_hybrid_auth_seal. Value is signed in the auth transcript (tamper-proof). After timestamp + max_decrypt_time, auth_open refuses to decrypt: "The sender set this message to self-destruct." Backwards compatible — envelopes without it work unchanged. 305 tests passing, zero regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 81b3ae1 commit c8de13c

6 files changed

Lines changed: 754 additions & 3 deletions

File tree

pqc_mcp_server/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class SenderVerificationError(Exception): # type: ignore[no-redef]
6666
handle_hash,
6767
handle_security_analysis,
6868
handle_benchmark,
69+
handle_compare,
6970
)
7071

7172
_PQC_HANDLERS = {
@@ -80,6 +81,7 @@ class SenderVerificationError(Exception): # type: ignore[no-redef]
8081
"pqc_hash_to_curve": handle_hash, # deprecated alias
8182
"pqc_security_analysis": handle_security_analysis,
8283
"pqc_benchmark": handle_benchmark,
84+
"pqc_compare": handle_compare,
8385
}
8486

8587
if HAS_HYBRID:
@@ -157,7 +159,7 @@ class SenderVerificationError(Exception): # type: ignore[no-redef]
157159
)
158160
_DICT_FIELDS = frozenset({"envelope", "key_data"})
159161
_BOOL_FIELDS = frozenset({"overwrite", "include_secret_key"})
160-
_INT_FIELDS = frozenset({"iterations", "max_age_seconds"})
162+
_INT_FIELDS = frozenset({"iterations", "max_age_seconds", "max_decrypt_time"})
161163

162164
_SIZE_LIMITS: dict[str, int] = {
163165
"plaintext": _MAX_PLAINTEXT_SIZE,

pqc_mcp_server/handlers_hybrid.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
hybrid_auth_open,
3030
hybrid_auth_verify,
3131
_fingerprint_public_key,
32+
_fingerprint_words,
3233
)
3334
from pqc_mcp_server.security_policy import get_policy
3435
from pqc_mcp_server.replay_cache import get_replay_cache, signature_digest
@@ -118,6 +119,7 @@ def handle_fingerprint(arguments: dict[str, Any]) -> dict[str, Any]:
118119
pk_bytes = _b64(arguments["public_key"])
119120
return {
120121
"fingerprint": _fingerprint_public_key(pk_bytes),
122+
"fingerprint_words": _fingerprint_words(pk_bytes),
121123
"algorithm": "SHA3-256",
122124
"public_key_size": len(pk_bytes),
123125
}
@@ -136,11 +138,13 @@ def handle_hybrid_keygen(arguments: dict[str, Any]) -> dict[str, Any]:
136138
"algorithm": result["classical"]["algorithm"],
137139
"public_key": result["classical"]["public_key"],
138140
"fingerprint": result["classical"]["fingerprint"],
141+
"fingerprint_words": result["classical"]["fingerprint_words"],
139142
},
140143
"pqc": {
141144
"algorithm": result["pqc"]["algorithm"],
142145
"public_key": result["pqc"]["public_key"],
143146
"fingerprint": result["pqc"]["fingerprint"],
147+
"fingerprint_words": result["pqc"]["fingerprint_words"],
144148
},
145149
}
146150
return result
@@ -197,7 +201,11 @@ def handle_hybrid_auth_seal(arguments: dict[str, Any]) -> dict[str, Any]:
197201
pt_bytes = _resolve_plaintext(arguments)
198202
classical_pk, pqc_pk = _resolve_hybrid_public(arguments, prefix="recipient_")
199203
sender_sk, sender_pk = _resolve_sender(arguments)
200-
envelope = hybrid_auth_seal(pt_bytes, classical_pk, pqc_pk, sender_sk, sender_pk)
204+
kwargs: dict[str, Any] = {}
205+
mdt = arguments.get("max_decrypt_time")
206+
if mdt is not None:
207+
kwargs["max_decrypt_time"] = int(mdt)
208+
envelope = hybrid_auth_seal(pt_bytes, classical_pk, pqc_pk, sender_sk, sender_pk, **kwargs)
201209
return {"envelope": envelope}
202210

203211

pqc_mcp_server/handlers_pqc.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,132 @@ def handle_benchmark(arguments: dict[str, Any]) -> dict[str, Any]:
437437
},
438438
"nist_level": details.get("claimed_nist_level", "Unknown"),
439439
}
440+
441+
442+
# Classical counterpart mapping for comparison tool
443+
_CLASSICAL_COUNTERPARTS: dict[str, str] = {
444+
"ML-KEM-768": "X25519",
445+
"ML-KEM-512": "X25519",
446+
"ML-KEM-1024": "X25519",
447+
"ML-DSA-44": "Ed25519",
448+
"ML-DSA-65": "Ed25519",
449+
"ML-DSA-87": "Ed25519",
450+
}
451+
452+
453+
def _benchmark_classical(algorithm: str, iterations: int) -> dict[str, Any]:
454+
"""Benchmark a classical algorithm (X25519 or Ed25519)."""
455+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
456+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
457+
458+
test_message = b"Benchmark test message for classical operations"
459+
460+
if algorithm == "X25519":
461+
# Keygen
462+
t0 = time.perf_counter()
463+
for _ in range(iterations):
464+
sk = X25519PrivateKey.generate()
465+
sk.public_key()
466+
keygen_ms = (time.perf_counter() - t0) / iterations * 1000
467+
468+
# Key exchange
469+
sk1 = X25519PrivateKey.generate()
470+
pk2 = X25519PrivateKey.generate().public_key()
471+
t0 = time.perf_counter()
472+
for _ in range(iterations):
473+
sk1.exchange(pk2)
474+
exchange_ms = (time.perf_counter() - t0) / iterations * 1000
475+
476+
return {
477+
"algorithm": "X25519",
478+
"type": "Key Exchange",
479+
"iterations": iterations,
480+
"timing_ms": {"keygen": round(keygen_ms, 3), "exchange": round(exchange_ms, 3)},
481+
"sizes_bytes": {"public_key": 32, "secret_key": 32, "shared_secret": 32},
482+
}
483+
484+
elif algorithm == "Ed25519":
485+
# Keygen
486+
t0 = time.perf_counter()
487+
for _ in range(iterations):
488+
sk = Ed25519PrivateKey.generate()
489+
sk.public_key()
490+
keygen_ms = (time.perf_counter() - t0) / iterations * 1000
491+
492+
# Sign
493+
sk = Ed25519PrivateKey.generate()
494+
t0 = time.perf_counter()
495+
for _ in range(iterations):
496+
sig = sk.sign(test_message)
497+
sign_ms = (time.perf_counter() - t0) / iterations * 1000
498+
499+
# Verify
500+
pk = sk.public_key()
501+
t0 = time.perf_counter()
502+
for _ in range(iterations):
503+
pk.verify(sig, test_message)
504+
verify_ms = (time.perf_counter() - t0) / iterations * 1000
505+
506+
return {
507+
"algorithm": "Ed25519",
508+
"type": "Signature",
509+
"iterations": iterations,
510+
"timing_ms": {
511+
"keygen": round(keygen_ms, 3),
512+
"sign": round(sign_ms, 3),
513+
"verify": round(verify_ms, 3),
514+
},
515+
"sizes_bytes": {"public_key": 32, "secret_key": 32, "signature": 64},
516+
}
517+
518+
raise ValueError(f"Unknown classical algorithm: {algorithm}")
519+
520+
521+
def handle_compare(arguments: dict[str, Any]) -> dict[str, Any]:
522+
"""Compare a PQC algorithm against its classical counterpart."""
523+
alg = arguments["algorithm"]
524+
iterations = min(arguments.get("iterations", 10), 100)
525+
526+
classical_name = _CLASSICAL_COUNTERPARTS.get(alg)
527+
if not classical_name:
528+
supported = ", ".join(sorted(_CLASSICAL_COUNTERPARTS.keys()))
529+
raise ValueError(f"No classical counterpart for '{alg}'. Supported: {supported}")
530+
531+
pqc_result = handle_benchmark({"algorithm": alg, "iterations": iterations})
532+
classical_result = _benchmark_classical(classical_name, iterations)
533+
534+
# Compute ratios
535+
ratios: dict[str, str] = {}
536+
ratios["keygen"] = (
537+
f"{pqc_result['timing_ms']['keygen'] / max(classical_result['timing_ms']['keygen'], 0.001):.1f}x"
538+
)
539+
540+
if pqc_result["type"] == "KEM":
541+
classical_op = classical_result["timing_ms"]["exchange"]
542+
pqc_op = pqc_result["timing_ms"]["encap"]
543+
ratios["encap_vs_exchange"] = f"{pqc_op / max(classical_op, 0.001):.1f}x"
544+
pk_ratio = (
545+
pqc_result["sizes_bytes"]["public_key"] / classical_result["sizes_bytes"]["public_key"]
546+
)
547+
ratios["public_key_size"] = f"{pk_ratio:.0f}x"
548+
else:
549+
classical_sign = classical_result["timing_ms"]["sign"]
550+
pqc_sign = pqc_result["timing_ms"]["sign"]
551+
ratios["sign"] = f"{pqc_sign / max(classical_sign, 0.001):.1f}x"
552+
classical_verify = classical_result["timing_ms"]["verify"]
553+
pqc_verify = pqc_result["timing_ms"]["verify"]
554+
ratios["verify"] = f"{pqc_verify / max(classical_verify, 0.001):.1f}x"
555+
sig_ratio = (
556+
pqc_result["sizes_bytes"]["signature"] / classical_result["sizes_bytes"]["signature"]
557+
)
558+
ratios["signature_size"] = f"{sig_ratio:.0f}x"
559+
560+
return {
561+
"pqc": pqc_result,
562+
"classical": classical_result,
563+
"ratios": ratios,
564+
"summary": (
565+
f"{alg} vs {classical_name}: PQC keygen is {ratios['keygen']} slower. "
566+
f"PQC provides NIST Level {pqc_result.get('nist_level', '?')} quantum resistance."
567+
),
568+
}

0 commit comments

Comments
 (0)