cryptnox-id-cli 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. cryptnox_id_cli/__init__.py +22 -0
  2. cryptnox_id_cli/__main__.py +6 -0
  3. cryptnox_id_cli/applets/__init__.py +2 -0
  4. cryptnox_id_cli/applets/fido/__init__.py +7 -0
  5. cryptnox_id_cli/applets/fido/authdata.py +81 -0
  6. cryptnox_id_cli/applets/fido/constants.py +155 -0
  7. cryptnox_id_cli/applets/fido/ctap.py +430 -0
  8. cryptnox_id_cli/applets/fido/errors.py +90 -0
  9. cryptnox_id_cli/applets/fido/pinproto.py +166 -0
  10. cryptnox_id_cli/applets/genuine/__init__.py +12 -0
  11. cryptnox_id_cli/applets/genuine/constants.py +32 -0
  12. cryptnox_id_cli/applets/genuine/genuine.py +85 -0
  13. cryptnox_id_cli/applets/genuine/verify.py +150 -0
  14. cryptnox_id_cli/applets/mifare/__init__.py +10 -0
  15. cryptnox_id_cli/applets/mifare/desfire.py +353 -0
  16. cryptnox_id_cli/applets/mifare/ev2.py +421 -0
  17. cryptnox_id_cli/applets/piv/__init__.py +9 -0
  18. cryptnox_id_cli/applets/piv/admin.py +182 -0
  19. cryptnox_id_cli/applets/piv/apt.py +58 -0
  20. cryptnox_id_cli/applets/piv/constants.py +66 -0
  21. cryptnox_id_cli/applets/piv/keyimport.py +244 -0
  22. cryptnox_id_cli/applets/piv/objects.py +126 -0
  23. cryptnox_id_cli/applets/piv/perso.py +138 -0
  24. cryptnox_id_cli/applets/piv/piv.py +171 -0
  25. cryptnox_id_cli/applets/piv/preperso.py +174 -0
  26. cryptnox_id_cli/applets/piv/profiles.py +486 -0
  27. cryptnox_id_cli/applets/piv/slots.py +44 -0
  28. cryptnox_id_cli/cli/__init__.py +1 -0
  29. cryptnox_id_cli/cli/commands/__init__.py +1 -0
  30. cryptnox_id_cli/cli/commands/apdu.py +123 -0
  31. cryptnox_id_cli/cli/commands/doctor.py +144 -0
  32. cryptnox_id_cli/cli/commands/factory.py +312 -0
  33. cryptnox_id_cli/cli/commands/fido.py +729 -0
  34. cryptnox_id_cli/cli/commands/genuine.py +192 -0
  35. cryptnox_id_cli/cli/commands/info.py +94 -0
  36. cryptnox_id_cli/cli/commands/mifare.py +998 -0
  37. cryptnox_id_cli/cli/commands/piv.py +2558 -0
  38. cryptnox_id_cli/cli/commands/readers.py +64 -0
  39. cryptnox_id_cli/cli/commands/report.py +217 -0
  40. cryptnox_id_cli/cli/commands/shell.py +130 -0
  41. cryptnox_id_cli/cli/context.py +71 -0
  42. cryptnox_id_cli/cli/dryrun.py +181 -0
  43. cryptnox_id_cli/cli/main.py +117 -0
  44. cryptnox_id_cli/crypto/__init__.py +1 -0
  45. cryptnox_id_cli/crypto/attestation.py +169 -0
  46. cryptnox_id_cli/crypto/csr.py +135 -0
  47. cryptnox_id_cli/crypto/piv_objects.py +79 -0
  48. cryptnox_id_cli/crypto/x509util.py +37 -0
  49. cryptnox_id_cli/output/__init__.py +5 -0
  50. cryptnox_id_cli/output/render.py +106 -0
  51. cryptnox_id_cli/secrets/__init__.py +5 -0
  52. cryptnox_id_cli/secrets/redaction.py +141 -0
  53. cryptnox_id_cli/secrets/resolver.py +93 -0
  54. cryptnox_id_cli/state/__init__.py +19 -0
  55. cryptnox_id_cli/state/detector.py +273 -0
  56. cryptnox_id_cli/state/model.py +147 -0
  57. cryptnox_id_cli/transport/__init__.py +28 -0
  58. cryptnox_id_cli/transport/apdu.py +72 -0
  59. cryptnox_id_cli/transport/elevation.py +165 -0
  60. cryptnox_id_cli/transport/errors.py +185 -0
  61. cryptnox_id_cli/transport/pcsc.py +351 -0
  62. cryptnox_id_cli/transport/scp02.py +247 -0
  63. cryptnox_id_cli/transport/scp03.py +213 -0
  64. cryptnox_id_cli/trust/__init__.py +90 -0
  65. cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
  66. cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
  67. cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
  68. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
  69. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
  70. cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
  71. cryptnox_id_cli/util/__init__.py +1 -0
  72. cryptnox_id_cli/util/hexutil.py +36 -0
  73. cryptnox_id_cli/util/tlv.py +144 -0
  74. cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
  75. cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
  76. cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
  77. cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
  78. cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
  79. cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,117 @@
1
+ """CLI root group, global options and the error funnel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+
8
+ import click
9
+
10
+ from cryptnox_id_cli import CLI_NAME, __version__
11
+ from cryptnox_id_cli.cli import dryrun
12
+ from cryptnox_id_cli.cli.commands import apdu as apdu_cmd
13
+ from cryptnox_id_cli.cli.commands import doctor as doctor_cmd
14
+ from cryptnox_id_cli.cli.commands import factory as factory_cmd
15
+ from cryptnox_id_cli.cli.commands import fido as fido_cmd
16
+ from cryptnox_id_cli.cli.commands import genuine as genuine_cmd
17
+ from cryptnox_id_cli.cli.commands import info as info_cmd
18
+ from cryptnox_id_cli.cli.commands import mifare as mifare_cmd
19
+ from cryptnox_id_cli.cli.commands import piv as piv_cmd
20
+ from cryptnox_id_cli.cli.commands import readers as readers_cmd
21
+ from cryptnox_id_cli.cli.commands import report as report_cmd
22
+ from cryptnox_id_cli.cli.commands import shell as shell_cmd
23
+ from cryptnox_id_cli.cli.context import AppContext
24
+ from cryptnox_id_cli.transport.errors import CryptnoxError
25
+
26
+
27
+ def _report(app: AppContext | None, exc: CryptnoxError) -> None:
28
+ if app is not None and app.json:
29
+ sys.stdout.write(_json.dumps(exc.to_dict(), indent=2) + "\n")
30
+ elif app is not None:
31
+ app.out.error(str(exc))
32
+ else: # error before context creation
33
+ click.echo(f"error: {exc}", err=True)
34
+
35
+
36
+ class CryptnoxCLI(click.Group):
37
+ """Group that routes our own exceptions through the friendly error funnel."""
38
+
39
+ def invoke(self, ctx: click.Context) -> object:
40
+ try:
41
+ return super().invoke(ctx)
42
+ except CryptnoxError as exc:
43
+ app = ctx.obj if isinstance(ctx.obj, AppContext) else None
44
+ _report(app, exc)
45
+ sys.exit(getattr(exc, "exit_code", 1))
46
+
47
+
48
+ @click.group(cls=CryptnoxCLI, context_settings={"help_option_names": ["-h", "--help"]})
49
+ @click.version_option(__version__, prog_name=CLI_NAME)
50
+ @click.option("--reader", metavar="NAME|INDEX", help="Select PC/SC reader (default: Cryptnox/ACS).")
51
+ @click.option("--json", "json_", is_flag=True, help="Machine-readable JSON output.")
52
+ @click.option("--verbose", is_flag=True, help="Human-readable debug output (APDU trace to stderr).")
53
+ @click.option("--apdu-log", metavar="FILE", help="Append a redacted APDU transcript to FILE.")
54
+ @click.option(
55
+ "--dry-run",
56
+ is_flag=True,
57
+ help="Write nothing to the card: commands that can plan show the intended actions; "
58
+ "commands that cannot refuse to run (fail-closed).",
59
+ )
60
+ @click.option("--yes", is_flag=True, help="Skip destructive confirmation prompts.")
61
+ @click.option("--timeout", type=int, default=15, show_default=True, help="Reader/card timeout (s).")
62
+ @click.option("--no-color", is_flag=True, help="Disable coloured output.")
63
+ @click.option(
64
+ "--elevated-result-out", hidden=True, help="Internal: capture result JSON (elevation)."
65
+ )
66
+ @click.pass_context
67
+ def main(
68
+ ctx: click.Context,
69
+ reader: str | None,
70
+ json_: bool,
71
+ verbose: bool,
72
+ apdu_log: str | None,
73
+ dry_run: bool,
74
+ yes: bool,
75
+ timeout: int,
76
+ no_color: bool,
77
+ elevated_result_out: str | None,
78
+ ) -> None:
79
+ """Manage the Cryptnox multi-applet smartcard (PIV / FIDO2 / DESFire).
80
+
81
+ PIV management, factory pre-personalization and personalization run over a
82
+ contact or contactless reader; MIFARE DESFire needs a contactless reader; FIDO2
83
+ needs an Administrator terminal on Windows. Cryptnox/ACS readers are picked by default.
84
+ """
85
+ ctx.obj = AppContext(
86
+ reader=reader,
87
+ json=json_,
88
+ verbose=verbose,
89
+ apdu_log_path=apdu_log,
90
+ dry_run=dry_run,
91
+ yes=yes,
92
+ timeout=timeout,
93
+ no_color=no_color,
94
+ elevated_result_out=elevated_result_out,
95
+ )
96
+ ctx.call_on_close(ctx.obj.close)
97
+
98
+
99
+ main.add_command(readers_cmd.command)
100
+ main.add_command(info_cmd.command)
101
+ main.add_command(doctor_cmd.command)
102
+ main.add_command(apdu_cmd.command)
103
+ main.add_command(piv_cmd.command)
104
+ main.add_command(mifare_cmd.command)
105
+ main.add_command(fido_cmd.command)
106
+ main.add_command(genuine_cmd.command)
107
+ main.add_command(report_cmd.command)
108
+ main.add_command(factory_cmd.command)
109
+ main.add_command(shell_cmd.command)
110
+
111
+ # Enforce the --dry-run promise fail-closed: commands that cannot plan their card
112
+ # operations refuse to run under --dry-run instead of silently executing for real.
113
+ dryrun.install_guard(main)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1 @@
1
+ """Crypto helpers (X.509 inspection, PEM). Read-only in Phases 2-3."""
@@ -0,0 +1,169 @@
1
+ """Offline verification of a PIV key-attestation chain.
2
+
3
+ The pinned ``cryptography`` exposes no high-level path validator, so we walk the chain
4
+ manually (the same approach as the genuineness check in ``docs/verify-genuine.md``):
5
+
6
+ * name chaining — each cert's issuer equals the next cert's subject
7
+ * signature — each cert is signed by the next cert's public key
8
+ * validity — each cert is within its not-before/not-after window
9
+ * CA constraint — every issuer (intermediate/root) asserts BasicConstraints CA=TRUE
10
+ * anchoring — the chain terminates at a **pinned** root (passed in by the caller);
11
+ a root is trusted because it is pinned, never because it appears here
12
+
13
+ The leaf comes off the card; ``intermediates`` is an **unordered candidate pool** (the bundled
14
+ trust store holds several sibling CAs, and a card may present its chain in any order), and
15
+ ``roots`` are the pinned anchors. The path is built by matching each certificate's issuer name
16
+ and then checking the signature, so sibling CAs in the pool cannot be mistaken for the issuer.
17
+ An optional ``csr_spki`` enforces that the attested public key is the one in a CSR (the "same
18
+ key as your CSR" property).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import datetime
24
+ from dataclasses import dataclass, field
25
+
26
+ from cryptography import x509
27
+ from cryptography.exceptions import InvalidSignature
28
+ from cryptography.hazmat.primitives import serialization
29
+ from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
30
+
31
+
32
+ @dataclass
33
+ class AttestationResult:
34
+ """Outcome of :func:`verify_attestation_chain` — JSON-friendly."""
35
+
36
+ verified: bool
37
+ reasons: list[str] = field(default_factory=list) # empty iff verified
38
+ chain: list[str] = field(default_factory=list) # subjects, leaf -> anchor
39
+ csr_match: bool | None = None # None if no CSR was supplied
40
+ #: DER of each certificate on the path, leaf first — the certificates actually selected,
41
+ #: not the whole candidate pool. Kept out of ``to_dict`` (bytes are not JSON-friendly).
42
+ path_ders: list[bytes] = field(default_factory=list, repr=False)
43
+
44
+ def to_dict(self) -> dict[str, object]:
45
+ return {
46
+ "verified": self.verified,
47
+ "reasons": self.reasons,
48
+ "chain": self.chain,
49
+ "csr_match": self.csr_match,
50
+ }
51
+
52
+
53
+ def _spki(cert: x509.Certificate) -> bytes:
54
+ return cert.public_key().public_bytes(
55
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
56
+ )
57
+
58
+
59
+ def _cn(cert: x509.Certificate) -> str:
60
+ return cert.subject.rfc4514_string()
61
+
62
+
63
+ def _der(cert: x509.Certificate) -> bytes:
64
+ return cert.public_bytes(serialization.Encoding.DER)
65
+
66
+
67
+ def _is_ca(cert: x509.Certificate) -> bool:
68
+ try:
69
+ return bool(cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
70
+ except x509.ExtensionNotFound:
71
+ return False
72
+
73
+
74
+ def _signed_by(child: x509.Certificate, issuer: x509.Certificate) -> bool:
75
+ """True if ``issuer``'s public key verifies ``child``'s signature."""
76
+ hash_alg = child.signature_hash_algorithm
77
+ if hash_alg is None: # e.g. Ed25519 — not used for these attestation certs
78
+ return False
79
+ pub = issuer.public_key()
80
+ try:
81
+ if isinstance(pub, ec.EllipticCurvePublicKey):
82
+ pub.verify(child.signature, child.tbs_certificate_bytes, ec.ECDSA(hash_alg))
83
+ elif isinstance(pub, rsa.RSAPublicKey):
84
+ pub.verify(child.signature, child.tbs_certificate_bytes, padding.PKCS1v15(), hash_alg)
85
+ else: # unsupported issuer key type
86
+ return False
87
+ except InvalidSignature:
88
+ return False
89
+ return True
90
+
91
+
92
+ def verify_attestation_chain(
93
+ leaf_der: bytes,
94
+ intermediate_ders: list[bytes],
95
+ root_ders: list[bytes],
96
+ *,
97
+ csr_spki: bytes | None = None,
98
+ at_time: datetime.datetime | None = None,
99
+ ) -> AttestationResult:
100
+ """Validate ``leaf -> intermediates -> pinned root`` and collect every failure reason."""
101
+ now = at_time or datetime.datetime.now(datetime.timezone.utc)
102
+ leaf = x509.load_der_x509_certificate(leaf_der)
103
+ pool = [x509.load_der_x509_certificate(d) for d in intermediate_ders]
104
+ roots = [x509.load_der_x509_certificate(d) for d in root_ders]
105
+
106
+ reasons: list[str] = []
107
+ chain = [_cn(leaf)]
108
+ path_ders = [leaf_der]
109
+
110
+ # "Same key as your CSR".
111
+ csr_match: bool | None = None
112
+ if csr_spki is not None:
113
+ csr_match = _spki(leaf) == csr_spki
114
+ if not csr_match:
115
+ reasons.append("attested public key does not match the CSR")
116
+
117
+ if not roots:
118
+ reasons.append("no pinned trust anchor available — cannot verify the chain")
119
+
120
+ def _check_validity(cert: x509.Certificate) -> None:
121
+ if now < cert.not_valid_before_utc or now > cert.not_valid_after_utc:
122
+ reasons.append(f"certificate {_cn(cert)!r} is outside its validity window")
123
+
124
+ child = leaf
125
+ used: set[bytes] = {leaf_der}
126
+ # One hop per available certificate is the most any acyclic path can need; `used` already
127
+ # rules out revisiting, so this only bounds a pathological pool.
128
+ for _ in range(len(pool) + len(roots) + 1):
129
+ _check_validity(child)
130
+
131
+ # Anchor first: a pinned root that issued this certificate ends the path.
132
+ anchor = next(
133
+ (r for r in roots if r.subject == child.issuer and _signed_by(child, r)), None
134
+ )
135
+ if anchor is not None:
136
+ _check_validity(anchor)
137
+ chain.append(_cn(anchor))
138
+ path_ders.append(_der(anchor))
139
+ break
140
+
141
+ # Otherwise look for the issuer among the intermediates, by name *then* signature —
142
+ # the pool holds sibling CAs, so position in the list means nothing.
143
+ named = [c for c in pool if c.subject == child.issuer and _der(c) not in used]
144
+ issuer = next((c for c in named if _is_ca(c) and _signed_by(child, c)), None) or next(
145
+ (c for c in named if _signed_by(child, c)), None
146
+ )
147
+ if issuer is None:
148
+ if named: # right name, wrong key — a substituted or tampered certificate
149
+ reasons.append(f"signature on {_cn(child)!r} does not verify")
150
+ elif roots: # nothing on hand issues this cert, and no pinned root does either
151
+ reasons.append(f"not anchored: no pinned root issues {_cn(child)!r}")
152
+ break # (an empty `roots` already recorded its own reason above)
153
+
154
+ if not _is_ca(issuer):
155
+ reasons.append(f"issuer {_cn(issuer)!r} is not a CA (BasicConstraints)")
156
+ chain.append(_cn(issuer))
157
+ path_ders.append(_der(issuer))
158
+ used.add(_der(issuer))
159
+ child = issuer
160
+ else: # pragma: no cover - only reachable if the bound above is ever wrong
161
+ reasons.append("chain too long — giving up before reaching a pinned root")
162
+
163
+ return AttestationResult(
164
+ verified=not reasons,
165
+ reasons=reasons,
166
+ chain=chain,
167
+ csr_match=csr_match,
168
+ path_ders=path_ders,
169
+ )
@@ -0,0 +1,135 @@
1
+ """Build CSRs and self-signed certs around an external (on-card) signer.
2
+
3
+ The PIV private key never leaves the card, so we assemble the ASN.1 with
4
+ ``asn1crypto`` and sign the TBS bytes via a callback that runs GENERAL
5
+ AUTHENTICATE on the card.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from collections.abc import Callable
12
+
13
+ from asn1crypto import algos, csr, keys, pem, x509
14
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
15
+
16
+ # A signer takes the digest to sign and returns the signature bytes.
17
+ Signer = Callable[[bytes], bytes]
18
+
19
+ # mechanism id -> (hash func, signature-algorithm name, EC coord length or None for RSA)
20
+ _ALG = {
21
+ 0x11: (hashlib.sha256, "sha256_ecdsa", 32),
22
+ 0x14: (hashlib.sha384, "sha384_ecdsa", 48),
23
+ 0x07: (hashlib.sha256, "sha256_rsa", None),
24
+ 0x05: (hashlib.sha256, "sha256_rsa", None),
25
+ }
26
+
27
+ _RDN = {
28
+ "CN": "common_name",
29
+ "O": "organization_name",
30
+ "OU": "organizational_unit_name",
31
+ "C": "country_name",
32
+ "L": "locality_name",
33
+ "ST": "state_or_province_name",
34
+ "E": "email_address",
35
+ }
36
+
37
+
38
+ def parse_subject(subject: str) -> x509.Name:
39
+ """Parse a one-line DN like 'CN=John Doe,O=Cryptnox,C=CH' into a Name."""
40
+ parts: dict[str, str] = {}
41
+ for rdn in subject.split(","):
42
+ key, sep, value = rdn.partition("=")
43
+ key = key.strip().upper()
44
+ if not sep or key not in _RDN:
45
+ raise ValueError(f"unsupported subject component {rdn.strip()!r}")
46
+ parts[_RDN[key]] = value.strip()
47
+ if not parts:
48
+ raise ValueError("empty subject")
49
+ return x509.Name.build(parts)
50
+
51
+
52
+ def ensure_der_ecdsa(signature: bytes, coord_len: int) -> bytes:
53
+ """Return a DER ECDSA signature; convert from raw r||s if needed."""
54
+ if len(signature) == 2 * coord_len: # raw r||s
55
+ r = int.from_bytes(signature[:coord_len], "big")
56
+ s = int.from_bytes(signature[coord_len:], "big")
57
+ return encode_dss_signature(r, s)
58
+ return signature # already DER
59
+
60
+
61
+ def _finalize_sig(mechanism: int, raw: bytes, coord_len: int | None) -> bytes:
62
+ if coord_len is not None: # EC
63
+ return ensure_der_ecdsa(raw, coord_len)
64
+ return raw # RSA: card returns the PKCS#1 signature as-is
65
+
66
+
67
+ def build_csr(subject: str, public_key_der: bytes, mechanism: int, signer: Signer) -> bytes:
68
+ """Build a PEM PKCS#10 CSR, signing the request info on-card via ``signer``."""
69
+ hash_fn, sig_alg, coord = _ALG[mechanism]
70
+ info = csr.CertificationRequestInfo(
71
+ {
72
+ "version": "v1",
73
+ "subject": parse_subject(subject),
74
+ "subject_pk_info": keys.PublicKeyInfo.load(public_key_der),
75
+ "attributes": [],
76
+ }
77
+ )
78
+ signature = _finalize_sig(mechanism, signer(hash_fn(info.dump()).digest()), coord)
79
+ request = csr.CertificationRequest(
80
+ {
81
+ "certification_request_info": info,
82
+ "signature_algorithm": algos.SignedDigestAlgorithm({"algorithm": sig_alg}),
83
+ "signature": signature,
84
+ }
85
+ )
86
+ return pem.armor("CERTIFICATE REQUEST", request.dump())
87
+
88
+
89
+ def build_self_signed(
90
+ subject: str,
91
+ public_key_der: bytes,
92
+ mechanism: int,
93
+ signer: Signer,
94
+ *,
95
+ serial: int,
96
+ not_before: str,
97
+ not_after: str,
98
+ ) -> bytes:
99
+ """Build a PEM self-signed X.509 cert (dev/test), signed on-card.
100
+
101
+ ``not_before``/``not_after`` are ISO datetimes (passed in to avoid using the
102
+ clock here); ``serial`` is a positive integer.
103
+ """
104
+ hash_fn, sig_alg, coord = _ALG[mechanism]
105
+ name = parse_subject(subject)
106
+ alg = algos.SignedDigestAlgorithm({"algorithm": sig_alg})
107
+ tbs = x509.TbsCertificate(
108
+ {
109
+ "version": "v3",
110
+ "serial_number": serial,
111
+ "signature": alg,
112
+ "issuer": name,
113
+ "validity": {
114
+ "not_before": _time(not_before),
115
+ "not_after": _time(not_after),
116
+ },
117
+ "subject": name,
118
+ "subject_public_key_info": keys.PublicKeyInfo.load(public_key_der),
119
+ }
120
+ )
121
+ signature = _finalize_sig(mechanism, signer(hash_fn(tbs.dump()).digest()), coord)
122
+ cert = x509.Certificate(
123
+ {"tbs_certificate": tbs, "signature_algorithm": alg, "signature_value": signature}
124
+ )
125
+ return pem.armor("CERTIFICATE", cert.dump())
126
+
127
+
128
+ def _time(iso: str) -> x509.Time:
129
+ """Encode an ISO datetime as the RFC 5280-correct ASN.1 time: UTCTime for years
130
+ < 2050, GeneralizedTime for 2050+ (UTCTime's 2-digit year wraps otherwise)."""
131
+ from datetime import datetime
132
+
133
+ dt = datetime.fromisoformat(iso)
134
+ field = "utc_time" if dt.year < 2050 else "general_time"
135
+ return x509.Time({field: dt})
@@ -0,0 +1,79 @@
1
+ """Generators for basic PIV data objects (CHUID, CCC, Discovery).
2
+
3
+ These produce minimal, well-formed objects suitable for a dev/test card so the
4
+ operator never hand-crafts TLV. FASC-N / Card Identifier use placeholder values;
5
+ they are NOT federally meaningful credentials.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from cryptnox_id_cli.applets.piv.constants import PIV_AID
13
+ from cryptnox_id_cli.util import tlv
14
+
15
+ # CHUID element tags (SP 800-73-4 Part 1 Table 9).
16
+ _FASC_N = 0x30
17
+ _GUID = 0x34
18
+ _EXPIRATION = 0x35
19
+ _ISSUER_SIGNATURE = 0x3E
20
+ _ERROR_DETECTION = 0xFE
21
+
22
+
23
+ def generate_chuid(guid: bytes | None = None, expiration: str = "20400101") -> bytes:
24
+ """Build a minimal CHUID object value (the content wrapped by tag 0x53).
25
+
26
+ ``guid`` defaults to a random 16-byte value; ``expiration`` is YYYYMMDD ASCII.
27
+ The issuer signature (0x3E) is left empty (unsigned dev CHUID).
28
+ """
29
+ guid = guid or os.urandom(16)
30
+ if len(guid) != 16:
31
+ raise ValueError("GUID must be 16 bytes")
32
+ return (
33
+ tlv.build(_FASC_N, bytes(25)) # placeholder FASC-N
34
+ + tlv.build(_GUID, guid)
35
+ + tlv.build(_EXPIRATION, expiration.encode("ascii"))
36
+ + tlv.build(_ISSUER_SIGNATURE, b"")
37
+ + tlv.build(_ERROR_DETECTION, b"")
38
+ )
39
+
40
+
41
+ def generate_ccc(card_id: bytes | None = None) -> bytes:
42
+ """Build a minimal Card Capability Container value (content wrapped by 0x53)."""
43
+ # F0 Card Identifier (21 bytes): GSC-RID A000000116 + mfr/card-type + 14-byte id.
44
+ card_id = card_id or (bytes.fromhex("A000000116") + b"\xff\x02" + os.urandom(14))
45
+ if len(card_id) != 21:
46
+ raise ValueError("CCC card identifier must be 21 bytes")
47
+ return (
48
+ tlv.build(0xF0, card_id)
49
+ + tlv.build(0xF1, b"\x21") # capability container version
50
+ + tlv.build(0xF2, b"\x21") # capability grammar version
51
+ + tlv.build(0xF3, b"") # applications CardURL
52
+ + tlv.build(0xF4, b"\x00") # PKCS#15
53
+ + tlv.build(0xF5, b"\x10") # registered data model number
54
+ + tlv.build(0xF6, b"") # access control rule table
55
+ + tlv.build(0xF7, b"") # card APDUs
56
+ + tlv.build(0xFA, b"") # redirection tag
57
+ + tlv.build(0xFB, b"") # capability tuples
58
+ + tlv.build(0xFC, b"") # status tuples
59
+ + tlv.build(0xFD, b"") # next CCC
60
+ + tlv.build(0xFE, b"") # error detection
61
+ )
62
+
63
+
64
+ # PIN Usage Policy (SP 800-73-4 Part 1, Discovery Object tag 0x5F2F): first byte
65
+ # 0x40 = the PIV Application PIN (key ref 0x80) is present; second byte 0x10 = it is
66
+ # the primary PIN. We deliberately do NOT offer the Global PIN (first byte 0x20 /
67
+ # second byte 0x20, key ref 0x00) -- see docs/adr/0001-piv-application-pin-not-global-pin.md.
68
+ _PIN_USAGE_APPLICATION_PRIMARY = b"\x40\x10"
69
+
70
+
71
+ def generate_discovery() -> bytes:
72
+ """Build the Discovery Object (the 0x7E TLV itself, not 0x53-wrapped).
73
+
74
+ The PIN Usage Policy is fixed to "Application PIN, primary" (0x40 0x10). The Global
75
+ PIN is intentionally not supported (ADR-0001), so this is not configurable.
76
+ """
77
+ return tlv.build_constructed(
78
+ 0x7E, tlv.build(0x4F, PIV_AID) + tlv.build(0x5F2F, _PIN_USAGE_APPLICATION_PRIMARY)
79
+ )
@@ -0,0 +1,37 @@
1
+ """Minimal X.509 inspection for PIV certificate objects (read-only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from cryptography import x509
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
10
+
11
+
12
+ def to_pem(der: bytes) -> bytes:
13
+ return x509.load_der_x509_certificate(der).public_bytes(serialization.Encoding.PEM)
14
+
15
+
16
+ def _key_description(cert: x509.Certificate) -> str:
17
+ pub = cert.public_key()
18
+ if isinstance(pub, rsa.RSAPublicKey):
19
+ return f"RSA-{pub.key_size}"
20
+ if isinstance(pub, ec.EllipticCurvePublicKey):
21
+ return f"EC-{pub.curve.name}"
22
+ return type(pub).__name__
23
+
24
+
25
+ def describe_certificate(der: bytes) -> dict[str, object]:
26
+ """Return a JSON-able summary of a DER certificate."""
27
+ cert = x509.load_der_x509_certificate(der)
28
+ return {
29
+ "subject": cert.subject.rfc4514_string(),
30
+ "issuer": cert.issuer.rfc4514_string(),
31
+ "serial": format(cert.serial_number, "X"),
32
+ "not_before": cert.not_valid_before_utc.isoformat(),
33
+ "not_after": cert.not_valid_after_utc.isoformat(),
34
+ "signature_algorithm": cert.signature_algorithm_oid._name,
35
+ "public_key": _key_description(cert),
36
+ "sha256_fingerprint": hashlib.sha256(der).hexdigest().upper(),
37
+ }
@@ -0,0 +1,5 @@
1
+ """Output rendering: one result object rendered as human tables or JSON."""
2
+
3
+ from cryptnox_id_cli.output.render import Output
4
+
5
+ __all__ = ["Output"]
@@ -0,0 +1,106 @@
1
+ """Unified output: each command builds a JSON-able payload plus a human renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+ from collections.abc import Callable
8
+
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+
13
+ class Output:
14
+ """Holds the rendering policy (JSON vs human, colour) for one CLI invocation."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ json: bool = False,
20
+ no_color: bool = False,
21
+ verbose: bool = False,
22
+ result_path: str | None = None,
23
+ ) -> None:
24
+ self.json = json
25
+ self.verbose = verbose
26
+ # When set (elevated child), the result payload is also written here as JSON so
27
+ # the non-elevated parent can surface it in the original console.
28
+ self.result_path = result_path
29
+ # In JSON mode we keep stdout pure (no rich), so diagnostics go to stderr.
30
+ self.console = Console(no_color=no_color, highlight=False, soft_wrap=True)
31
+ self.err = Console(stderr=True, no_color=no_color, highlight=False, soft_wrap=True)
32
+
33
+ # -- results ------------------------------------------------------------ #
34
+ def result(self, payload: dict, render_human: Callable[[Console], None]) -> None:
35
+ if self.result_path:
36
+ with open(self.result_path, "w", encoding="utf-8") as fh:
37
+ fh.write(_json.dumps(payload, indent=2, default=str) + "\n")
38
+ if self.json:
39
+ sys.stdout.write(_json.dumps(payload, indent=2, default=str) + "\n")
40
+ else:
41
+ render_human(self.console)
42
+
43
+ # -- ad-hoc messages (suppressed or routed to stderr in JSON mode) ------ #
44
+ def info(self, message: str) -> None:
45
+ if not self.json:
46
+ self.console.print(message)
47
+
48
+ def detail(self, message: str) -> None:
49
+ if self.verbose and not self.json:
50
+ self.console.print(f"[dim]{message}[/dim]")
51
+
52
+ def note(self, message: str) -> None:
53
+ """A dim, always-shown informational line (e.g. a satisfied requirement)."""
54
+ target = self.err if self.json else self.console
55
+ target.print(f"[dim]note: {message}[/dim]")
56
+
57
+ def warn(self, message: str) -> None:
58
+ target = self.err if self.json else self.console
59
+ target.print(f"[yellow]warning:[/yellow] {message}")
60
+
61
+ def error(self, message: str) -> None:
62
+ self.err.print(f"[red]error:[/red] {message}")
63
+
64
+ def success(self, message: str) -> None:
65
+ if not self.json:
66
+ self.console.print(f"[green]OK[/green] {message}")
67
+
68
+ # -- helpers ------------------------------------------------------------ #
69
+ def table(self, *headers: str, title: str | None = None) -> Table:
70
+ table = Table(title=title, show_lines=False, header_style="bold")
71
+ for h in headers:
72
+ table.add_column(h)
73
+ return table
74
+
75
+ def print(self, renderable: object) -> None:
76
+ if not self.json:
77
+ self.console.print(renderable)
78
+
79
+
80
+ def state_style(label: str) -> str:
81
+ """Colour a state label for human output."""
82
+ good = {
83
+ "PivPersonalized",
84
+ "FidoPersonalized",
85
+ "DesfireReachable",
86
+ "CardPresent",
87
+ "GenuinenessPersonalized",
88
+ }
89
+ warn = {
90
+ "PivPrePersonalized",
91
+ "PivPartiallyPersonalized",
92
+ "PivSelectable",
93
+ "FidoSelectable",
94
+ "DesfireNeedsContactlessReader",
95
+ "DesfireNoAnswerContactless",
96
+ "FidoBlockedByOS",
97
+ "GenuinenessPresent",
98
+ "GenuinenessNeedsContactReader",
99
+ }
100
+ if label in good:
101
+ return f"[green]{label}[/green]"
102
+ if label in warn:
103
+ return f"[yellow]{label}[/yellow]"
104
+ if label in {"Unknown"} or label.endswith("NotPresent"):
105
+ return f"[dim]{label}[/dim]"
106
+ return label
@@ -0,0 +1,5 @@
1
+ """Secret handling: a single redaction chokepoint and secret-input resolver.
2
+
3
+ Nothing in this CLI prints or logs a secret except through code that has first
4
+ passed it through :class:`~cryptnox_id_cli.secrets.redaction.Redactor`.
5
+ """