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.
Files changed (20) hide show
  1. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/PKG-INFO +14 -6
  2. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/README.md +13 -5
  3. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/PKG-INFO +14 -6
  4. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/dncli.py +146 -8
  5. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/pyproject.toml +1 -1
  6. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_cert.py +151 -15
  7. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/LICENSE +0 -0
  8. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/setup.cfg +0 -0
  14. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_cli.py +0 -0
  15. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.5.0 → devnomads_cli-0.5.1}/tests/test_hook.py +0 -0
  20. {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.0
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 (key,
145
- then certificate, then intermediate, in that order).
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 single PEM
154
- bundle without re-issuing:
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 # omit --out to print
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 (key,
129
- then certificate, then intermediate, in that order).
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 single PEM
138
- bundle without re-issuing:
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 # omit --out to print
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.0
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 (key,
145
- then certificate, then intermediate, in that order).
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 single PEM
154
- bundle without re-issuing:
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 # omit --out to print
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 _write_private(path: Path, content: str) -> None:
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, "w") as fh:
115
- fh.write(content)
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, then intermediate(s)."""
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
- _assert_dns_authority(state, [domain, *sans])
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 PEM (key + cert + intermediate)."""
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}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
- CHAIN = "-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----\n"
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("CHAINPEM\n")
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
- # ...and the combined file has key, then leaf, then intermediate, in order.
200
- assert out.read_text() == KEY.decode() + LEAF + CHAIN
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 test_export_combined_pem_order_and_mode(tmp_path):
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(out, privkey="KEY\n", leaf="LEAF\n", chain="CHAIN\n")
216
- assert out.read_text() == "KEY\nLEAF\nCHAIN\n"
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
- text.index("EC PRIVATE KEY")
395
- < text.index("CERTIFICATE")
396
- < text.index("CHAINPEM")
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 "CHAINPEM" in result.stdout
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