From 017f9ee1074b92384c35e720674dd2a73c7b59d8 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Wed, 2 Sep 2026 18:39:19 +0200 Subject: [PATCH] Add RSA4096 to the PIV algorithm set perso generate-key, perso import-key (plain N/E/D form), CSR and self-signed-cert signing, and profile key mechanisms all accept RSA4096 (mechanism 0x16), alongside the existing algorithms. generate-key gains the --create-key-object dev/eval fallback import-key already had, and 6F00 on RSA generate/import is reported as the chip's RSA size cap instead of the raw status word. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +++++ docs/factory/pre-personalization-profiles.rst | 2 +- docs/piv/piv-personalization.rst | 4 +-- src/cryptnox_id_cli/applets/piv/constants.py | 9 ++--- src/cryptnox_id_cli/applets/piv/keyimport.py | 6 ++-- src/cryptnox_id_cli/applets/piv/perso.py | 2 +- src/cryptnox_id_cli/applets/piv/profiles.py | 1 + src/cryptnox_id_cli/cli/commands/piv.py | 35 ++++++++++++++++--- src/cryptnox_id_cli/crypto/csr.py | 1 + 9 files changed, 51 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5370036..d35f653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `RSA4096` alongside the existing algorithms: `perso generate-key`, + `perso import-key`, CSR/self-signed-cert signing, and profile key + mechanisms. +- `perso generate-key --create-key-object` - the same dev/eval fallback + `import-key` already had. + ## [1.0.3] - 2026-08-31 ### Changed diff --git a/docs/factory/pre-personalization-profiles.rst b/docs/factory/pre-personalization-profiles.rst index cd218e5..efa4625 100644 --- a/docs/factory/pre-personalization-profiles.rst +++ b/docs/factory/pre-personalization-profiles.rst @@ -110,7 +110,7 @@ Profile YAML attributes: [IMPORTABLE] **Mechanisms**: ``AES128``, ``AES192``, ``AES256``, ``RSA2048``, ``RSA3072``, -``ECCP256``, ``ECCP384``, ``CS2``, ``CS7``. +``RSA4096``, ``ECCP256``, ``ECCP384``, ``CS2``, ``CS7``. **Access modes** (used for ``contact``/``contactless`` on both containers and keys): ``ALWAYS``, ``NEVER``, ``PIN``, ``VCI`` (contact-interface-only, no diff --git a/docs/piv/piv-personalization.rst b/docs/piv/piv-personalization.rst index fd3d1eb..73b008f 100644 --- a/docs/piv/piv-personalization.rst +++ b/docs/piv/piv-personalization.rst @@ -109,8 +109,8 @@ mechanism the slot has no object for returns ``6A80``. ECC P-256 everywhere, accepts explicit RSA only for ``--profile ms-logon`` on 9A (the one built-in shape with an RSA object, and the one Windows needs — the ECC default warns there), and elsewhere refuses RSA early rather than -failing on the card. RSA-1024 and -RSA-4096 are removed from the build; the CLI never offers them. +failing on the card. RSA-1024 is removed from the build; the CLI never offers +it. .. _piv-personalization-import-key: diff --git a/src/cryptnox_id_cli/applets/piv/constants.py b/src/cryptnox_id_cli/applets/piv/constants.py index 4740b7b..107a3b5 100644 --- a/src/cryptnox_id_cli/applets/piv/constants.py +++ b/src/cryptnox_id_cli/applets/piv/constants.py @@ -52,12 +52,9 @@ 0x2E: "PIV-SM-CS7 (AES-256/SHA-384)", } -# Algorithms this OpenFIPS201 FIPS build actually implements. RSA-4096 (0x16) is -# included: on-card createKey + GENERATE for RSA-4096 is confirmed on the D600/SCP03 -# hardware (returns a real 512-byte modulus), gated only by the chip's DF2B max-RSA -# = 0x1000 on a non-fused card. NOTE: this covers on-card key GENERATION and key -# objects; the host-side key-IMPORT/sign helpers (keyimport.py) still cover only -# RSA-2048/3072, so external RSA-4096 import is a separate, not-yet-implemented path. +# Algorithms this OpenFIPS201 FIPS build actually implements. A chip whose DF2B +# configuration caps RSA below a requested size answers 6F00; the CLI maps that to +# a size-cap message on GENERATE/import. SUPPORTED_ALGORITHMS: frozenset[int] = frozenset( {0x07, 0x05, 0x16, 0x08, 0x0A, 0x0C, 0x11, 0x14, 0x27, 0x2E} ) diff --git a/src/cryptnox_id_cli/applets/piv/keyimport.py b/src/cryptnox_id_cli/applets/piv/keyimport.py index d4d5b06..64e7828 100644 --- a/src/cryptnox_id_cli/applets/piv/keyimport.py +++ b/src/cryptnox_id_cli/applets/piv/keyimport.py @@ -51,7 +51,7 @@ # Mechanism -> (curve, scalar/point sizes) for ECC, modulus size for RSA. _EC_BY_MECH: dict[int, type[ec.EllipticCurve]] = {0x11: ec.SECP256R1, 0x14: ec.SECP384R1} _EC_SCALAR_LEN = {0x11: 32, 0x14: 48} -_RSA_MODULUS_LEN = {0x07: 256, 0x05: 384} +_RSA_MODULUS_LEN = {0x07: 256, 0x05: 384, 0x16: 512} # Host-side slot policy (SP 800-78 per-key-reference algorithm table): PKI slots # and the retired key history accept the four asymmetric mechanisms; 9B is @@ -133,11 +133,11 @@ def mechanism_for_key(key) -> int: if key.key_size == k * 8: return mech raise KeyImportError( - f"RSA-{key.key_size} not supported; this applet takes RSA-2048 or RSA-3072." + f"RSA-{key.key_size} not supported; this applet takes RSA-2048, RSA-3072 or RSA-4096." ) raise KeyImportError( f"unsupported key type {type(key).__name__}; this applet takes " - "ECC P-256/P-384 and RSA-2048/3072." + "ECC P-256/P-384 and RSA-2048/3072/4096." ) diff --git a/src/cryptnox_id_cli/applets/piv/perso.py b/src/cryptnox_id_cli/applets/piv/perso.py index 8c22dcc..e01280f 100644 --- a/src/cryptnox_id_cli/applets/piv/perso.py +++ b/src/cryptnox_id_cli/applets/piv/perso.py @@ -36,7 +36,7 @@ _EC_CURVES = {0x11: ec.SECP256R1, 0x14: ec.SECP384R1} # Asymmetric algorithm name -> mechanism id (those this applet supports). -ALGORITHMS = {"ECCP256": 0x11, "ECCP384": 0x14, "RSA2048": 0x07, "RSA3072": 0x05} +ALGORITHMS = {"ECCP256": 0x11, "ECCP384": 0x14, "RSA2048": 0x07, "RSA3072": 0x05, "RSA4096": 0x16} def pad_pin(value: bytes, length: int = 8) -> bytes: diff --git a/src/cryptnox_id_cli/applets/piv/profiles.py b/src/cryptnox_id_cli/applets/piv/profiles.py index 2da80a6..229ac2a 100644 --- a/src/cryptnox_id_cli/applets/piv/profiles.py +++ b/src/cryptnox_id_cli/applets/piv/profiles.py @@ -24,6 +24,7 @@ "AES256": 0x0C, "RSA2048": 0x07, "RSA3072": 0x05, + "RSA4096": 0x16, "ECCP256": 0x11, "ECCP384": 0x14, "CS2": 0x27, diff --git a/src/cryptnox_id_cli/cli/commands/piv.py b/src/cryptnox_id_cli/cli/commands/piv.py index b99c310..6d6d073 100644 --- a/src/cryptnox_id_cli/cli/commands/piv.py +++ b/src/cryptnox_id_cli/cli/commands/piv.py @@ -805,7 +805,10 @@ def perso() -> None: @perso.command("generate-key") @click.option("--slot", required=True, help="Key slot hex, e.g. 9A.") @click.option( - "--algorithm", default="ECCP256", show_default=True, help="ECCP256/ECCP384/RSA2048/RSA3072." + "--algorithm", + default="ECCP256", + show_default=True, + help="ECCP256/ECCP384/RSA2048/RSA3072/RSA4096.", ) @click.option( "--default-keys", @@ -816,9 +819,21 @@ def perso() -> None: @click.option( "--out", "out_", type=click.Path(dir_okay=False), help="Write public-key PEM to FILE." ) +@click.option( + "--create-key-object", + is_flag=True, + help="If the slot has no key object for this (slot, algorithm) pair, create one " + "first (PUT DATA ADMIN, non-finalized cards only) - same dev/eval fallback as " + "`import-key --create-key-object`.", +) @click.pass_obj def perso_generate_key( - app: AppContext, slot: str, algorithm: str, default_keys: bool, out_: str | None + app: AppContext, + slot: str, + algorithm: str, + default_keys: bool, + out_: str | None, + create_key_object: bool, ) -> None: """Generate a key pair ON-CARD; the private key never leaves the card. @@ -850,7 +865,13 @@ def perso_generate_key( f"Replace the certified key in slot {slot}?", default=False ): raise click.Abort() - public_key = _generate_key_on_card(PivAdmin(session), keys, ref, mech, label=slot) + adm = PivAdmin(session) + if create_key_object: + _select(session) + probe = session.transmit(keyimport.probe_apdu(ref, mech), context=f"probe {slot}") + if probe.sw == 0x6A86: + _create_key_object(app, adm, keys, ref, mech, False, slot) + public_key = _generate_key_on_card(adm, keys, ref, mech, label=slot) pem = perso_mod.public_key_pem(public_key) der = public_key.public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo @@ -890,6 +911,8 @@ def _generate_key_on_card(adm: PivAdmin, keys, ref: int, mech: int, *, label: st adm.select() adm.open(keys) resp = adm.send(perso_mod.generate_keypair_apdu(ref, mech), context=f"GENERATE {label}") + if resp.sw == 0x6F00 and keyimport.rsa_modulus_len(mech): + raise CryptnoxError(f"GENERATE key {label} failed (SW=6F00): {_IMPORT_SW_HINTS[0x6F00]}.") if not resp.ok: raise StatusWordError(resp.sw1, resp.sw2, context=f"GENERATE key {label}") return perso_mod.parse_public_key(mech, resp.data) @@ -994,6 +1017,10 @@ def signer(digest: bytes) -> bytes: "CRT attribute does not match; try the other --rsa-form" ), 0x6700: "element too long for the transport (chaining should have engaged - report this)", + 0x6F00: ( + "the chip refuses this key size - its pre-issuance maximum-RSA configuration " + "(DF2B) caps RSA below it, and that is factory-set before the card is secured" + ), } @@ -2130,7 +2157,7 @@ def _collect_quickstart_facts(session, ref: int, mech: int) -> tuple[CardFacts, "--algorithm", default="ECCP256", show_default=True, - help="ECCP256, ECCP384, RSA2048 or RSA3072; the slot must have a key object for it " + help="ECCP256, ECCP384, RSA2048, RSA3072 or RSA4096; the slot must have a key object for it " "(RSA: only ms-logon's 9A among the built-in profiles — pass RSA2048 explicitly " "for Windows, which does not enumerate EC keys).", ) diff --git a/src/cryptnox_id_cli/crypto/csr.py b/src/cryptnox_id_cli/crypto/csr.py index 4acf15e..66d94db 100644 --- a/src/cryptnox_id_cli/crypto/csr.py +++ b/src/cryptnox_id_cli/crypto/csr.py @@ -22,6 +22,7 @@ 0x14: (hashlib.sha384, "sha384_ecdsa", 48), 0x07: (hashlib.sha256, "sha256_rsa", None), 0x05: (hashlib.sha256, "sha256_rsa", None), + 0x16: (hashlib.sha256, "sha256_rsa", None), } _RDN = {