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.
- cryptnox_id_cli/__init__.py +22 -0
- cryptnox_id_cli/__main__.py +6 -0
- cryptnox_id_cli/applets/__init__.py +2 -0
- cryptnox_id_cli/applets/fido/__init__.py +7 -0
- cryptnox_id_cli/applets/fido/authdata.py +81 -0
- cryptnox_id_cli/applets/fido/constants.py +155 -0
- cryptnox_id_cli/applets/fido/ctap.py +430 -0
- cryptnox_id_cli/applets/fido/errors.py +90 -0
- cryptnox_id_cli/applets/fido/pinproto.py +166 -0
- cryptnox_id_cli/applets/genuine/__init__.py +12 -0
- cryptnox_id_cli/applets/genuine/constants.py +32 -0
- cryptnox_id_cli/applets/genuine/genuine.py +85 -0
- cryptnox_id_cli/applets/genuine/verify.py +150 -0
- cryptnox_id_cli/applets/mifare/__init__.py +10 -0
- cryptnox_id_cli/applets/mifare/desfire.py +353 -0
- cryptnox_id_cli/applets/mifare/ev2.py +421 -0
- cryptnox_id_cli/applets/piv/__init__.py +9 -0
- cryptnox_id_cli/applets/piv/admin.py +182 -0
- cryptnox_id_cli/applets/piv/apt.py +58 -0
- cryptnox_id_cli/applets/piv/constants.py +66 -0
- cryptnox_id_cli/applets/piv/keyimport.py +244 -0
- cryptnox_id_cli/applets/piv/objects.py +126 -0
- cryptnox_id_cli/applets/piv/perso.py +138 -0
- cryptnox_id_cli/applets/piv/piv.py +171 -0
- cryptnox_id_cli/applets/piv/preperso.py +174 -0
- cryptnox_id_cli/applets/piv/profiles.py +486 -0
- cryptnox_id_cli/applets/piv/slots.py +44 -0
- cryptnox_id_cli/cli/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/apdu.py +123 -0
- cryptnox_id_cli/cli/commands/doctor.py +144 -0
- cryptnox_id_cli/cli/commands/factory.py +312 -0
- cryptnox_id_cli/cli/commands/fido.py +729 -0
- cryptnox_id_cli/cli/commands/genuine.py +192 -0
- cryptnox_id_cli/cli/commands/info.py +94 -0
- cryptnox_id_cli/cli/commands/mifare.py +998 -0
- cryptnox_id_cli/cli/commands/piv.py +2558 -0
- cryptnox_id_cli/cli/commands/readers.py +64 -0
- cryptnox_id_cli/cli/commands/report.py +217 -0
- cryptnox_id_cli/cli/commands/shell.py +130 -0
- cryptnox_id_cli/cli/context.py +71 -0
- cryptnox_id_cli/cli/dryrun.py +181 -0
- cryptnox_id_cli/cli/main.py +117 -0
- cryptnox_id_cli/crypto/__init__.py +1 -0
- cryptnox_id_cli/crypto/attestation.py +169 -0
- cryptnox_id_cli/crypto/csr.py +135 -0
- cryptnox_id_cli/crypto/piv_objects.py +79 -0
- cryptnox_id_cli/crypto/x509util.py +37 -0
- cryptnox_id_cli/output/__init__.py +5 -0
- cryptnox_id_cli/output/render.py +106 -0
- cryptnox_id_cli/secrets/__init__.py +5 -0
- cryptnox_id_cli/secrets/redaction.py +141 -0
- cryptnox_id_cli/secrets/resolver.py +93 -0
- cryptnox_id_cli/state/__init__.py +19 -0
- cryptnox_id_cli/state/detector.py +273 -0
- cryptnox_id_cli/state/model.py +147 -0
- cryptnox_id_cli/transport/__init__.py +28 -0
- cryptnox_id_cli/transport/apdu.py +72 -0
- cryptnox_id_cli/transport/elevation.py +165 -0
- cryptnox_id_cli/transport/errors.py +185 -0
- cryptnox_id_cli/transport/pcsc.py +351 -0
- cryptnox_id_cli/transport/scp02.py +247 -0
- cryptnox_id_cli/transport/scp03.py +213 -0
- cryptnox_id_cli/trust/__init__.py +90 -0
- cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
- cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
- cryptnox_id_cli/util/__init__.py +1 -0
- cryptnox_id_cli/util/hexutil.py +36 -0
- cryptnox_id_cli/util/tlv.py +144 -0
- cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
- cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
- cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
- cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
- cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
- 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,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
|