Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72337bf0bb | |||
| 4b2d020c86 | |||
| b0aad96673 |
@@ -31,8 +31,8 @@ cmd/certmanager/main.go # CLI (flattened: the positional arg is the comman
|
|||||||
cmd/certmanager/main_test.go # end-to-end tests against an httptest Vault stub
|
cmd/certmanager/main_test.go # end-to-end tests against an httptest Vault stub
|
||||||
test/integration/ # end-to-end tests against a real Vault dev server
|
test/integration/ # end-to-end tests against a real Vault dev server
|
||||||
internal/config/ # YAML config: parsing, defaults, validation
|
internal/config/ # YAML config: parsing, defaults, validation
|
||||||
internal/pki/ # issue request/response + the JSON output shape
|
internal/pki/ # issue + ca_chain requests and the output shapes
|
||||||
internal/vault/ # authenticated HTTP client (approle, kubernetes)
|
internal/vault/ # HTTP client: approle, kubernetes, unauthenticated
|
||||||
Makefile # build / test / lint / completions / version bump
|
Makefile # build / test / lint / completions / version bump
|
||||||
.goreleaser.yaml # single release driver: binaries + RPM + Gitea release
|
.goreleaser.yaml # single release driver: binaries + RPM + Gitea release
|
||||||
scripts/integration-test.sh # runs the integration test against a Vault dev server
|
scripts/integration-test.sh # runs the integration test against a Vault dev server
|
||||||
@@ -46,7 +46,12 @@ dist/ # build output: binaries, RPM, checksums (not comm
|
|||||||
wires it to the real streams and `os.Exit`. Cobra runs with `SilenceUsage` and
|
wires it to the real streams and `os.Exit`. Cobra runs with `SilenceUsage` and
|
||||||
`SilenceErrors` so nothing reaches stderr unless the run failed.
|
`SilenceErrors` so nothing reaches stderr unless the run failed.
|
||||||
- **Flat CLI**: the root command takes `common_name` positionally
|
- **Flat CLI**: the root command takes `common_name` positionally
|
||||||
(`cobra.ExactArgs(1)`). Only `completion` and `help` are subcommands.
|
(`cobra.ExactArgs(1)`). `ca`, `completion` and `help` are the only
|
||||||
|
subcommands; `--config` is persistent so `ca` shares it.
|
||||||
|
- **`ca` is unauthenticated**: `GET {addr}/v1/{mount}/ca_chain` sends no
|
||||||
|
`X-Vault-Token` and loads the config through `config.LoadCA`, which requires
|
||||||
|
only `vault.addr`. The body is parsed as PEM, never sniffed — Vault labels it
|
||||||
|
`application/pkix-cert`. It prints raw PEM: Puppet uses it as file content.
|
||||||
- **`parseList`** splits comma lists, trims whitespace, drops empty tokens.
|
- **`parseList`** splits comma lists, trims whitespace, drops empty tokens.
|
||||||
- **`config.Load`** applies defaults (`auth_method: approle`, `jwt_path`) then
|
- **`config.Load`** applies defaults (`auth_method: approle`, `jwt_path`) then
|
||||||
`Validate()`, which names the offending key and rejects an unknown
|
`Validate()`, which names the offending key and rejects an unknown
|
||||||
@@ -59,8 +64,10 @@ dist/ # build output: binaries, RPM, checksums (not comm
|
|||||||
|
|
||||||
## Deliberate deviations from the Python script
|
## Deliberate deviations from the Python script
|
||||||
|
|
||||||
- Emits `ca_certificate`; the Python did not, so Puppet's
|
- The CA is served by `certmanager ca` from the mount's `/ca_chain`, not welded
|
||||||
`/etc/pki/tls/vault/ca_certificate.crt` was silently never populated.
|
to issuance. `issuing_ca` is the intermediate only, and deriving the CA from
|
||||||
|
an issue response would pin CA rotation to each node's 90 day renewal.
|
||||||
|
- `full_chain` is leaf first; the Python put the issuer first.
|
||||||
- `output_path` is read from under `vault:` where it is actually written. The
|
- `output_path` is read from under `vault:` where it is actually written. The
|
||||||
Python declared it at the top level but read it from the unwrapped `vault`
|
Python declared it at the top level but read it from the unwrapped `vault`
|
||||||
dict, so it never resolved.
|
dict, so it never resolved.
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ failure path exits non-zero.
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
certmanager <common_name> [flags]
|
certmanager <common_name> [flags] # issue a certificate
|
||||||
|
certmanager ca [flags] # print the CA chain
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Short | Type | Default | Description |
|
| Flag | Short | Type | Default | Description |
|
||||||
@@ -31,6 +32,12 @@ certmanager <common_name> [flags]
|
|||||||
| `--json` | | bool | `false` | Print the certificate data as JSON |
|
| `--json` | | bool | `false` | Print the certificate data as JSON |
|
||||||
| `--config` | | path | `/opt/certmanager/config.yaml` | Config file location |
|
| `--config` | | path | `/opt/certmanager/config.yaml` | Config file location |
|
||||||
|
|
||||||
|
`ca` takes `--config` plus:
|
||||||
|
|
||||||
|
| Flag | Short | Type | Default | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `--mount` | `-m` | string | `vault.mount_point` | PKI mount to read |
|
||||||
|
|
||||||
Comma lists are split on `,`, whitespace-trimmed, and empty tokens dropped.
|
Comma lists are split on `,`, whitespace-trimmed, and empty tokens dropped.
|
||||||
|
|
||||||
### JSON output
|
### JSON output
|
||||||
@@ -39,15 +46,15 @@ Comma lists are split on `,`, whitespace-trimmed, and empty tokens dropped.
|
|||||||
{
|
{
|
||||||
"certificate": "<PEM leaf>",
|
"certificate": "<PEM leaf>",
|
||||||
"private_key": "<PEM key>",
|
"private_key": "<PEM key>",
|
||||||
"full_chain": "<issuing_ca PEM>\n<certificate PEM>",
|
"full_chain": "<certificate PEM>\n<issuing_ca PEM>"
|
||||||
"ca_certificate": "<issuing_ca PEM>"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`ca_certificate` is new. `profiles::pki::vault` writes
|
`full_chain` is leaf first, the order every TLS server expects.
|
||||||
`$cert_data['ca_certificate']` to `/etc/pki/tls/vault/ca_certificate.crt`, but
|
|
||||||
the Python never emitted the key, so Puppet saw `undef` and the file was never
|
The CA is not part of this document. It is estate-wide and public, so binding
|
||||||
populated.
|
it to a node's issuance would pin CA distribution to that node's renewal cycle;
|
||||||
|
`certmanager ca` fetches it instead.
|
||||||
|
|
||||||
### File output
|
### File output
|
||||||
|
|
||||||
@@ -58,12 +65,40 @@ Without `--json` the certificate is written under `vault.output_path`
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `<output_path>/<common_name>/certificate.crt` | Signed certificate |
|
| `<output_path>/<common_name>/certificate.crt` | Signed certificate |
|
||||||
| `<output_path>/<common_name>/private.key` | Private key (mode 0600) |
|
| `<output_path>/<common_name>/private.key` | Private key (mode 0600) |
|
||||||
| `<output_path>/<common_name>/full_chain.crt` | Issuing CA + certificate |
|
| `<output_path>/<common_name>/full_chain.crt` | Certificate + issuing CA |
|
||||||
| `<output_path>/<common_name>/ca_certificate.crt` | Issuing CA certificate |
|
|
||||||
|
|
||||||
With `-c` the same four files are written into
|
With `-c` the same three files are written into
|
||||||
`<output_path>/<common_name>.zip`.
|
`<output_path>/<common_name>.zip`.
|
||||||
|
|
||||||
|
## The `ca` subcommand
|
||||||
|
|
||||||
|
```
|
||||||
|
certmanager ca [--mount <pki_mount>] [--config <path>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Prints the mount's CA chain to stdout as raw PEM — not JSON, because Puppet
|
||||||
|
uses it as file content directly:
|
||||||
|
|
||||||
|
```puppet
|
||||||
|
file { '/etc/pki/tls/vault/ca_certificate.crt':
|
||||||
|
content => generate('/usr/local/bin/certmanager', 'ca'),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It reads `GET {addr}/v1/{mount}/ca_chain` unauthenticated: no AppRole login, no
|
||||||
|
Kubernetes login, no token header. A config carrying only `addr` and
|
||||||
|
`mount_point` is enough, and `--mount` overrides the configured mount.
|
||||||
|
|
||||||
|
`/ca_chain` returns the intermediate **and** the root, unlike `issuing_ca` or
|
||||||
|
`/ca/pem`, which return the intermediate alone. An intermediate-only `CAfile`
|
||||||
|
is not self-sufficient: strict path builders that require a chain to a
|
||||||
|
self-signed root reject it.
|
||||||
|
|
||||||
|
The endpoint advertises `application/pkix-cert` while serving PEM, so the body
|
||||||
|
is parsed rather than sniffed. A non-200, an empty body or anything that is not
|
||||||
|
a PEM certificate chain exits non-zero with the cause on stderr and nothing on
|
||||||
|
stdout.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
YAML, everything nested under `vault:`. The path is baked in at build time
|
YAML, everything nested under `vault:`. The path is baked in at build time
|
||||||
@@ -74,11 +109,11 @@ YAML, everything nested under `vault:`. The path is baked in at build time
|
|||||||
| `addr` | yes | | e.g. `https://vault.service.consul:8200` |
|
| `addr` | yes | | e.g. `https://vault.service.consul:8200` |
|
||||||
| `auth_method` | no | `approle` | `approle` or `kubernetes` |
|
| `auth_method` | no | `approle` | `approle` or `kubernetes` |
|
||||||
| `mount_point` | yes | | e.g. `pki_int` |
|
| `mount_point` | yes | | e.g. `pki_int` |
|
||||||
| `role_name` | yes | | e.g. `servers_default` |
|
| `role_name` | iff issuing | | e.g. `servers_default` |
|
||||||
| `role_id` | iff `approle` | | |
|
| `role_id` | iff `approle` and issuing | | |
|
||||||
| `approle_path` | iff `approle` | | e.g. `approle` |
|
| `approle_path` | iff `approle` and issuing | | e.g. `approle` |
|
||||||
| `k8s_mount` | iff `kubernetes` | | e.g. `k8s/au/syd1` |
|
| `k8s_mount` | iff `kubernetes` and issuing | | e.g. `k8s/au/syd1` |
|
||||||
| `k8s_role` | iff `kubernetes` | | e.g. `puppet_certmanager` |
|
| `k8s_role` | iff `kubernetes` and issuing | | e.g. `puppet_certmanager` |
|
||||||
| `jwt_path` | no | `/var/run/secrets/kubernetes.io/serviceaccount/token` | |
|
| `jwt_path` | no | `/var/run/secrets/kubernetes.io/serviceaccount/token` | |
|
||||||
| `output_path` | no | `.` | Non-JSON output directory |
|
| `output_path` | no | `.` | Non-JSON output directory |
|
||||||
| `tls_skip_verify` | no | `true` | Set `false` once every node trusts the CA |
|
| `tls_skip_verify` | no | `true` | Set `false` once every node trusts the CA |
|
||||||
@@ -95,6 +130,8 @@ vault:
|
|||||||
output_path: /tmp/certmanager
|
output_path: /tmp/certmanager
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ca` requires only `addr` and a mount; every auth key above is optional for it.
|
||||||
|
|
||||||
AppRole logs in with `role_id` only — the Vault role sets
|
AppRole logs in with `role_id` only — the Vault role sets
|
||||||
`bind_secret_id: false`. Kubernetes reads the JWT from `jwt_path` and posts it
|
`bind_secret_id: false`. Kubernetes reads the JWT from `jwt_path` and posts it
|
||||||
with `k8s_role`. A missing, unreadable or empty JWT, a rejected login, or an
|
with `k8s_role`. A missing, unreadable or empty JWT, a rejected login, or an
|
||||||
@@ -115,7 +152,9 @@ make release-snapshot # goreleaser: binaries, tar.gz and RPM into dist/
|
|||||||
|
|
||||||
`make integration-test` starts a `hashicorp/vault` dev container with
|
`make integration-test` starts a `hashicorp/vault` dev container with
|
||||||
docker/podman, provisions a PKI mount and an approle role, then runs the built
|
docker/podman, provisions a PKI mount and an approle role, then runs the built
|
||||||
binary with the exact argv Puppet uses and asserts on the issued certificate.
|
binary with the exact argv Puppet uses and asserts on the issued certificate. A
|
||||||
|
root mount signs an intermediate so the CA chain under test is the two
|
||||||
|
certificate shape production serves.
|
||||||
Point it at an existing dev server with `CERTMANAGER_IT_VAULT_ADDR`
|
Point it at an existing dev server with `CERTMANAGER_IT_VAULT_ADDR`
|
||||||
(`VAULT_ADDR` is deliberately ignored — the test writes to the PKI mount).
|
(`VAULT_ADDR` is deliberately ignored — the test writes to the PKI mount).
|
||||||
|
|
||||||
|
|||||||
+52
-2
@@ -79,13 +79,64 @@ func rootCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml")
|
// Persistent so the ca subcommand shares it; the positional form keeps
|
||||||
|
// parsing --config exactly as before.
|
||||||
|
cmd.PersistentFlags().StringVar(&cfgFile, "config", config.DefaultPath(), "path to config.yaml")
|
||||||
cmd.Flags().StringVarP(&altNames, "alt-names", "a", "", "comma-separated DNS subject alternative names")
|
cmd.Flags().StringVarP(&altNames, "alt-names", "a", "", "comma-separated DNS subject alternative names")
|
||||||
cmd.Flags().StringVarP(&ipSans, "ip-sans", "i", "", "comma-separated IP subject alternative names")
|
cmd.Flags().StringVarP(&ipSans, "ip-sans", "i", "", "comma-separated IP subject alternative names")
|
||||||
cmd.Flags().IntVarP(&expiryDays, "expiry-days", "e", 365, "certificate validity in days")
|
cmd.Flags().IntVarP(&expiryDays, "expiry-days", "e", 365, "certificate validity in days")
|
||||||
cmd.Flags().BoolVarP(&compress, "compress", "c", false, "write the certificate, key and chain into a zip file")
|
cmd.Flags().BoolVarP(&compress, "compress", "c", false, "write the certificate, key and chain into a zip file")
|
||||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "print the certificate data as JSON instead of writing files")
|
cmd.Flags().BoolVar(&jsonOut, "json", false, "print the certificate data as JSON instead of writing files")
|
||||||
|
|
||||||
|
cmd.AddCommand(caCmd(&cfgFile))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// caCmd prints the mount's CA chain. Distributing the CA this way decouples it
|
||||||
|
// from certificate issuance, so a CA rotation reaches every node on the next
|
||||||
|
// catalog compile instead of waiting out a 90 day renewal cycle.
|
||||||
|
func caCmd(cfgFile *string) *cobra.Command {
|
||||||
|
var mount string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ca",
|
||||||
|
Short: "Print the PKI mount's CA chain (intermediate and root) as PEM",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := config.LoadCA(*cfgFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
mountPoint := cfg.Vault.MountPoint
|
||||||
|
if mount != "" {
|
||||||
|
mountPoint = mount
|
||||||
|
}
|
||||||
|
if mountPoint == "" {
|
||||||
|
return fmt.Errorf("vault.mount_point is required (or pass --mount)")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := vault.NewUnauthenticated(cfg.Vault)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chain is fully validated before a byte reaches stdout: a
|
||||||
|
// half-written document would break the catalog compile.
|
||||||
|
chain, err := pki.FetchCAChain(client, mountPoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.WriteString(cmd.OutOrStdout(), chain)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&mount, "mount", "m", "", "PKI mount to read (default: vault.mount_point)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +174,6 @@ func writeFiles(outputPath, commonName string, compress bool, cert *pki.Certific
|
|||||||
{"certificate.crt", cert.Certificate, 0o644},
|
{"certificate.crt", cert.Certificate, 0o644},
|
||||||
{"private.key", cert.PrivateKey, 0o600},
|
{"private.key", cert.PrivateKey, 0o600},
|
||||||
{"full_chain.crt", cert.FullChain, 0o644},
|
{"full_chain.crt", cert.FullChain, 0o644},
|
||||||
{"ca_certificate.crt", cert.CACertificate, 0o644},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if compress {
|
if compress {
|
||||||
|
|||||||
+333
-12
@@ -3,9 +3,16 @@ package main
|
|||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -27,6 +34,12 @@ type stubOptions struct {
|
|||||||
k8sStatus int
|
k8sStatus int
|
||||||
issueStatus int
|
issueStatus int
|
||||||
issueDelay time.Duration
|
issueDelay time.Duration
|
||||||
|
|
||||||
|
caStatus int
|
||||||
|
// caBody overrides the CA chain response; caBodySet distinguishes an
|
||||||
|
// intentional empty body from "unset".
|
||||||
|
caBody string
|
||||||
|
caBodySet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type capture struct {
|
type capture struct {
|
||||||
@@ -34,11 +47,50 @@ type capture struct {
|
|||||||
authBody map[string]any
|
authBody map[string]any
|
||||||
issueBody map[string]any
|
issueBody map[string]any
|
||||||
token string
|
token string
|
||||||
|
|
||||||
|
caPath string
|
||||||
|
caRequested bool
|
||||||
|
// caToken records the header verbatim; the CA endpoint must be called
|
||||||
|
// without one.
|
||||||
|
caToken string
|
||||||
|
caTokenSet bool
|
||||||
|
// chain is the PEM the stub serves from /ca_chain.
|
||||||
|
chain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// caChainPEM is a real two certificate PEM chain, since the ca path parses what
|
||||||
|
// Vault returns rather than trusting the content type.
|
||||||
|
func caChainPEM(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return selfSignedPEM(t, "test intermediate") + selfSignedPEM(t, "test root")
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfSignedPEM(t *testing.T, cn string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create certificate: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVaultStub(t *testing.T, opts stubOptions) (*httptest.Server, *capture) {
|
func newVaultStub(t *testing.T, opts stubOptions) (*httptest.Server, *capture) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
got := &capture{}
|
defaultChain := caChainPEM(t)
|
||||||
|
got := &capture{chain: defaultChain}
|
||||||
|
|
||||||
decode := func(r *http.Request) map[string]any {
|
decode := func(r *http.Request) map[string]any {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
@@ -83,6 +135,26 @@ func newVaultStub(t *testing.T, opts stubOptions) (*httptest.Server, *capture) {
|
|||||||
}}
|
}}
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/v1/pki_int/ca_chain", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got.caRequested = true
|
||||||
|
got.caPath = r.URL.Path
|
||||||
|
got.caToken = r.Header.Get("X-Vault-Token")
|
||||||
|
_, got.caTokenSet = r.Header["X-Vault-Token"]
|
||||||
|
|
||||||
|
if s := orOK(opts.caStatus); s != http.StatusOK {
|
||||||
|
w.WriteHeader(s)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":["unsupported path"]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := opts.caBody
|
||||||
|
if !opts.caBodySet {
|
||||||
|
body = defaultChain
|
||||||
|
}
|
||||||
|
// Vault advertises pkix-cert while serving PEM; the stub does too, so
|
||||||
|
// nothing downstream may sniff the content type.
|
||||||
|
w.Header().Set("Content-Type", "application/pkix-cert")
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
})
|
||||||
|
|
||||||
srv := httptest.NewTLSServer(mux)
|
srv := httptest.NewTLSServer(mux)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
@@ -140,8 +212,7 @@ func TestRunJSONGolden(t *testing.T) {
|
|||||||
|
|
||||||
want := `{"certificate":"-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----",` +
|
want := `{"certificate":"-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----",` +
|
||||||
`"private_key":"-----BEGIN RSA PRIVATE KEY-----\nKEY\n-----END RSA PRIVATE KEY-----",` +
|
`"private_key":"-----BEGIN RSA PRIVATE KEY-----\nKEY\n-----END RSA PRIVATE KEY-----",` +
|
||||||
`"full_chain":"-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----",` +
|
`"full_chain":"-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"}` + "\n"
|
||||||
`"ca_certificate":"-----BEGIN CERTIFICATE-----\nCA\n-----END CERTIFICATE-----"}` + "\n"
|
|
||||||
if stdout.String() != want {
|
if stdout.String() != want {
|
||||||
t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout.String(), want)
|
t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout.String(), want)
|
||||||
}
|
}
|
||||||
@@ -151,11 +222,18 @@ func TestRunJSONGolden(t *testing.T) {
|
|||||||
if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil {
|
||||||
t.Fatalf("stdout is not valid JSON: %v", err)
|
t.Fatalf("stdout is not valid JSON: %v", err)
|
||||||
}
|
}
|
||||||
if parsed["ca_certificate"] != testCA {
|
if _, ok := parsed["ca_certificate"]; ok {
|
||||||
t.Errorf("ca_certificate = %q, want issuing_ca", parsed["ca_certificate"])
|
t.Error("ca_certificate must not be emitted; the CA comes from the ca subcommand")
|
||||||
}
|
}
|
||||||
if parsed["full_chain"] != testCA+"\n"+testCert {
|
if len(parsed) != 3 {
|
||||||
t.Errorf("full_chain = %q", parsed["full_chain"])
|
t.Errorf("document has %d keys, want exactly 3: %v", len(parsed), parsed)
|
||||||
|
}
|
||||||
|
// Leaf first, issuer after -- asserted as an ordering, not a membership.
|
||||||
|
if parsed["full_chain"] != testCert+"\n"+testCA {
|
||||||
|
t.Errorf("full_chain = %q, want leaf then issuing CA", parsed["full_chain"])
|
||||||
|
}
|
||||||
|
if strings.Index(parsed["full_chain"], "LEAF") > strings.Index(parsed["full_chain"], "\nCA\n") {
|
||||||
|
t.Errorf("full_chain puts the CA ahead of the leaf: %q", parsed["full_chain"])
|
||||||
}
|
}
|
||||||
|
|
||||||
if got.token != "s.testtoken" {
|
if got.token != "s.testtoken" {
|
||||||
@@ -357,10 +435,12 @@ func TestRunWritesFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
"certificate.crt": testCert,
|
"certificate.crt": testCert,
|
||||||
"private.key": testKey,
|
"private.key": testKey,
|
||||||
"full_chain.crt": testCA + "\n" + testCert,
|
"full_chain.crt": testCert + "\n" + testCA,
|
||||||
"ca_certificate.crt": testCA,
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(out, "host.example.com", "ca_certificate.crt")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("ca_certificate.crt must no longer be written by the issue path (stat err = %v)", err)
|
||||||
}
|
}
|
||||||
for name, body := range want {
|
for name, body := range want {
|
||||||
got, err := os.ReadFile(filepath.Join(out, "host.example.com", name))
|
got, err := os.ReadFile(filepath.Join(out, "host.example.com", name))
|
||||||
@@ -401,7 +481,7 @@ func TestRunCompress(t *testing.T) {
|
|||||||
for _, f := range zr.File {
|
for _, f := range zr.File {
|
||||||
names = append(names, f.Name)
|
names = append(names, f.Name)
|
||||||
}
|
}
|
||||||
wantNames := []string{"certificate.crt", "private.key", "full_chain.crt", "ca_certificate.crt"}
|
wantNames := []string{"certificate.crt", "private.key", "full_chain.crt"}
|
||||||
if !reflect.DeepEqual(names, wantNames) {
|
if !reflect.DeepEqual(names, wantNames) {
|
||||||
t.Errorf("zip entries = %v, want %v", names, wantNames)
|
t.Errorf("zip entries = %v, want %v", names, wantNames)
|
||||||
}
|
}
|
||||||
@@ -448,3 +528,244 @@ func TestCompletionSubcommand(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authlessConfig carries only addr and mount_point: the CA chain endpoint is
|
||||||
|
// public, so the ca subcommand must not need auth keys to be present.
|
||||||
|
func authlessConfig(t *testing.T, addr string, extra ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
return writeConfig(t, "vault:\n"+
|
||||||
|
" addr: "+addr+"\n"+
|
||||||
|
" mount_point: pki_int\n"+
|
||||||
|
strings.Join(extra, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCAPrintsChain(t *testing.T) {
|
||||||
|
srv, got := newVaultStub(t, stubOptions{})
|
||||||
|
cfg := authlessConfig(t, srv.URL)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run([]string{"ca", "--config", cfg}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.caPath != "/v1/pki_int/ca_chain" {
|
||||||
|
t.Errorf("requested path = %q, want /v1/pki_int/ca_chain", got.caPath)
|
||||||
|
}
|
||||||
|
// Unauthenticated: the header must be absent, not merely empty.
|
||||||
|
if got.caTokenSet {
|
||||||
|
t.Errorf("X-Vault-Token must not be sent to the CA endpoint, got %q", got.caToken)
|
||||||
|
}
|
||||||
|
if got.issueBody != nil {
|
||||||
|
t.Error("the ca subcommand must not issue a certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
want := strings.TrimSpace(got.chain) + "\n"
|
||||||
|
if stdout.String() != want {
|
||||||
|
t.Errorf("stdout = %q, want the PEM chain %q", stdout.String(), want)
|
||||||
|
}
|
||||||
|
if n := strings.Count(stdout.String(), "-----BEGIN CERTIFICATE-----"); n != 2 {
|
||||||
|
t.Errorf("stdout carries %d certificates, want 2", n)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Errorf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "ca" is the subcommand, never a common name to issue for.
|
||||||
|
func TestRunCAIsNotTreatedAsACommonName(t *testing.T) {
|
||||||
|
srv, got := newVaultStub(t, stubOptions{})
|
||||||
|
cfg := approleConfig(t, srv.URL)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run([]string{"ca", "--config", cfg}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !got.caRequested {
|
||||||
|
t.Error("the CA chain endpoint was never called")
|
||||||
|
}
|
||||||
|
if got.issueBody != nil {
|
||||||
|
t.Errorf("issuance was attempted for common name %v", got.issueBody["common_name"])
|
||||||
|
}
|
||||||
|
if got.authPath != "" {
|
||||||
|
t.Errorf("the ca subcommand logged in at %q; it must not authenticate", got.authPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The positional issue form must keep parsing unchanged now that a subcommand
|
||||||
|
// sits alongside it.
|
||||||
|
func TestRunPositionalFormStillParses(t *testing.T) {
|
||||||
|
srv, got := newVaultStub(t, stubOptions{})
|
||||||
|
cfg := approleConfig(t, srv.URL)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(puppetArgs(cfg), &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if got.issueBody["common_name"] != "host.example.com" {
|
||||||
|
t.Errorf("common_name = %v", got.issueBody["common_name"])
|
||||||
|
}
|
||||||
|
if got.caRequested {
|
||||||
|
t.Error("the issue path must not call the CA chain endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCAMountFlagOverridesConfig(t *testing.T) {
|
||||||
|
srv, got := newVaultStub(t, stubOptions{})
|
||||||
|
cfg := writeConfig(t, "vault:\n addr: "+srv.URL+"\n")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run([]string{"ca", "--config", cfg, "--mount", "pki_int"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if got.caPath != "/v1/pki_int/ca_chain" {
|
||||||
|
t.Errorf("requested path = %q", got.caPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCASuccessWritesNothingToStderr(t *testing.T) {
|
||||||
|
srv, _ := newVaultStub(t, stubOptions{})
|
||||||
|
cfg := authlessConfig(t, srv.URL)
|
||||||
|
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pipe: %v", err)
|
||||||
|
}
|
||||||
|
orig := os.Stderr
|
||||||
|
os.Stderr = w
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
code := run([]string{"ca", "--config", cfg}, &stdout, os.Stderr)
|
||||||
|
|
||||||
|
os.Stderr = orig
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("close pipe: %v", err)
|
||||||
|
}
|
||||||
|
captured, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pipe: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d", code)
|
||||||
|
}
|
||||||
|
if len(captured) != 0 {
|
||||||
|
t.Errorf("stderr must be byte-empty on the ca success path, got %q", captured)
|
||||||
|
}
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
t.Error("stdout is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCAFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts stubOptions
|
||||||
|
config func(t *testing.T, addr string) string
|
||||||
|
args func(cfg string) []string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "vault rejects the request",
|
||||||
|
opts: stubOptions{caStatus: http.StatusForbidden},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: "vault returned 403",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount does not exist",
|
||||||
|
opts: stubOptions{caStatus: http.StatusNotFound},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: "vault returned 404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
opts: stubOptions{caBody: "", caBodySet: true},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: "empty CA chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only body",
|
||||||
|
opts: stubOptions{caBody: "\n \n", caBodySet: true},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: "empty CA chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body is not PEM",
|
||||||
|
opts: stubOptions{caBody: "<html>nope</html>", caBodySet: true},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: "not a PEM certificate chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PEM block is not a certificate",
|
||||||
|
opts: stubOptions{caBody: "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n", caBodySet: true},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: `not a CERTIFICATE`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PEM certificate is corrupt",
|
||||||
|
opts: stubOptions{caBody: "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n", caBodySet: true},
|
||||||
|
config: func(t *testing.T, addr string) string { return authlessConfig(t, addr) },
|
||||||
|
wantErr: "parse certificate 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing config file",
|
||||||
|
config: func(t *testing.T, _ string) string {
|
||||||
|
return filepath.Join(t.TempDir(), "nope.yaml")
|
||||||
|
},
|
||||||
|
wantErr: "open config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config without addr",
|
||||||
|
config: func(t *testing.T, _ string) string {
|
||||||
|
return writeConfig(t, "vault:\n mount_point: pki_int\n")
|
||||||
|
},
|
||||||
|
wantErr: "vault.addr is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no mount anywhere",
|
||||||
|
config: func(t *testing.T, addr string) string {
|
||||||
|
return writeConfig(t, "vault:\n addr: "+addr+"\n")
|
||||||
|
},
|
||||||
|
wantErr: "vault.mount_point is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vault is unreachable",
|
||||||
|
config: func(t *testing.T, _ string) string {
|
||||||
|
return authlessConfig(t, "https://127.0.0.1:1", " timeout: 200ms\n")
|
||||||
|
},
|
||||||
|
wantErr: "GET pki_int/ca_chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ca takes no positional arguments",
|
||||||
|
config: func(t *testing.T, addr string) string {
|
||||||
|
return authlessConfig(t, addr)
|
||||||
|
},
|
||||||
|
args: func(cfg string) []string { return []string{"ca", "extra", "--config", cfg} },
|
||||||
|
wantErr: "unknown command",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv, _ := newVaultStub(t, tc.opts)
|
||||||
|
cfg := tc.config(t, srv.URL)
|
||||||
|
args := []string{"ca", "--config", cfg}
|
||||||
|
if tc.args != nil {
|
||||||
|
args = tc.args(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := run(args, &stdout, &stderr)
|
||||||
|
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatalf("exit code = 0, want non-zero (stdout %q)", stdout.String())
|
||||||
|
}
|
||||||
|
// A half-written PEM would be spliced into the catalog.
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("stdout must stay empty on failure, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), tc.wantErr) {
|
||||||
|
t.Errorf("stderr = %q, want it to mention %q", stderr.String(), tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,31 @@ func (v VaultConfig) RequestTimeout() (time.Duration, error) {
|
|||||||
|
|
||||||
// Load reads, parses, defaults and validates the config file at path.
|
// Load reads, parses, defaults and validates the config file at path.
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
|
cfg, err := parse(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.Vault.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCA reads the config for the CA chain path. That endpoint is public, so
|
||||||
|
// only vault.addr is required: a config carrying no auth method, no role and no
|
||||||
|
// credentials still loads.
|
||||||
|
func LoadCA(path string) (*Config, error) {
|
||||||
|
cfg, err := parse(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.Vault.ValidateCA(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(path string) (*Config, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open config %q: %w", path, err)
|
return nil, fmt.Errorf("open config %q: %w", path, err)
|
||||||
@@ -102,13 +127,21 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Vault.JWTPath == "" {
|
if cfg.Vault.JWTPath == "" {
|
||||||
cfg.Vault.JWTPath = DefaultJWTPath
|
cfg.Vault.JWTPath = DefaultJWTPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.Vault.Validate(); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid config %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateCA rejects a config that could not reach the CA chain endpoint. It
|
||||||
|
// deliberately ignores every auth key: the endpoint is unauthenticated.
|
||||||
|
func (v VaultConfig) ValidateCA() error {
|
||||||
|
if v.Addr == "" {
|
||||||
|
return fmt.Errorf("vault.addr is required")
|
||||||
|
}
|
||||||
|
if _, err := v.RequestTimeout(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Validate rejects a config that could not produce a certificate, naming the
|
// Validate rejects a config that could not produce a certificate, naming the
|
||||||
// key at fault. It never falls back from one auth method to another.
|
// key at fault. It never falls back from one auth method to another.
|
||||||
func (v VaultConfig) Validate() error {
|
func (v VaultConfig) Validate() error {
|
||||||
|
|||||||
@@ -125,3 +125,63 @@ func TestDefaultPath(t *testing.T) {
|
|||||||
t.Errorf("DefaultPath() = %q, want the Puppet-managed path", got)
|
t.Errorf("DefaultPath() = %q, want the Puppet-managed path", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CA chain endpoint is unauthenticated, so a config with nothing but addr
|
||||||
|
// and mount_point has to load.
|
||||||
|
func TestLoadCAWithoutCredentials(t *testing.T) {
|
||||||
|
cfg, err := LoadCA(writeConfig(t, "vault:\n addr: https://vault.service.consul:8200\n mount_point: pki_int\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadCA: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Vault.Addr != "https://vault.service.consul:8200" {
|
||||||
|
t.Errorf("addr = %q", cfg.Vault.Addr)
|
||||||
|
}
|
||||||
|
if cfg.Vault.MountPoint != "pki_int" {
|
||||||
|
t.Errorf("mount_point = %q", cfg.Vault.MountPoint)
|
||||||
|
}
|
||||||
|
if cfg.Vault.RoleID != "" || cfg.Vault.RoleName != "" {
|
||||||
|
t.Errorf("LoadCA invented credentials: %+v", cfg.Vault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load, by contrast, still demands everything issuance needs.
|
||||||
|
func TestLoadRejectsWhatLoadCAAccepts(t *testing.T) {
|
||||||
|
path := writeConfig(t, "vault:\n addr: https://vault.service.consul:8200\n mount_point: pki_int\n")
|
||||||
|
if _, err := Load(path); err == nil {
|
||||||
|
t.Fatal("Load accepted a config with no role_name or credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCAErrors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{"no addr", "vault:\n mount_point: pki_int\n", "vault.addr is required"},
|
||||||
|
{"bad timeout", "vault:\n addr: https://v:8200\n timeout: soon\n", "not a valid duration"},
|
||||||
|
{"negative timeout", "vault:\n addr: https://v:8200\n timeout: -5s\n", "must be positive"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := LoadCA(writeConfig(t, tc.body))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadCAMissingFile(t *testing.T) {
|
||||||
|
_, err := LoadCA(filepath.Join(t.TempDir(), "absent.yaml"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "open config") {
|
||||||
|
t.Errorf("error = %v, want an open failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unknown auth_method is fatal for issuance but irrelevant to the CA path.
|
||||||
|
func TestLoadCAIgnoresAuthMethod(t *testing.T) {
|
||||||
|
if _, err := LoadCA(writeConfig(t, "vault:\n addr: https://v:8200\n auth_method: ldap\n mount_point: pki_int\n")); err != nil {
|
||||||
|
t.Errorf("LoadCA: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+57
-8
@@ -1,6 +1,8 @@
|
|||||||
package pki
|
package pki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,10 +12,9 @@ import (
|
|||||||
// CertificateResponse is the JSON document printed with --json and consumed by
|
// CertificateResponse is the JSON document printed with --json and consumed by
|
||||||
// parsejson() in profiles::pki::vault. Field order fixes the key order.
|
// parsejson() in profiles::pki::vault. Field order fixes the key order.
|
||||||
type CertificateResponse struct {
|
type CertificateResponse struct {
|
||||||
Certificate string `json:"certificate"`
|
Certificate string `json:"certificate"`
|
||||||
PrivateKey string `json:"private_key"`
|
PrivateKey string `json:"private_key"`
|
||||||
FullChain string `json:"full_chain"`
|
FullChain string `json:"full_chain"`
|
||||||
CACertificate string `json:"ca_certificate"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type issueRequest struct {
|
type issueRequest struct {
|
||||||
@@ -48,10 +49,58 @@ func IssueCert(client *vault.Client, mountPoint, roleName, commonName string, al
|
|||||||
return nil, fmt.Errorf("vault returned no certificate data for %q", commonName)
|
return nil, fmt.Errorf("vault returned no certificate data for %q", commonName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leaf first: every TLS server that reads full_chain.crt expects the end
|
||||||
|
// entity certificate ahead of the issuers.
|
||||||
return &CertificateResponse{
|
return &CertificateResponse{
|
||||||
Certificate: resp.Data.Certificate,
|
Certificate: resp.Data.Certificate,
|
||||||
PrivateKey: resp.Data.PrivateKey,
|
PrivateKey: resp.Data.PrivateKey,
|
||||||
FullChain: resp.Data.IssuingCA + "\n" + resp.Data.Certificate,
|
FullChain: resp.Data.Certificate + "\n" + resp.Data.IssuingCA,
|
||||||
CACertificate: resp.Data.IssuingCA,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchCAChain returns the mount's CA chain as PEM. /ca_chain is public and
|
||||||
|
// estate-wide: it needs no token, no role and no per-node context, which is why
|
||||||
|
// it is fetched here rather than derived from an issue response. It carries the
|
||||||
|
// root as well as the intermediate, so the result stands alone as a CAfile for
|
||||||
|
// path builders that insist on a self-signed anchor.
|
||||||
|
func FetchCAChain(client *vault.Client, mountPoint string) (string, error) {
|
||||||
|
path := fmt.Sprintf("%s/ca_chain", mountPoint)
|
||||||
|
body, err := client.Get(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The endpoint advertises application/pkix-cert while serving PEM, so the
|
||||||
|
// body is parsed rather than sniffed.
|
||||||
|
chain := strings.TrimSpace(string(body))
|
||||||
|
if chain == "" {
|
||||||
|
return "", fmt.Errorf("GET %s: vault returned an empty CA chain", path)
|
||||||
|
}
|
||||||
|
n, err := countCertificates(chain)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("GET %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "", fmt.Errorf("GET %s: response is not a PEM certificate chain", path)
|
||||||
|
}
|
||||||
|
return chain + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCertificates(chain string) (int, error) {
|
||||||
|
rest := []byte(chain)
|
||||||
|
count := 0
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
return 0, fmt.Errorf("PEM block %d is a %q, not a CERTIFICATE", count+1, block.Type)
|
||||||
|
}
|
||||||
|
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||||
|
return 0, fmt.Errorf("parse certificate %d: %w", count+1, err)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+168
-9
@@ -1,13 +1,22 @@
|
|||||||
package pki_test
|
package pki_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.unkin.net/unkin/certmanager/internal/config"
|
"git.unkin.net/unkin/certmanager/internal/config"
|
||||||
"git.unkin.net/unkin/certmanager/internal/pki"
|
"git.unkin.net/unkin/certmanager/internal/pki"
|
||||||
@@ -68,14 +77,17 @@ func TestIssueCert(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
want := &pki.CertificateResponse{
|
want := &pki.CertificateResponse{
|
||||||
Certificate: "CERT",
|
Certificate: "CERT",
|
||||||
PrivateKey: "KEY",
|
PrivateKey: "KEY",
|
||||||
FullChain: "CA\nCERT",
|
FullChain: "CERT\nCA",
|
||||||
CACertificate: "CA",
|
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Errorf("IssueCert = %+v, want %+v", got, want)
|
t.Errorf("IssueCert = %+v, want %+v", got, want)
|
||||||
}
|
}
|
||||||
|
// Leaf first: asserted as an order, not as membership.
|
||||||
|
if got.FullChain != got.Certificate+"\n"+"CA" {
|
||||||
|
t.Errorf("full_chain = %q, want the leaf ahead of the issuing CA", got.FullChain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIssueCertSendsEmptySANFields(t *testing.T) {
|
func TestIssueCertSendsEmptySANFields(t *testing.T) {
|
||||||
@@ -124,16 +136,163 @@ func TestIssueCertEmptyPayload(t *testing.T) {
|
|||||||
|
|
||||||
func TestCertificateResponseJSONKeyOrder(t *testing.T) {
|
func TestCertificateResponseJSONKeyOrder(t *testing.T) {
|
||||||
b, err := json.Marshal(&pki.CertificateResponse{
|
b, err := json.Marshal(&pki.CertificateResponse{
|
||||||
Certificate: "CERT",
|
Certificate: "CERT",
|
||||||
PrivateKey: "KEY",
|
PrivateKey: "KEY",
|
||||||
FullChain: "CA\nCERT",
|
FullChain: "CERT\nCA",
|
||||||
CACertificate: "CA",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Marshal: %v", err)
|
t.Fatalf("Marshal: %v", err)
|
||||||
}
|
}
|
||||||
want := `{"certificate":"CERT","private_key":"KEY","full_chain":"CA\nCERT","ca_certificate":"CA"}`
|
want := `{"certificate":"CERT","private_key":"KEY","full_chain":"CERT\nCA"}`
|
||||||
if string(b) != want {
|
if string(b) != want {
|
||||||
t.Errorf("JSON = %s, want %s", b, want)
|
t.Errorf("JSON = %s, want %s", b, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newCAServer serves the CA chain endpoint and records what the client sent.
|
||||||
|
func newCAServer(t *testing.T, h http.HandlerFunc) (*vault.Client, *caCapture) {
|
||||||
|
t.Helper()
|
||||||
|
got := &caCapture{}
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got.method = r.Method
|
||||||
|
got.path = r.URL.Path
|
||||||
|
_, got.tokenSet = r.Header["X-Vault-Token"]
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/login") {
|
||||||
|
got.loggedIn = true
|
||||||
|
}
|
||||||
|
h(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("vault.NewUnauthenticated: %v", err)
|
||||||
|
}
|
||||||
|
return c, got
|
||||||
|
}
|
||||||
|
|
||||||
|
type caCapture struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
tokenSet bool
|
||||||
|
loggedIn bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChainPEM(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return testCertPEM(t, "intermediate") + testCertPEM(t, "root")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCertPEM(t *testing.T, cn string) string {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||||
|
Subject: pkix.Name{CommonName: cn},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
IsCA: true,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create certificate: %v", err)
|
||||||
|
}
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCAChain(t *testing.T) {
|
||||||
|
chain := testChainPEM(t)
|
||||||
|
c, got := newCAServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
// Vault labels the PEM body application/pkix-cert.
|
||||||
|
w.Header().Set("Content-Type", "application/pkix-cert")
|
||||||
|
_, _ = io.WriteString(w, chain)
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := pki.FetchCAChain(c, "pki_int")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchCAChain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", got.method)
|
||||||
|
}
|
||||||
|
if got.path != "/v1/pki_int/ca_chain" {
|
||||||
|
t.Errorf("path = %q", got.path)
|
||||||
|
}
|
||||||
|
if got.tokenSet {
|
||||||
|
t.Error("X-Vault-Token must not be sent to the CA chain endpoint")
|
||||||
|
}
|
||||||
|
if got.loggedIn {
|
||||||
|
t.Error("the CA chain path must not authenticate")
|
||||||
|
}
|
||||||
|
if out != strings.TrimSpace(chain)+"\n" {
|
||||||
|
t.Errorf("chain = %q, want %q", out, chain)
|
||||||
|
}
|
||||||
|
if n := strings.Count(out, "-----BEGIN CERTIFICATE-----"); n != 2 {
|
||||||
|
t.Errorf("chain carries %d certificates, want 2", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchCAChainFailures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "non-200",
|
||||||
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = io.WriteString(w, `{"errors":["permission denied"]}`)
|
||||||
|
},
|
||||||
|
wantErr: "vault returned 403",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
handler: func(http.ResponseWriter, *http.Request) {},
|
||||||
|
wantErr: "empty CA chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not PEM",
|
||||||
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, "definitely not a certificate")
|
||||||
|
},
|
||||||
|
wantErr: "not a PEM certificate chain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong PEM type",
|
||||||
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n")
|
||||||
|
},
|
||||||
|
wantErr: "not a CERTIFICATE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "corrupt certificate",
|
||||||
|
handler: func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n")
|
||||||
|
},
|
||||||
|
wantErr: "parse certificate 1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c, _ := newCAServer(t, tc.handler)
|
||||||
|
out, err := pki.FetchCAChain(c, "pki_int")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("FetchCAChain succeeded, want an error (got %q)", out)
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("a failing FetchCAChain must return no chain, got %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Errorf("error = %v, want it to mention %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+59
-12
@@ -17,6 +17,9 @@ import (
|
|||||||
// maxErrorBody caps how much of a Vault error response is quoted back.
|
// maxErrorBody caps how much of a Vault error response is quoted back.
|
||||||
const maxErrorBody = 2048
|
const maxErrorBody = 2048
|
||||||
|
|
||||||
|
// maxBody caps a GET response; the CA chain is a handful of kilobytes.
|
||||||
|
const maxBody = 1 << 20
|
||||||
|
|
||||||
// Client is an authenticated Vault HTTP client.
|
// Client is an authenticated Vault HTTP client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
addr string
|
addr string
|
||||||
@@ -28,22 +31,11 @@ type Client struct {
|
|||||||
// Client. An unknown method, or a failure of the selected method, is an error:
|
// Client. An unknown method, or a failure of the selected method, is an error:
|
||||||
// there is no fallback between methods.
|
// there is no fallback between methods.
|
||||||
func New(cfg config.VaultConfig) (*Client, error) {
|
func New(cfg config.VaultConfig) (*Client, error) {
|
||||||
timeout, err := cfg.RequestTimeout()
|
c, err := newClient(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.SkipTLSVerify()}, //nolint:gosec // defaults to the Python script's verify=False; flip vault.tls_skip_verify to enable
|
|
||||||
TLSHandshakeTimeout: timeout,
|
|
||||||
ResponseHeaderTimeout: timeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &Client{
|
|
||||||
addr: strings.TrimRight(cfg.Addr, "/"),
|
|
||||||
http: &http.Client{Transport: transport, Timeout: timeout},
|
|
||||||
}
|
|
||||||
|
|
||||||
switch cfg.AuthMethod {
|
switch cfg.AuthMethod {
|
||||||
case config.AuthMethodAppRole:
|
case config.AuthMethodAppRole:
|
||||||
err = c.loginAppRole(cfg)
|
err = c.loginAppRole(cfg)
|
||||||
@@ -59,6 +51,31 @@ func New(cfg config.VaultConfig) (*Client, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewUnauthenticated returns a Client that performs no login and holds no
|
||||||
|
// token. The PKI CA endpoints are public, so the CA chain is reachable from a
|
||||||
|
// config that carries no credentials at all.
|
||||||
|
func NewUnauthenticated(cfg config.VaultConfig) (*Client, error) {
|
||||||
|
return newClient(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(cfg config.VaultConfig) (*Client, error) {
|
||||||
|
timeout, err := cfg.RequestTimeout()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.SkipTLSVerify()}, //nolint:gosec // defaults to the Python script's verify=False; flip vault.tls_skip_verify to enable
|
||||||
|
TLSHandshakeTimeout: timeout,
|
||||||
|
ResponseHeaderTimeout: timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
addr: strings.TrimRight(cfg.Addr, "/"),
|
||||||
|
http: &http.Client{Transport: transport, Timeout: timeout},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) loginAppRole(cfg config.VaultConfig) error {
|
func (c *Client) loginAppRole(cfg config.VaultConfig) error {
|
||||||
// The Vault role sets bind_secret_id: false, so role_id alone logs in.
|
// The Vault role sets bind_secret_id: false, so role_id alone logs in.
|
||||||
payload := map[string]string{"role_id": cfg.RoleID}
|
payload := map[string]string{"role_id": cfg.RoleID}
|
||||||
@@ -105,6 +122,36 @@ func (c *Client) fetchToken(authPath string, payload any) (string, error) {
|
|||||||
return result.Auth.ClientToken, nil
|
return result.Auth.ClientToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get sends a GET to a Vault path and returns the raw body undecoded. The
|
||||||
|
// X-Vault-Token header is omitted entirely when the client holds no token.
|
||||||
|
func (c *Client) Get(path string) ([]byte, error) {
|
||||||
|
url := fmt.Sprintf("%s/v1/%s", c.addr, path)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("X-Vault-Token", c.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GET %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
detail, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody))
|
||||||
|
return nil, fmt.Errorf("GET %s: vault returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(detail)))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GET %s: read response: %w", path, err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Post sends an authenticated POST to a Vault path and decodes the response.
|
// Post sends an authenticated POST to a Vault path and decodes the response.
|
||||||
func (c *Client) Post(path string, payload, out any) error {
|
func (c *Client) Post(path string, payload, out any) error {
|
||||||
return c.post(path, c.token, payload, out)
|
return c.post(path, c.token, payload, out)
|
||||||
|
|||||||
@@ -259,3 +259,70 @@ func TestTLSVerificationCanBeEnabled(t *testing.T) {
|
|||||||
t.Fatal("New succeeded against a self-signed server with verification enabled")
|
t.Fatal("New succeeded against a self-signed server with verification enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewUnauthenticatedDoesNotLogIn(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
if _, ok := r.Header["X-Vault-Token"]; ok {
|
||||||
|
t.Errorf("X-Vault-Token sent to %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, "PEM")
|
||||||
|
})
|
||||||
|
|
||||||
|
// No auth_method, no role_id, no JWT path: the CA chain endpoint is public.
|
||||||
|
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUnauthenticated: %v", err)
|
||||||
|
}
|
||||||
|
body, err := c.Get("pki_int/ca_chain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "PEM" {
|
||||||
|
t.Errorf("body = %q", body)
|
||||||
|
}
|
||||||
|
if len(paths) != 1 || paths[0] != "/v1/pki_int/ca_chain" {
|
||||||
|
t.Errorf("requests = %v, want a single GET of the chain", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSendsTheTokenWhenAuthenticated(t *testing.T) {
|
||||||
|
var token string
|
||||||
|
srv := newServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/login") {
|
||||||
|
_, _ = fmt.Fprint(w, `{"auth":{"client_token":"s.token"}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token = r.Header.Get("X-Vault-Token")
|
||||||
|
_, _ = fmt.Fprint(w, "PEM")
|
||||||
|
})
|
||||||
|
|
||||||
|
c, err := vault.New(baseConfig(srv.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c.Get("pki_int/ca_chain"); err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if token != "s.token" {
|
||||||
|
t.Errorf("X-Vault-Token = %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSurfacesTheStatusAndBody(t *testing.T) {
|
||||||
|
srv := newServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = fmt.Fprint(w, `{"errors":["unsupported path"]}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
c, err := vault.NewUnauthenticated(config.VaultConfig{Addr: srv.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUnauthenticated: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c.Get("pki_int/ca_chain"); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "vault returned 404") ||
|
||||||
|
!strings.Contains(err.Error(), "unsupported path") {
|
||||||
|
t.Errorf("error = %v, want the status and Vault's message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
pkiMount = "pki"
|
// Dev-mode Vault has no PKI of its own, and a single root mount would make
|
||||||
|
// the CA chain one self-signed certificate -- which would not exercise the
|
||||||
|
// intermediate + root shape production serves. So a root mount signs an
|
||||||
|
// intermediate, and certificates are issued from the intermediate.
|
||||||
|
rootMount = "pki_root"
|
||||||
|
pkiMount = "pki_int"
|
||||||
pkiRole = "puppet"
|
pkiRole = "puppet"
|
||||||
approlePath = "approle"
|
approlePath = "approle"
|
||||||
approleRole = "certmanager"
|
approleRole = "certmanager"
|
||||||
@@ -123,18 +129,59 @@ func vaultReq(t *testing.T, method, path string, payload any, okErrors ...string
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupVault provisions the PKI mount, issuing role, policy and approle role,
|
// setupVault provisions the mounts, issuing role, policy and approle role,
|
||||||
// returning the role_id certmanager will authenticate with.
|
// returning the role_id certmanager will authenticate with. Provisioning runs
|
||||||
|
// once per process: re-signing the intermediate would add issuers and make the
|
||||||
|
// mount's CA chain non-deterministic.
|
||||||
func setupVault(t *testing.T) string {
|
func setupVault(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
setupOnce.Do(func() { setupRoleID = provisionVault(t) })
|
||||||
|
if setupRoleID == "" {
|
||||||
|
t.Fatal("vault provisioning failed")
|
||||||
|
}
|
||||||
|
return setupRoleID
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
setupOnce sync.Once
|
||||||
|
setupRoleID string
|
||||||
|
)
|
||||||
|
|
||||||
|
func provisionVault(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaultReq(t, http.MethodPost, "sys/mounts/"+rootMount,
|
||||||
|
map[string]any{"type": "pki", "config": map[string]any{"max_lease_ttl": "87600h"}},
|
||||||
|
"path is already in use")
|
||||||
vaultReq(t, http.MethodPost, "sys/mounts/"+pkiMount,
|
vaultReq(t, http.MethodPost, "sys/mounts/"+pkiMount,
|
||||||
map[string]any{"type": "pki", "config": map[string]any{"max_lease_ttl": "87600h"}},
|
map[string]any{"type": "pki", "config": map[string]any{"max_lease_ttl": "87600h"}},
|
||||||
"path is already in use")
|
"path is already in use")
|
||||||
|
|
||||||
vaultReq(t, http.MethodPost, pkiMount+"/root/generate/internal", map[string]any{
|
root := vaultReq(t, http.MethodPost, rootMount+"/root/generate/internal", map[string]any{
|
||||||
"common_name": certDomain + " integration CA",
|
"common_name": certDomain + " integration root CA",
|
||||||
"ttl": "87600h",
|
"ttl": "87600h",
|
||||||
|
"key_type": "rsa",
|
||||||
|
"key_bits": 2048,
|
||||||
|
})
|
||||||
|
rootPEM := dataString(t, root, "certificate")
|
||||||
|
|
||||||
|
csr := vaultReq(t, http.MethodPost, pkiMount+"/intermediate/generate/internal", map[string]any{
|
||||||
|
"common_name": certDomain + " integration intermediate CA",
|
||||||
|
"key_type": "rsa",
|
||||||
|
"key_bits": 2048,
|
||||||
|
})
|
||||||
|
|
||||||
|
signed := vaultReq(t, http.MethodPost, rootMount+"/root/sign-intermediate", map[string]any{
|
||||||
|
"csr": dataString(t, csr, "csr"),
|
||||||
|
"common_name": certDomain + " integration intermediate CA",
|
||||||
|
"ttl": "43800h",
|
||||||
|
"format": "pem",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Appending the root to the signed intermediate is what makes /ca_chain
|
||||||
|
// serve both certificates.
|
||||||
|
vaultReq(t, http.MethodPost, pkiMount+"/intermediate/set-signed", map[string]any{
|
||||||
|
"certificate": dataString(t, signed, "certificate") + "\n" + rootPEM,
|
||||||
})
|
})
|
||||||
|
|
||||||
vaultReq(t, http.MethodPost, pkiMount+"/roles/"+pkiRole, map[string]any{
|
vaultReq(t, http.MethodPost, pkiMount+"/roles/"+pkiRole, map[string]any{
|
||||||
@@ -172,6 +219,16 @@ func setupVault(t *testing.T) string {
|
|||||||
return roleID
|
return roleID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dataString(t *testing.T, resp map[string]any, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := resp["data"].(map[string]any)
|
||||||
|
v, _ := data[key].(string)
|
||||||
|
if v == "" {
|
||||||
|
t.Fatalf("vault returned no data.%s", key)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
// buildBinary compiles certmanager with the config path baked in, so the test
|
// buildBinary compiles certmanager with the config path baked in, so the test
|
||||||
// can invoke it with exactly the argv Puppet's generate() uses -- no --config.
|
// can invoke it with exactly the argv Puppet's generate() uses -- no --config.
|
||||||
func buildBinary(t *testing.T, dir, configPath string) string {
|
func buildBinary(t *testing.T, dir, configPath string) string {
|
||||||
@@ -263,11 +320,17 @@ func TestIssueCertificateAgainstVault(t *testing.T) {
|
|||||||
if err := json.Unmarshal([]byte(stdout), &doc); err != nil {
|
if err := json.Unmarshal([]byte(stdout), &doc); err != nil {
|
||||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
|
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
|
||||||
}
|
}
|
||||||
for _, key := range []string{"certificate", "private_key", "full_chain", "ca_certificate"} {
|
for _, key := range []string{"certificate", "private_key", "full_chain"} {
|
||||||
if doc[key] == "" {
|
if doc[key] == "" {
|
||||||
t.Errorf("JSON key %q is missing or empty", key)
|
t.Errorf("JSON key %q is missing or empty", key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, ok := doc["ca_certificate"]; ok {
|
||||||
|
t.Error("ca_certificate must not be emitted; the CA comes from the ca subcommand")
|
||||||
|
}
|
||||||
|
if len(doc) != 3 {
|
||||||
|
t.Errorf("document has %d keys, want exactly 3: %v", len(doc), keysOf(doc))
|
||||||
|
}
|
||||||
|
|
||||||
cert := parseCert(t, doc["certificate"])
|
cert := parseCert(t, doc["certificate"])
|
||||||
if cert.Subject.CommonName != commonName {
|
if cert.Subject.CommonName != commonName {
|
||||||
@@ -290,16 +353,173 @@ func TestIssueCertificateAgainstVault(t *testing.T) {
|
|||||||
t.Errorf("certificate lifetime %v, want ~%dd", lifetime, expiryDays)
|
t.Errorf("certificate lifetime %v, want ~%dd", lifetime, expiryDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
ca := parseCert(t, doc["ca_certificate"])
|
// full_chain is leaf first: asserted as an order, not as membership.
|
||||||
if !ca.IsCA {
|
chain := parseChain(t, doc["full_chain"])
|
||||||
t.Errorf("ca_certificate is not a CA certificate")
|
if len(chain) != 2 {
|
||||||
|
t.Fatalf("full_chain carries %d certificates, want leaf + issuing CA", len(chain))
|
||||||
}
|
}
|
||||||
if err := cert.CheckSignatureFrom(ca); err != nil {
|
if !chain[0].Equal(cert) {
|
||||||
t.Errorf("certificate is not signed by ca_certificate: %v", err)
|
t.Errorf("full_chain starts with %q, want the leaf %q", chain[0].Subject.CommonName, commonName)
|
||||||
}
|
}
|
||||||
if !strings.Contains(doc["full_chain"], strings.TrimSpace(doc["certificate"])) ||
|
if !chain[1].IsCA {
|
||||||
!strings.Contains(doc["full_chain"], strings.TrimSpace(doc["ca_certificate"])) {
|
t.Errorf("full_chain[1] (%q) is not a CA certificate", chain[1].Subject.CommonName)
|
||||||
t.Errorf("full_chain does not contain both the leaf and the issuing CA")
|
}
|
||||||
|
if err := chain[0].CheckSignatureFrom(chain[1]); err != nil {
|
||||||
|
t.Errorf("full_chain is not leaf-then-issuer: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCAChainAgainstVault is the reason the CA no longer rides along with
|
||||||
|
// issuance: this runs with no credentials at all.
|
||||||
|
func TestCAChainAgainstVault(t *testing.T) {
|
||||||
|
waitForVault(t)
|
||||||
|
roleID := setupVault(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := writeConfig(t, dir, roleID)
|
||||||
|
bin := buildBinary(t, dir, cfg)
|
||||||
|
|
||||||
|
code, stdout, stderr := runCertmanager(t, bin, "ca")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code %d, stderr: %s", code, stderr)
|
||||||
|
}
|
||||||
|
if stderr != "" {
|
||||||
|
t.Errorf("stderr must be empty on success, got %q", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puppet reads this through generate() too, so it must be the PEM and
|
||||||
|
// nothing else.
|
||||||
|
if !strings.HasPrefix(stdout, "-----BEGIN CERTIFICATE-----") {
|
||||||
|
t.Fatalf("stdout is not a PEM document: %q", stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
chain := parseChain(t, stdout)
|
||||||
|
if len(chain) != 2 {
|
||||||
|
t.Fatalf("CA chain carries %d certificates, want intermediate + root", len(chain))
|
||||||
|
}
|
||||||
|
intermediate, root := chain[0], chain[1]
|
||||||
|
|
||||||
|
if !intermediate.IsCA || !root.IsCA {
|
||||||
|
t.Errorf("both chain entries must be CA certificates: %v, %v", intermediate.IsCA, root.IsCA)
|
||||||
|
}
|
||||||
|
// The root anchors the chain: self-issued and self-signed. issuing_ca alone
|
||||||
|
// would not be, which is what breaks strict path builders.
|
||||||
|
if root.Subject.String() != root.Issuer.String() {
|
||||||
|
t.Errorf("chain[1] subject %q != issuer %q, so it is not the root", root.Subject, root.Issuer)
|
||||||
|
}
|
||||||
|
if err := root.CheckSignatureFrom(root); err != nil {
|
||||||
|
t.Errorf("chain[1] is not self-signed: %v", err)
|
||||||
|
}
|
||||||
|
if intermediate.Subject.String() == intermediate.Issuer.String() {
|
||||||
|
t.Errorf("chain[0] %q is self-issued; expected the intermediate", intermediate.Subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue a leaf and verify it against the chain the ca subcommand returned.
|
||||||
|
_, issued, stderr := runCertmanager(t, bin,
|
||||||
|
commonName,
|
||||||
|
"--alt-names", shortName+","+altName,
|
||||||
|
"--ip-sans", ipSAN1+","+ipSAN2,
|
||||||
|
"--expiry-days", fmt.Sprint(expiryDays),
|
||||||
|
"--json",
|
||||||
|
)
|
||||||
|
var doc map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(issued), &doc); err != nil {
|
||||||
|
t.Fatalf("issue stdout is not JSON: %v (stderr %q)", err, stderr)
|
||||||
|
}
|
||||||
|
leaf := parseCert(t, doc["certificate"])
|
||||||
|
|
||||||
|
if err := leaf.CheckSignatureFrom(intermediate); err != nil {
|
||||||
|
t.Errorf("chain[0] did not sign the issued leaf: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The openssl verify equivalent: anchor on the root, bridge with the
|
||||||
|
// intermediate, both taken from the ca output alone.
|
||||||
|
roots := x509.NewCertPool()
|
||||||
|
roots.AddCert(root)
|
||||||
|
inters := x509.NewCertPool()
|
||||||
|
inters.AddCert(intermediate)
|
||||||
|
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||||
|
Roots: roots,
|
||||||
|
Intermediates: inters,
|
||||||
|
DNSName: commonName,
|
||||||
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||||
|
}); err != nil {
|
||||||
|
t.Errorf("leaf does not validate against the ca chain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CA chain endpoint is public, so a config with no auth keys must work.
|
||||||
|
func TestCAChainNeedsNoCredentials(t *testing.T) {
|
||||||
|
waitForVault(t)
|
||||||
|
roleID := setupVault(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := buildBinary(t, dir, writeConfig(t, dir, roleID))
|
||||||
|
|
||||||
|
authless := filepath.Join(dir, "authless.yaml")
|
||||||
|
body := fmt.Sprintf("vault:\n addr: %s\n mount_point: %s\n", vaultAddr(), pkiMount)
|
||||||
|
if err := os.WriteFile(authless, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, stdout, stderr := runCertmanager(t, bin, "ca", "--config", authless)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code %d, stderr: %s", code, stderr)
|
||||||
|
}
|
||||||
|
if stderr != "" {
|
||||||
|
t.Errorf("stderr must be empty on success, got %q", stderr)
|
||||||
|
}
|
||||||
|
if len(parseChain(t, stdout)) != 2 {
|
||||||
|
t.Errorf("credential-free ca output is not the two certificate chain: %q", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCAChainFailsCleanlyForAnUnknownMount(t *testing.T) {
|
||||||
|
waitForVault(t)
|
||||||
|
roleID := setupVault(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := buildBinary(t, dir, writeConfig(t, dir, roleID))
|
||||||
|
|
||||||
|
code, stdout, stderr := runCertmanager(t, bin, "ca", "--mount", "pki_does_not_exist")
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatalf("expected a non-zero exit, stdout: %s", stdout)
|
||||||
|
}
|
||||||
|
if stdout != "" {
|
||||||
|
t.Errorf("stdout must be empty on failure, got %q", stdout)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "certmanager:") {
|
||||||
|
t.Errorf("stderr should carry the cause, got %q", stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChain(t *testing.T, data string) []*x509.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
var out []*x509.Certificate
|
||||||
|
rest := []byte(data)
|
||||||
|
for {
|
||||||
|
var block *pem.Block
|
||||||
|
block, rest = pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
t.Fatalf("PEM block %d is a %q", len(out)+1, block.Type)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse certificate %d: %v", len(out)+1, err)
|
||||||
|
}
|
||||||
|
out = append(out, cert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user