devnomads-cli 0.5.0__tar.gz → 0.5.1__tar.gz
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.
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/PKG-INFO +14 -6
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/README.md +13 -5
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/PKG-INFO +14 -6
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/dncli.py +146 -8
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/pyproject.toml +1 -1
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_cert.py +151 -15
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/LICENSE +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/setup.cfg +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_cli.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_config.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_generate.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_hook.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_transfer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -141,8 +141,10 @@ DNS-01 challenge records would not be visible to the CA.
|
|
|
141
141
|
|
|
142
142
|
The certificate is always written to `~/.config/dncli/certs/<domain>/`
|
|
143
143
|
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
|
|
144
|
-
Pass `--out <file>` to additionally export a single PEM bundle
|
|
145
|
-
|
|
144
|
+
Pass `--out <file>` to additionally export a single PEM bundle of
|
|
145
|
+
exactly three blocks - the key, the certificate, and the issuing
|
|
146
|
+
intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
|
|
147
|
+
progress.
|
|
146
148
|
|
|
147
149
|
Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
148
150
|
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
|
|
@@ -150,14 +152,20 @@ Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
|
150
152
|
Re-running `cert issue` is a no-op while the existing certificate is
|
|
151
153
|
still valid for more than 21 days; pass `--force` to re-issue anyway.
|
|
152
154
|
|
|
153
|
-
List what you have issued and re-export any of them as a
|
|
154
|
-
|
|
155
|
+
List what you have issued and re-export any of them - as a PEM bundle
|
|
156
|
+
or a PKCS#12 (`.pfx`) file - without re-issuing:
|
|
155
157
|
|
|
156
158
|
```sh
|
|
157
159
|
dncli cert list
|
|
158
|
-
dncli cert export example.com --out bundle.pem
|
|
160
|
+
dncli cert export example.com --out bundle.pem # omit --out to print
|
|
161
|
+
dncli cert export example.com --format pfx --out bundle.pfx
|
|
162
|
+
dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
|
|
159
163
|
```
|
|
160
164
|
|
|
165
|
+
The `.pfx` is unencrypted unless you pass `--passphrase` (alias
|
|
166
|
+
`--password`). Both bundle formats carry the same three items: key,
|
|
167
|
+
certificate, and the issuing intermediate.
|
|
168
|
+
|
|
161
169
|
A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
|
|
162
170
|
|
|
163
171
|
```sh
|
|
@@ -125,8 +125,10 @@ DNS-01 challenge records would not be visible to the CA.
|
|
|
125
125
|
|
|
126
126
|
The certificate is always written to `~/.config/dncli/certs/<domain>/`
|
|
127
127
|
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
|
|
128
|
-
Pass `--out <file>` to additionally export a single PEM bundle
|
|
129
|
-
|
|
128
|
+
Pass `--out <file>` to additionally export a single PEM bundle of
|
|
129
|
+
exactly three blocks - the key, the certificate, and the issuing
|
|
130
|
+
intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
|
|
131
|
+
progress.
|
|
130
132
|
|
|
131
133
|
Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
132
134
|
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
|
|
@@ -134,14 +136,20 @@ Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
|
134
136
|
Re-running `cert issue` is a no-op while the existing certificate is
|
|
135
137
|
still valid for more than 21 days; pass `--force` to re-issue anyway.
|
|
136
138
|
|
|
137
|
-
List what you have issued and re-export any of them as a
|
|
138
|
-
|
|
139
|
+
List what you have issued and re-export any of them - as a PEM bundle
|
|
140
|
+
or a PKCS#12 (`.pfx`) file - without re-issuing:
|
|
139
141
|
|
|
140
142
|
```sh
|
|
141
143
|
dncli cert list
|
|
142
|
-
dncli cert export example.com --out bundle.pem
|
|
144
|
+
dncli cert export example.com --out bundle.pem # omit --out to print
|
|
145
|
+
dncli cert export example.com --format pfx --out bundle.pfx
|
|
146
|
+
dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
|
|
143
147
|
```
|
|
144
148
|
|
|
149
|
+
The `.pfx` is unencrypted unless you pass `--passphrase` (alias
|
|
150
|
+
`--password`). Both bundle formats carry the same three items: key,
|
|
151
|
+
certificate, and the issuing intermediate.
|
|
152
|
+
|
|
145
153
|
A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
|
|
146
154
|
|
|
147
155
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -141,8 +141,10 @@ DNS-01 challenge records would not be visible to the CA.
|
|
|
141
141
|
|
|
142
142
|
The certificate is always written to `~/.config/dncli/certs/<domain>/`
|
|
143
143
|
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
|
|
144
|
-
Pass `--out <file>` to additionally export a single PEM bundle
|
|
145
|
-
|
|
144
|
+
Pass `--out <file>` to additionally export a single PEM bundle of
|
|
145
|
+
exactly three blocks - the key, the certificate, and the issuing
|
|
146
|
+
intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
|
|
147
|
+
progress.
|
|
146
148
|
|
|
147
149
|
Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
148
150
|
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
|
|
@@ -150,14 +152,20 @@ Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
|
150
152
|
Re-running `cert issue` is a no-op while the existing certificate is
|
|
151
153
|
still valid for more than 21 days; pass `--force` to re-issue anyway.
|
|
152
154
|
|
|
153
|
-
List what you have issued and re-export any of them as a
|
|
154
|
-
|
|
155
|
+
List what you have issued and re-export any of them - as a PEM bundle
|
|
156
|
+
or a PKCS#12 (`.pfx`) file - without re-issuing:
|
|
155
157
|
|
|
156
158
|
```sh
|
|
157
159
|
dncli cert list
|
|
158
|
-
dncli cert export example.com --out bundle.pem
|
|
160
|
+
dncli cert export example.com --out bundle.pem # omit --out to print
|
|
161
|
+
dncli cert export example.com --format pfx --out bundle.pfx
|
|
162
|
+
dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
|
|
159
163
|
```
|
|
160
164
|
|
|
165
|
+
The `.pfx` is unencrypted unless you pass `--passphrase` (alias
|
|
166
|
+
`--password`). Both bundle formats carry the same three items: key,
|
|
167
|
+
certificate, and the issuing intermediate.
|
|
168
|
+
|
|
161
169
|
A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
|
|
162
170
|
|
|
163
171
|
```sh
|
|
@@ -24,6 +24,7 @@ import hashlib
|
|
|
24
24
|
import hmac
|
|
25
25
|
import io
|
|
26
26
|
import json
|
|
27
|
+
import logging
|
|
27
28
|
import os
|
|
28
29
|
import secrets
|
|
29
30
|
import stat
|
|
@@ -107,15 +108,19 @@ def load_credentials() -> configparser.ConfigParser:
|
|
|
107
108
|
return parser
|
|
108
109
|
|
|
109
110
|
|
|
110
|
-
def
|
|
111
|
+
def _write_private_bytes(path: Path, data: bytes) -> None:
|
|
111
112
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
113
|
path.parent.chmod(0o700)
|
|
113
114
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
114
|
-
with os.fdopen(fd, "
|
|
115
|
-
fh.write(
|
|
115
|
+
with os.fdopen(fd, "wb") as fh:
|
|
116
|
+
fh.write(data)
|
|
116
117
|
path.chmod(0o600)
|
|
117
118
|
|
|
118
119
|
|
|
120
|
+
def _write_private(path: Path, content: str) -> None:
|
|
121
|
+
_write_private_bytes(path, content.encode())
|
|
122
|
+
|
|
123
|
+
|
|
119
124
|
def save_credentials(parser: configparser.ConfigParser) -> None:
|
|
120
125
|
buffer = io.StringIO()
|
|
121
126
|
parser.write(buffer)
|
|
@@ -1374,6 +1379,26 @@ REQUIRED_NAMESERVERS = (
|
|
|
1374
1379
|
)
|
|
1375
1380
|
|
|
1376
1381
|
|
|
1382
|
+
class _ProgressHandler(logging.Handler):
|
|
1383
|
+
"""Route the devnomads library's log records to stderr in the CLI's
|
|
1384
|
+
muted style, so cert issuance shows what it is doing step by step."""
|
|
1385
|
+
|
|
1386
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
1387
|
+
try:
|
|
1388
|
+
err_console.print(f"[dim]{escape(record.getMessage())}[/]", soft_wrap=True)
|
|
1389
|
+
except Exception: # pragma: no cover - logging must never crash the CLI
|
|
1390
|
+
self.handleError(record)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def _enable_progress_logging(verbose: bool = False) -> None:
|
|
1394
|
+
"""Surface the library's progress on stderr (INFO, or DEBUG when verbose)."""
|
|
1395
|
+
|
|
1396
|
+
logger = logging.getLogger("devnomads")
|
|
1397
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
1398
|
+
if not any(isinstance(h, _ProgressHandler) for h in logger.handlers):
|
|
1399
|
+
logger.addHandler(_ProgressHandler())
|
|
1400
|
+
|
|
1401
|
+
|
|
1377
1402
|
class KeyType(str, Enum):
|
|
1378
1403
|
"""Certificate key types. ECDSA is the default; RSA on request."""
|
|
1379
1404
|
|
|
@@ -1384,6 +1409,13 @@ class KeyType(str, Enum):
|
|
|
1384
1409
|
rsa4096 = "rsa4096"
|
|
1385
1410
|
|
|
1386
1411
|
|
|
1412
|
+
class ExportFormat(str, Enum):
|
|
1413
|
+
"""Bundle formats for `cert export`."""
|
|
1414
|
+
|
|
1415
|
+
pem = "pem"
|
|
1416
|
+
pfx = "pfx"
|
|
1417
|
+
|
|
1418
|
+
|
|
1387
1419
|
def _load_acme() -> Any:
|
|
1388
1420
|
"""Import devnomads.acme lazily, so non-cert commands skip the ACME
|
|
1389
1421
|
stack at startup."""
|
|
@@ -1477,10 +1509,26 @@ def _write_cert_files(
|
|
|
1477
1509
|
(out_dir / name).write_text(content)
|
|
1478
1510
|
|
|
1479
1511
|
|
|
1512
|
+
def _first_cert(pem: str) -> str:
|
|
1513
|
+
"""The first PEM certificate block in ``pem`` (the issuing intermediate
|
|
1514
|
+
of a chain), or "" if there is none."""
|
|
1515
|
+
|
|
1516
|
+
begin = "-----BEGIN CERTIFICATE-----"
|
|
1517
|
+
end = "-----END CERTIFICATE-----"
|
|
1518
|
+
start = pem.find(begin)
|
|
1519
|
+
if start == -1:
|
|
1520
|
+
return ""
|
|
1521
|
+
stop = pem.find(end, start)
|
|
1522
|
+
if stop == -1:
|
|
1523
|
+
return ""
|
|
1524
|
+
return pem[start : stop + len(end)] + "\n"
|
|
1525
|
+
|
|
1526
|
+
|
|
1480
1527
|
def _combined_pem(privkey: str, leaf: str, chain: str) -> str:
|
|
1481
|
-
"""One PEM bundle: key, then certificate,
|
|
1528
|
+
"""One PEM bundle of exactly three blocks: key, then leaf certificate,
|
|
1529
|
+
then the single issuing intermediate (the rest of the chain is dropped)."""
|
|
1482
1530
|
|
|
1483
|
-
parts = [privkey, leaf, chain]
|
|
1531
|
+
parts = [privkey, leaf, _first_cert(chain)]
|
|
1484
1532
|
return "".join(p if p.endswith("\n") else p + "\n" for p in parts if p)
|
|
1485
1533
|
|
|
1486
1534
|
|
|
@@ -1490,6 +1538,47 @@ def _export_combined_pem(path: Path, *, privkey: str, leaf: str, chain: str) ->
|
|
|
1490
1538
|
_write_private(path, _combined_pem(privkey, leaf, chain))
|
|
1491
1539
|
|
|
1492
1540
|
|
|
1541
|
+
def _export_pkcs12(
|
|
1542
|
+
path: Path,
|
|
1543
|
+
*,
|
|
1544
|
+
privkey: str,
|
|
1545
|
+
leaf: str,
|
|
1546
|
+
chain: str,
|
|
1547
|
+
name: str,
|
|
1548
|
+
passphrase: str | None = None,
|
|
1549
|
+
) -> None:
|
|
1550
|
+
"""Write a PKCS#12 (.pfx) bundle of key + leaf + single intermediate at
|
|
1551
|
+
0600. Unencrypted unless ``passphrase`` is given."""
|
|
1552
|
+
|
|
1553
|
+
from cryptography import x509
|
|
1554
|
+
from cryptography.hazmat.primitives import serialization
|
|
1555
|
+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
1556
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
1557
|
+
|
|
1558
|
+
try:
|
|
1559
|
+
key = serialization.load_pem_private_key(privkey.encode(), password=None)
|
|
1560
|
+
cert = x509.load_pem_x509_certificate(leaf.encode())
|
|
1561
|
+
intermediate = _first_cert(chain)
|
|
1562
|
+
cas = (
|
|
1563
|
+
[x509.load_pem_x509_certificate(intermediate.encode())]
|
|
1564
|
+
if intermediate
|
|
1565
|
+
else None
|
|
1566
|
+
)
|
|
1567
|
+
except ValueError as exc:
|
|
1568
|
+
raise CliError(f"could not read stored certificate files: {exc}") from exc
|
|
1569
|
+
if not isinstance(key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
|
|
1570
|
+
raise CliError("unsupported private key type for pfx export")
|
|
1571
|
+
encryption = (
|
|
1572
|
+
serialization.BestAvailableEncryption(passphrase.encode())
|
|
1573
|
+
if passphrase
|
|
1574
|
+
else serialization.NoEncryption()
|
|
1575
|
+
)
|
|
1576
|
+
blob = pkcs12.serialize_key_and_certificates(
|
|
1577
|
+
name.encode(), key, cert, cas, encryption
|
|
1578
|
+
)
|
|
1579
|
+
_write_private_bytes(path, blob)
|
|
1580
|
+
|
|
1581
|
+
|
|
1493
1582
|
def _cert_info(cert_path: Path) -> dict[str, Any]:
|
|
1494
1583
|
"""Summarize a stored leaf certificate for `cert list`."""
|
|
1495
1584
|
|
|
@@ -1535,19 +1624,26 @@ def _issue_certificate(
|
|
|
1535
1624
|
staging: bool,
|
|
1536
1625
|
out_dir: Path,
|
|
1537
1626
|
export_path: Path | None = None,
|
|
1627
|
+
verbose: bool = False,
|
|
1538
1628
|
) -> None:
|
|
1539
1629
|
"""Obtain a DNS-01 certificate for ``domain`` and write it to ``out_dir``;
|
|
1540
1630
|
optionally also export a single combined PEM to ``export_path``."""
|
|
1541
1631
|
|
|
1542
1632
|
acme = _load_acme()
|
|
1543
|
-
|
|
1633
|
+
_enable_progress_logging(verbose)
|
|
1634
|
+
|
|
1635
|
+
names = [domain, *sans]
|
|
1636
|
+
err_console.print(f"checking {len(names)} name(s) are delegated to DevNomads")
|
|
1637
|
+
_assert_dns_authority(state, names)
|
|
1544
1638
|
|
|
1545
1639
|
directory_url = LE_STAGING_DIRECTORY if staging else acme.DEFAULT_DIRECTORY_URL
|
|
1640
|
+
err_console.print(f"using ACME directory {directory_url}")
|
|
1546
1641
|
client = acme.AcmeClient(
|
|
1547
1642
|
str(_account_key_path()),
|
|
1548
1643
|
directory_url=directory_url,
|
|
1549
1644
|
contact_email=email,
|
|
1550
1645
|
)
|
|
1646
|
+
err_console.print(f"generating {key_type} certificate key")
|
|
1551
1647
|
try:
|
|
1552
1648
|
domain_key = acme.generate_key(key_type)
|
|
1553
1649
|
except acme.AcmeError as exc:
|
|
@@ -1624,6 +1720,10 @@ def cert_issue(
|
|
|
1624
1720
|
"intermediate) to this file.",
|
|
1625
1721
|
),
|
|
1626
1722
|
] = None,
|
|
1723
|
+
verbose: Annotated[
|
|
1724
|
+
bool,
|
|
1725
|
+
typer.Option("--verbose", "-v", help="Show detailed ACME progress."),
|
|
1726
|
+
] = False,
|
|
1627
1727
|
) -> None:
|
|
1628
1728
|
"""Issue a TLS certificate for a domain via ACME (Let's Encrypt, DNS-01)."""
|
|
1629
1729
|
|
|
@@ -1649,6 +1749,7 @@ def cert_issue(
|
|
|
1649
1749
|
staging=staging,
|
|
1650
1750
|
out_dir=out_dir,
|
|
1651
1751
|
export_path=out,
|
|
1752
|
+
verbose=verbose,
|
|
1652
1753
|
)
|
|
1653
1754
|
|
|
1654
1755
|
|
|
@@ -1692,6 +1793,10 @@ def cert_renew(
|
|
|
1692
1793
|
staging: Annotated[
|
|
1693
1794
|
bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
|
|
1694
1795
|
] = False,
|
|
1796
|
+
verbose: Annotated[
|
|
1797
|
+
bool,
|
|
1798
|
+
typer.Option("--verbose", "-v", help="Show detailed ACME progress."),
|
|
1799
|
+
] = False,
|
|
1695
1800
|
) -> None:
|
|
1696
1801
|
"""Re-issue certificates that expire within 30 days; skip the rest."""
|
|
1697
1802
|
|
|
@@ -1720,6 +1825,7 @@ def cert_renew(
|
|
|
1720
1825
|
key_type=key_type.value,
|
|
1721
1826
|
staging=staging,
|
|
1722
1827
|
out_dir=out_dir,
|
|
1828
|
+
verbose=verbose,
|
|
1723
1829
|
)
|
|
1724
1830
|
|
|
1725
1831
|
|
|
@@ -1762,11 +1868,26 @@ def cert_export(
|
|
|
1762
1868
|
Path | None,
|
|
1763
1869
|
typer.Option(
|
|
1764
1870
|
"--out",
|
|
1765
|
-
help="Write the bundle here (0600); omit to print to stdout.",
|
|
1871
|
+
help="Write the bundle here (0600); omit to print to stdout (pem only).",
|
|
1872
|
+
),
|
|
1873
|
+
] = None,
|
|
1874
|
+
fmt: Annotated[
|
|
1875
|
+
ExportFormat,
|
|
1876
|
+
typer.Option("--format", help="Bundle format: pem or pfx (PKCS#12)."),
|
|
1877
|
+
] = ExportFormat.pem,
|
|
1878
|
+
passphrase: Annotated[
|
|
1879
|
+
str | None,
|
|
1880
|
+
typer.Option(
|
|
1881
|
+
"--passphrase",
|
|
1882
|
+
"--password",
|
|
1883
|
+
help="Encrypt the pfx with this passphrase (default: none).",
|
|
1766
1884
|
),
|
|
1767
1885
|
] = None,
|
|
1768
1886
|
) -> None:
|
|
1769
|
-
"""Re-export an issued certificate as one
|
|
1887
|
+
"""Re-export an issued certificate as one bundle (key + cert + intermediate).
|
|
1888
|
+
|
|
1889
|
+
The default PEM bundle is three blocks; --format pfx writes a PKCS#12 file.
|
|
1890
|
+
"""
|
|
1770
1891
|
|
|
1771
1892
|
cert_dir = _cert_dir(domain)
|
|
1772
1893
|
privkey = cert_dir / "privkey.pem"
|
|
@@ -1780,6 +1901,23 @@ def cert_export(
|
|
|
1780
1901
|
key_text = privkey.read_text()
|
|
1781
1902
|
leaf_text = leaf.read_text()
|
|
1782
1903
|
chain_text = chain.read_text() if chain.is_file() else ""
|
|
1904
|
+
|
|
1905
|
+
if fmt is ExportFormat.pfx:
|
|
1906
|
+
if out is None:
|
|
1907
|
+
raise CliError("--out is required for pfx (it is a binary file)")
|
|
1908
|
+
_export_pkcs12(
|
|
1909
|
+
out,
|
|
1910
|
+
privkey=key_text,
|
|
1911
|
+
leaf=leaf_text,
|
|
1912
|
+
chain=chain_text,
|
|
1913
|
+
name=domain,
|
|
1914
|
+
passphrase=passphrase,
|
|
1915
|
+
)
|
|
1916
|
+
err_console.print(f"exported pkcs12 bundle to {out}")
|
|
1917
|
+
return
|
|
1918
|
+
|
|
1919
|
+
if passphrase is not None:
|
|
1920
|
+
raise CliError("--passphrase only applies to --format pfx")
|
|
1783
1921
|
if out is not None:
|
|
1784
1922
|
_export_combined_pem(out, privkey=key_text, leaf=leaf_text, chain=chain_text)
|
|
1785
1923
|
err_console.print(f"exported combined pem to {out}")
|
|
@@ -11,7 +11,10 @@ from dncli import app
|
|
|
11
11
|
runner = CliRunner()
|
|
12
12
|
|
|
13
13
|
LEAF = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n"
|
|
14
|
-
|
|
14
|
+
INTERMEDIATE = "-----BEGIN CERTIFICATE-----\nINTER\n-----END CERTIFICATE-----\n"
|
|
15
|
+
ROOT = "-----BEGIN CERTIFICATE-----\nROOT\n-----END CERTIFICATE-----\n"
|
|
16
|
+
# Let's Encrypt returns a multi-cert chain (intermediate + cross-signs/roots).
|
|
17
|
+
CHAIN = INTERMEDIATE + ROOT
|
|
15
18
|
FULLCHAIN = LEAF + CHAIN
|
|
16
19
|
KEY = b"-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n"
|
|
17
20
|
|
|
@@ -82,7 +85,7 @@ def _write_cert(cert_dir, *, days, with_key_chain=False):
|
|
|
82
85
|
serialization.NoEncryption(),
|
|
83
86
|
)
|
|
84
87
|
)
|
|
85
|
-
(cert_dir / "chain.pem").write_text(
|
|
88
|
+
(cert_dir / "chain.pem").write_text(INTERMEDIATE + ROOT)
|
|
86
89
|
return cert
|
|
87
90
|
|
|
88
91
|
|
|
@@ -193,11 +196,15 @@ def test_issue_out_exports_combined_pem(
|
|
|
193
196
|
out = tmp_path / "bundle.pem"
|
|
194
197
|
result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
|
|
195
198
|
assert result.exit_code == 0, result.output
|
|
196
|
-
# the default per-domain dir is still written...
|
|
199
|
+
# the default per-domain dir is still written (with the full chain)...
|
|
197
200
|
default_dir = dncli.config_dir() / "certs" / "example.com"
|
|
198
201
|
assert (default_dir / "fullchain.pem").read_text() == FULLCHAIN
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
assert (default_dir / "chain.pem").read_text() == CHAIN
|
|
203
|
+
# ...and the combined file is exactly key + leaf + first intermediate,
|
|
204
|
+
# i.e. the rest of the chain (roots) is dropped.
|
|
205
|
+
assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE
|
|
206
|
+
assert ROOT not in out.read_text()
|
|
207
|
+
assert out.read_text().count("BEGIN CERTIFICATE") == 2
|
|
201
208
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
202
209
|
|
|
203
210
|
|
|
@@ -210,10 +217,23 @@ def test_our_zone_for_matches_longest_suffix():
|
|
|
210
217
|
assert dncli._our_zone_for("other.org", zones) is None
|
|
211
218
|
|
|
212
219
|
|
|
213
|
-
def
|
|
220
|
+
def test_first_cert_takes_only_the_intermediate():
|
|
221
|
+
assert dncli._first_cert(INTERMEDIATE + ROOT) == INTERMEDIATE
|
|
222
|
+
assert dncli._first_cert("no cert here") == ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_combined_pem_is_three_blocks(tmp_path):
|
|
226
|
+
bundle = dncli._combined_pem(KEY.decode(), LEAF, INTERMEDIATE + ROOT)
|
|
227
|
+
assert bundle == KEY.decode() + LEAF + INTERMEDIATE
|
|
228
|
+
assert bundle.count("BEGIN CERTIFICATE") == 2 # leaf + one intermediate
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_export_combined_pem_mode(tmp_path):
|
|
214
232
|
out = tmp_path / "bundle.pem"
|
|
215
|
-
dncli._export_combined_pem(
|
|
216
|
-
|
|
233
|
+
dncli._export_combined_pem(
|
|
234
|
+
out, privkey=KEY.decode(), leaf=LEAF, chain=INTERMEDIATE + ROOT
|
|
235
|
+
)
|
|
236
|
+
assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE
|
|
217
237
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
218
238
|
|
|
219
239
|
|
|
@@ -389,12 +409,11 @@ def test_cert_export_to_file(configured, isolated_config, tmp_path):
|
|
|
389
409
|
result = runner.invoke(app, ["cert", "export", "example.com", "--out", str(out)])
|
|
390
410
|
assert result.exit_code == 0, result.output
|
|
391
411
|
text = out.read_text()
|
|
392
|
-
# order: private key, then leaf certificate, then intermediate
|
|
393
|
-
assert (
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
)
|
|
412
|
+
# order: private key, then leaf certificate, then the one intermediate
|
|
413
|
+
assert text.index("EC PRIVATE KEY") < text.index("INTER")
|
|
414
|
+
# only the first intermediate (not the root), and exactly three blocks
|
|
415
|
+
assert "ROOT" not in text
|
|
416
|
+
assert text.count("BEGIN CERTIFICATE") == 2
|
|
398
417
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
399
418
|
|
|
400
419
|
|
|
@@ -405,10 +424,127 @@ def test_cert_export_to_stdout(configured, isolated_config):
|
|
|
405
424
|
result = runner.invoke(app, ["cert", "export", "example.com"])
|
|
406
425
|
assert result.exit_code == 0, result.output
|
|
407
426
|
assert "BEGIN CERTIFICATE" in result.stdout
|
|
408
|
-
assert "
|
|
427
|
+
assert "INTER" in result.stdout
|
|
428
|
+
assert "ROOT" not in result.stdout
|
|
409
429
|
|
|
410
430
|
|
|
411
431
|
def test_cert_export_missing(isolated_config):
|
|
412
432
|
result = runner.invoke(app, ["cert", "export", "nope.com"])
|
|
413
433
|
assert result.exit_code != 0
|
|
414
434
|
assert "no certificate" in result.output
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_cert_export_passphrase_rejected_for_pem(configured, isolated_config, tmp_path):
|
|
438
|
+
_write_cert(
|
|
439
|
+
dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
|
|
440
|
+
)
|
|
441
|
+
result = runner.invoke(
|
|
442
|
+
app,
|
|
443
|
+
[
|
|
444
|
+
"cert",
|
|
445
|
+
"export",
|
|
446
|
+
"example.com",
|
|
447
|
+
"--passphrase",
|
|
448
|
+
"x",
|
|
449
|
+
"--out",
|
|
450
|
+
str(tmp_path / "b.pem"),
|
|
451
|
+
],
|
|
452
|
+
)
|
|
453
|
+
assert result.exit_code != 0
|
|
454
|
+
assert "pfx" in result.output
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _write_real_chain(cert_dir):
|
|
458
|
+
"""Write a real key + leaf + (intermediate, root) chain so PKCS#12 export,
|
|
459
|
+
which actually parses the certificates, has valid input."""
|
|
460
|
+
|
|
461
|
+
import datetime
|
|
462
|
+
|
|
463
|
+
from cryptography import x509
|
|
464
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
465
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
466
|
+
from cryptography.x509.oid import NameOID
|
|
467
|
+
|
|
468
|
+
def make(cn):
|
|
469
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
470
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
471
|
+
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
|
|
472
|
+
cert = (
|
|
473
|
+
x509.CertificateBuilder()
|
|
474
|
+
.subject_name(name)
|
|
475
|
+
.issuer_name(name)
|
|
476
|
+
.public_key(key.public_key())
|
|
477
|
+
.serial_number(x509.random_serial_number())
|
|
478
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
479
|
+
.not_valid_after(now + datetime.timedelta(days=30))
|
|
480
|
+
.sign(key, hashes.SHA256())
|
|
481
|
+
)
|
|
482
|
+
return key, cert
|
|
483
|
+
|
|
484
|
+
leaf_key, leaf = make("example.com")
|
|
485
|
+
_, inter = make("Intermediate CA")
|
|
486
|
+
_, root = make("Root CA")
|
|
487
|
+
pem = serialization.Encoding.PEM
|
|
488
|
+
cert_dir.mkdir(parents=True, exist_ok=True)
|
|
489
|
+
(cert_dir / "cert.pem").write_bytes(leaf.public_bytes(pem))
|
|
490
|
+
(cert_dir / "privkey.pem").write_bytes(
|
|
491
|
+
leaf_key.private_bytes(
|
|
492
|
+
pem,
|
|
493
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
494
|
+
serialization.NoEncryption(),
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
(cert_dir / "chain.pem").write_bytes(
|
|
498
|
+
inter.public_bytes(pem) + root.public_bytes(pem)
|
|
499
|
+
)
|
|
500
|
+
return leaf, inter, root
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_cert_export_pfx_no_passphrase(configured, isolated_config, tmp_path):
|
|
504
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
505
|
+
|
|
506
|
+
leaf, inter, root = _write_real_chain(dncli.config_dir() / "certs" / "example.com")
|
|
507
|
+
out = tmp_path / "bundle.pfx"
|
|
508
|
+
result = runner.invoke(
|
|
509
|
+
app, ["cert", "export", "example.com", "--format", "pfx", "--out", str(out)]
|
|
510
|
+
)
|
|
511
|
+
assert result.exit_code == 0, result.output
|
|
512
|
+
key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), None)
|
|
513
|
+
assert key is not None
|
|
514
|
+
assert cert.subject == leaf.subject
|
|
515
|
+
# only the issuing intermediate, not the root
|
|
516
|
+
assert [c.subject for c in cas] == [inter.subject]
|
|
517
|
+
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def test_cert_export_pfx_with_passphrase(configured, isolated_config, tmp_path):
|
|
521
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
522
|
+
|
|
523
|
+
_write_real_chain(dncli.config_dir() / "certs" / "example.com")
|
|
524
|
+
out = tmp_path / "bundle.pfx"
|
|
525
|
+
result = runner.invoke(
|
|
526
|
+
app,
|
|
527
|
+
[
|
|
528
|
+
"cert",
|
|
529
|
+
"export",
|
|
530
|
+
"example.com",
|
|
531
|
+
"--format",
|
|
532
|
+
"pfx",
|
|
533
|
+
"--out",
|
|
534
|
+
str(out),
|
|
535
|
+
"--password",
|
|
536
|
+
"s3cret",
|
|
537
|
+
],
|
|
538
|
+
)
|
|
539
|
+
assert result.exit_code == 0, result.output
|
|
540
|
+
with pytest.raises(ValueError): # wrong/no password must fail
|
|
541
|
+
pkcs12.load_key_and_certificates(out.read_bytes(), None)
|
|
542
|
+
key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), b"s3cret")
|
|
543
|
+
assert key is not None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_cert_export_pfx_requires_out(configured, isolated_config):
|
|
547
|
+
_write_real_chain(dncli.config_dir() / "certs" / "example.com")
|
|
548
|
+
result = runner.invoke(app, ["cert", "export", "example.com", "--format", "pfx"])
|
|
549
|
+
assert result.exit_code != 0
|
|
550
|
+
assert "out" in result.output.lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|