keys.py
python
sha256:e519738f2102d625d47828a17d113283cd744577b43551013cb668fd04f1c3fe
chore(mwp8/phase5): tick all RG checkboxes; close-out doc (…
Sonnet 4.6
18 days ago
| 1 | """Algorithm-agnostic public-key signature verification for MuseHub auth. |
| 2 | |
| 3 | Design for quantum readiness |
| 4 | ----------------------------- |
| 5 | Ed25519 (based on elliptic-curve Diffie-Hellman) is broken by Shor's algorithm |
| 6 | running on a sufficiently powerful quantum computer. NIST finalised three |
| 7 | post-quantum signature standards in 2024: |
| 8 | |
| 9 | FIPS 203 ML-KEM (key encapsulation — not needed here) |
| 10 | FIPS 204 ML-DSA-65 (signatures — the drop-in replacement for Ed25519) |
| 11 | FIPS 205 SLH-DSA (signatures — hash-based, conservative option) |
| 12 | |
| 13 | This module implements a single interface for all signature algorithms so that |
| 14 | migrating from Ed25519 → ML-DSA-65 requires changing exactly this file. |
| 15 | |
| 16 | Current status |
| 17 | -------------- |
| 18 | ``ed25519`` IMPLEMENTED via PyCA ``cryptography`` (hazmat Ed25519PublicKey) |
| 19 | ``ml-dsa-65`` DEFINED awaiting stable Python library support. |
| 20 | The ``cryptography`` library (PyCA) plans ML-DSA support; |
| 21 | ``liboqs-python`` (Open Quantum Safe) supports it today but |
| 22 | requires native compilation. Uncomment the ML-DSA block below |
| 23 | when the dependency is available in the container. |
| 24 | |
| 25 | Algorithm wire identifiers follow the IANA/NIST naming convention: |
| 26 | "ed25519" — RFC 8032 / FIPS 186-5 |
| 27 | "ml-dsa-65" — FIPS 204 (formerly CRYSTALS-Dilithium-3) |
| 28 | |
| 29 | Key and signature sizes (raw bytes) |
| 30 | ------------------------------------ |
| 31 | Algorithm Public key Signature |
| 32 | ed25519 32 64 |
| 33 | ml-dsa-65 1952 3309 |
| 34 | |
| 35 | Usage |
| 36 | ----- |
| 37 | from musehub.crypto.keys import KeyAlgorithm, verify_signature, key_fingerprint |
| 38 | |
| 39 | # Verification raises InvalidKeyError or SignatureError on failure |
| 40 | verify_signature( |
| 41 | algorithm=KeyAlgorithm.ED25519, |
| 42 | public_key_bytes=raw_pub, |
| 43 | message=nonce_bytes, |
| 44 | signature_bytes=sig, |
| 45 | ) |
| 46 | |
| 47 | fp = key_fingerprint(raw_pub) # "sha256:<64-hex>", length 71 |
| 48 | """ |
| 49 | |
| 50 | import hashlib |
| 51 | import hmac |
| 52 | from enum import Enum |
| 53 | from typing import Final |
| 54 | |
| 55 | from muse.core.types import DEFAULT_SIGN_ALGO, b64url_encode, b64url_decode |
| 56 | |
| 57 | # --------------------------------------------------------------------------- |
| 58 | # Algorithm registry |
| 59 | # --------------------------------------------------------------------------- |
| 60 | |
| 61 | class KeyAlgorithm(str, Enum): |
| 62 | """Supported key algorithm identifiers (wire strings).""" |
| 63 | |
| 64 | ED25519 = DEFAULT_SIGN_ALGO |
| 65 | ML_DSA_65 = "ml-dsa-65" |
| 66 | |
| 67 | # Expected sizes in raw bytes for each algorithm. |
| 68 | # Used for pre-validation before calling into the crypto library. |
| 69 | PUBLIC_KEY_SIZES: Final[dict[KeyAlgorithm, int]] = { |
| 70 | KeyAlgorithm.ED25519: 32, |
| 71 | KeyAlgorithm.ML_DSA_65: 1952, |
| 72 | } |
| 73 | |
| 74 | SIGNATURE_SIZES: Final[dict[KeyAlgorithm, int]] = { |
| 75 | KeyAlgorithm.ED25519: 64, |
| 76 | KeyAlgorithm.ML_DSA_65: 3309, |
| 77 | } |
| 78 | |
| 79 | # Human-readable descriptions for documentation and error messages |
| 80 | ALGORITHM_DESCRIPTIONS: Final[dict[KeyAlgorithm, str]] = { |
| 81 | KeyAlgorithm.ED25519: "Ed25519 (RFC 8032 / FIPS 186-5) — classical, not quantum-safe", |
| 82 | KeyAlgorithm.ML_DSA_65: "ML-DSA-65 (FIPS 204) — NIST post-quantum standard, quantum-safe", |
| 83 | } |
| 84 | |
| 85 | # Algorithms that are currently implemented and callable. |
| 86 | # ML_DSA_65 is defined above but implementation is pending stable Python library. |
| 87 | IMPLEMENTED_ALGORITHMS: Final[frozenset[KeyAlgorithm]] = frozenset({ |
| 88 | KeyAlgorithm.ED25519, |
| 89 | # KeyAlgorithm.ML_DSA_65, # Uncomment when liboqs-python or cryptography adds support |
| 90 | }) |
| 91 | |
| 92 | # Default algorithm for new registrations |
| 93 | DEFAULT_ALGORITHM: Final[KeyAlgorithm] = KeyAlgorithm.ED25519 |
| 94 | |
| 95 | # --------------------------------------------------------------------------- |
| 96 | # Exceptions |
| 97 | # --------------------------------------------------------------------------- |
| 98 | |
| 99 | class InvalidKeyError(ValueError): |
| 100 | """Raised when a public key is malformed, wrong size, or unsupported.""" |
| 101 | |
| 102 | class SignatureError(ValueError): |
| 103 | """Raised when a signature fails verification or is malformed.""" |
| 104 | |
| 105 | class AlgorithmNotImplementedError(NotImplementedError): |
| 106 | """Raised when the requested algorithm is defined but not yet implemented.""" |
| 107 | |
| 108 | def __init__(self, algorithm: KeyAlgorithm) -> None: |
| 109 | super().__init__( |
| 110 | f"Algorithm '{algorithm.value}' is defined but not yet implemented. " |
| 111 | f"See musehub/crypto/keys.py for the upgrade path." |
| 112 | ) |
| 113 | |
| 114 | # --------------------------------------------------------------------------- |
| 115 | # Core functions |
| 116 | # --------------------------------------------------------------------------- |
| 117 | |
| 118 | def key_fingerprint(public_key_bytes: bytes) -> str: |
| 119 | """Return the canonical ``sha256:``-prefixed fingerprint of raw public key bytes. |
| 120 | |
| 121 | Returns ``sha256:<64-hex>`` (71 chars total). Delegates to the single |
| 122 | canonical implementation in ``muse.core.types``. |
| 123 | """ |
| 124 | from muse.core.types import public_key_fingerprint # noqa: PLC0415 |
| 125 | return public_key_fingerprint(public_key_bytes) |
| 126 | |
| 127 | def fingerprints_equal(a: str, b: str) -> bool: |
| 128 | """Constant-time comparison of two ``sha256:``-prefixed fingerprint strings. |
| 129 | |
| 130 | Uses ``hmac.compare_digest`` to prevent timing-based side-channel attacks. |
| 131 | """ |
| 132 | return hmac.compare_digest(a.lower(), b.lower()) |
| 133 | |
| 134 | def verify_signature( |
| 135 | *, |
| 136 | algorithm: KeyAlgorithm, |
| 137 | public_key_bytes: bytes, |
| 138 | message: bytes, |
| 139 | signature_bytes: bytes, |
| 140 | ) -> None: |
| 141 | """Verify a digital signature. Raises on any failure. |
| 142 | |
| 143 | Args: |
| 144 | algorithm: Which signing algorithm to use for verification. |
| 145 | public_key_bytes: Raw (unencoded) public key bytes. |
| 146 | message: The exact bytes that were signed. |
| 147 | signature_bytes: The raw signature bytes. |
| 148 | |
| 149 | Raises: |
| 150 | AlgorithmNotImplementedError: Algorithm is defined but not yet available. |
| 151 | InvalidKeyError: Public key is malformed or the wrong size. |
| 152 | SignatureError: Signature verification failed or signature is malformed. |
| 153 | """ |
| 154 | if algorithm not in IMPLEMENTED_ALGORITHMS: |
| 155 | raise AlgorithmNotImplementedError(algorithm) |
| 156 | |
| 157 | expected_key_size = PUBLIC_KEY_SIZES[algorithm] |
| 158 | if len(public_key_bytes) != expected_key_size: |
| 159 | raise InvalidKeyError( |
| 160 | f"Algorithm '{algorithm.value}' requires a {expected_key_size}-byte public key; " |
| 161 | f"got {len(public_key_bytes)} bytes." |
| 162 | ) |
| 163 | |
| 164 | expected_sig_size = SIGNATURE_SIZES[algorithm] |
| 165 | if len(signature_bytes) != expected_sig_size: |
| 166 | raise SignatureError( |
| 167 | f"Algorithm '{algorithm.value}' requires a {expected_sig_size}-byte signature; " |
| 168 | f"got {len(signature_bytes)} bytes." |
| 169 | ) |
| 170 | |
| 171 | if algorithm is KeyAlgorithm.ED25519: |
| 172 | _verify_ed25519(public_key_bytes, message, signature_bytes) |
| 173 | elif algorithm is KeyAlgorithm.ML_DSA_65: # pragma: no cover — not yet implemented |
| 174 | _verify_ml_dsa_65(public_key_bytes, message, signature_bytes) |
| 175 | |
| 176 | # --------------------------------------------------------------------------- |
| 177 | # Per-algorithm implementations |
| 178 | # --------------------------------------------------------------------------- |
| 179 | |
| 180 | def _verify_ed25519( |
| 181 | public_key_bytes: bytes, |
| 182 | message: bytes, |
| 183 | signature_bytes: bytes, |
| 184 | ) -> None: |
| 185 | """Verify an Ed25519 signature using PyCA cryptography (hazmat layer). |
| 186 | |
| 187 | Ed25519 is a deterministic Schnorr signature over Curve25519. It is NOT |
| 188 | quantum-safe — a cryptographically-relevant quantum computer running Shor's |
| 189 | algorithm can recover the private key from the public key. |
| 190 | |
| 191 | When to migrate: when `IMPLEMENTED_ALGORITHMS` includes ML_DSA_65, update |
| 192 | the CLI key-generation command to produce ML-DSA-65 keys and advise users |
| 193 | to re-register. Old Ed25519 keys continue to work until manually revoked. |
| 194 | """ |
| 195 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey |
| 196 | from cryptography.exceptions import InvalidSignature |
| 197 | |
| 198 | try: |
| 199 | pub_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) |
| 200 | except (ValueError, TypeError) as exc: |
| 201 | raise InvalidKeyError(f"Invalid Ed25519 public key: {exc}") from exc |
| 202 | |
| 203 | try: |
| 204 | pub_key.verify(signature_bytes, message) |
| 205 | except InvalidSignature as exc: |
| 206 | raise SignatureError("Ed25519 signature verification failed") from exc |
| 207 | |
| 208 | def _verify_ml_dsa_65( |
| 209 | public_key_bytes: bytes, # noqa: ARG001 |
| 210 | message: bytes, # noqa: ARG001 |
| 211 | signature_bytes: bytes, # noqa: ARG001 |
| 212 | ) -> None: # pragma: no cover |
| 213 | """Verify an ML-DSA-65 signature (FIPS 204 / formerly CRYSTALS-Dilithium-3). |
| 214 | |
| 215 | ML-DSA-65 is a lattice-based signature scheme standardised by NIST in |
| 216 | August 2024. It is believed to be secure against both classical and |
| 217 | quantum adversaries. |
| 218 | |
| 219 | Implementation status: NOT YET AVAILABLE in stable Python builds. |
| 220 | |
| 221 | To enable: |
| 222 | 1. Add ``liboqs-python`` to ``requirements.txt`` and rebuild the Docker image. |
| 223 | (Requires ``cmake`` and C build tools in the container.) |
| 224 | OR wait for ``cryptography`` (PyCA) to add ML-DSA support. |
| 225 | |
| 226 | 2. Uncomment ``KeyAlgorithm.ML_DSA_65`` in ``IMPLEMENTED_ALGORITHMS``. |
| 227 | |
| 228 | 3. Remove this ``raise`` and implement the verification: |
| 229 | |
| 230 | import oqs |
| 231 | verifier = oqs.Signature("Dilithium3") |
| 232 | is_valid = verifier.verify(message, signature_bytes, public_key_bytes) |
| 233 | if not is_valid: |
| 234 | raise SignatureError("ML-DSA-65 signature verification failed") |
| 235 | |
| 236 | 4. Update DEFAULT_ALGORITHM to ``KeyAlgorithm.ML_DSA_65`` and document |
| 237 | the migration path for existing Ed25519 users. |
| 238 | """ |
| 239 | raise AlgorithmNotImplementedError(KeyAlgorithm.ML_DSA_65) |
| 240 | |
| 241 | # b64url_encode and b64url_decode live in muse.core.types — re-exported above. |
File History
2 commits
sha256:e519738f2102d625d47828a17d113283cd744577b43551013cb668fd04f1c3fe
chore(mwp8/phase5): tick all RG checkboxes; close-out doc (…
Sonnet 4.6
18 days ago
sha256:0e5174da777684df43b2db71f282079030ef28bacffb93e19c29ee712b2a8bc4
test(mwp8/phase0): audit — repair+force-push leave stale ca…
Sonnet 4.6
20 days ago