Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions cmd/krakenkey/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,9 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
case "download":
fs := flag.NewFlagSet("cert download", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var outPath string
var outPath, format string
fs.StringVar(&outPath, "out", "", "Output file path (default: ./<cn>.crt)")
fs.StringVar(&format, "format", "cert", "PEM format: cert (leaf only), chain (intermediates), fullchain (leaf + intermediates)")
if err := fs.Parse(subArgs); err != nil {
return err
}
Expand All @@ -433,7 +434,7 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
if !ok {
return &api.ErrConfig{Message: "certificate ID must be an integer"}
}
return cert.RunDownload(ctx, client, printer, id, outPath)
return cert.RunDownload(ctx, client, printer, id, outPath, format)

case "issue":
fs := flag.NewFlagSet("cert issue", flag.ContinueOnError)
Expand All @@ -449,6 +450,8 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
keyOut string
csrOut string
out string
chainOut string
fullchainOut string
autoRenew bool
wait bool
pollInterval = 15 * time.Second
Expand All @@ -465,6 +468,8 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
fs.StringVar(&keyOut, "key-out", "", "Private key output path (default: ./<domain>.key)")
fs.StringVar(&csrOut, "csr-out", "", "CSR output path (default: ./<domain>.csr)")
fs.StringVar(&out, "out", "", "Certificate output path (default: ./<domain>.crt)")
fs.StringVar(&chainOut, "chain-out", "", "Chain output path (default: ./<domain>.chain.crt)")
fs.StringVar(&fullchainOut, "fullchain-out", "", "Full chain output path (default: ./<domain>.fullchain.crt)")
fs.BoolVar(&autoRenew, "auto-renew", false, "Enable automatic renewal")
fs.BoolVar(&wait, "wait", false, "Wait for issuance to complete")
fs.DurationVar(&pollInterval, "poll-interval", pollInterval, "How often to poll for status")
Expand All @@ -491,6 +496,8 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
KeyOut: keyOut,
CSROut: csrOut,
Out: out,
ChainOut: chainOut,
FullchainOut: fullchainOut,
AutoRenew: autoRenew,
Wait: wait,
PollInterval: pollInterval,
Expand All @@ -503,13 +510,17 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
var (
csrPath string
out string
chainOut string
fullchainOut string
autoRenew bool
wait bool
pollInterval = 15 * time.Second
pollTimeout = 10 * time.Minute
)
fs.StringVar(&csrPath, "csr", "", "Path to CSR PEM file (required)")
fs.StringVar(&out, "out", "", "Certificate output path (default: ./<cn>.crt)")
fs.StringVar(&chainOut, "chain-out", "", "Chain output path (default: ./<cn>.chain.crt)")
fs.StringVar(&fullchainOut, "fullchain-out", "", "Full chain output path (default: ./<cn>.fullchain.crt)")
fs.BoolVar(&autoRenew, "auto-renew", false, "Enable automatic renewal")
fs.BoolVar(&wait, "wait", false, "Wait for issuance to complete")
fs.DurationVar(&pollInterval, "poll-interval", pollInterval, "How often to poll for status")
Expand All @@ -527,6 +538,8 @@ func runCert(ctx context.Context, client *api.Client, printer *output.Printer, c
return cert.RunSubmit(ctx, client, printer, cert.SubmitOptions{
CSRPath: csrPath,
Out: out,
ChainOut: chainOut,
FullchainOut: fullchainOut,
AutoRenew: autoRenew,
Wait: wait,
PollInterval: pollInterval,
Expand Down
8 changes: 8 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ func (c *Client) GetCertDetails(ctx context.Context, id int) (*TlsCertDetails, e
return &details, nil
}

func (c *Client) GetCertChain(ctx context.Context, id int) (*TlsCertChainInfo, error) {
var chain TlsCertChainInfo
if err := c.do(ctx, http.MethodGet, "/certs/tls/"+strconv.Itoa(id)+"/chain", nil, &chain); err != nil {
return nil, err
}
return &chain, nil
}

func (c *Client) UpdateCert(ctx context.Context, id int, autoRenew *bool) (*TlsCert, error) {
body := map[string]any{"autoRenew": autoRenew}
var cert TlsCert
Expand Down
18 changes: 18 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type TlsCert struct {
RawCsr string `json:"rawCsr"`
ParsedCsr *ParsedCsr `json:"parsedCsr"`
CrtPem string `json:"crtPem"`
ChainPem string `json:"chainPem"`
Status string `json:"status"`
ExpiresAt *time.Time `json:"expiresAt"`
LastRenewedAt *time.Time `json:"lastRenewedAt"`
Expand Down Expand Up @@ -82,6 +83,23 @@ type TlsCertDetails struct {
Fingerprint string `json:"fingerprint"`
}

// TlsCertChainEntry holds parsed details for an intermediate certificate.
type TlsCertChainEntry struct {
SerialNumber string `json:"serialNumber"`
Issuer string `json:"issuer"`
Subject string `json:"subject"`
ValidFrom string `json:"validFrom"`
ValidTo string `json:"validTo"`
Fingerprint string `json:"fingerprint"`
}

// TlsCertChainInfo holds the full certificate chain.
type TlsCertChainInfo struct {
LeafCert TlsCertDetails `json:"leafCert"`
Intermediates []TlsCertChainEntry `json:"intermediates"`
FullChainPem string `json:"fullChainPem"`
}

// CertResponse is returned by mutating cert operations (create, renew, revoke, retry).
type CertResponse struct {
ID int `json:"id"`
Expand Down
90 changes: 79 additions & 11 deletions internal/cert/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,57 @@ func RunShow(ctx context.Context, client *api.Client, printer *output.Printer, i
}

if c.Status == api.CertStatusIssued {
details, err := client.GetCertDetails(ctx, id)
chain, err := client.GetCertChain(ctx, id)
if err == nil {
printer.Println("")
printer.Println("Serial: %s", details.SerialNumber)
printer.Println("Issuer: %s", details.Issuer)
printer.Println("Valid from: %s", details.ValidFrom.Format(time.RFC3339))
printer.Println("Valid to: %s", details.ValidTo.Format(time.RFC3339))
printer.Println("Fingerprint: %s", details.Fingerprint)
printer.Println("Serial: %s", chain.LeafCert.SerialNumber)
printer.Println("Issuer: %s", chain.LeafCert.Issuer)
printer.Println("Valid from: %s", chain.LeafCert.ValidFrom.Format(time.RFC3339))
printer.Println("Valid to: %s", chain.LeafCert.ValidTo.Format(time.RFC3339))
printer.Println("Fingerprint: %s", chain.LeafCert.Fingerprint)

if len(chain.Intermediates) > 0 {
printer.Println("")
printer.Println("Chain (%d intermediate%s):", len(chain.Intermediates), pluralS(len(chain.Intermediates)))
for i, ic := range chain.Intermediates {
printer.Println(" [%d] Subject: %s", i+1, ic.Subject)
printer.Println(" Issuer: %s", ic.Issuer)
printer.Println(" Fingerprint: %s", ic.Fingerprint)
}
}
} else {
details, err := client.GetCertDetails(ctx, id)
if err == nil {
printer.Println("")
printer.Println("Serial: %s", details.SerialNumber)
printer.Println("Issuer: %s", details.Issuer)
printer.Println("Valid from: %s", details.ValidFrom.Format(time.RFC3339))
printer.Println("Valid to: %s", details.ValidTo.Format(time.RFC3339))
printer.Println("Fingerprint: %s", details.Fingerprint)
}
}
}
return nil
}

func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}

// DownloadFormat controls which PEM content is written by RunDownload.
const (
FormatCert = "cert"
FormatChain = "chain"
FormatFullchain = "fullchain"
)

// RunDownload saves the certificate PEM to outPath (default: ./<cn>.crt).
func RunDownload(ctx context.Context, client *api.Client, printer *output.Printer, id int, outPath string) error {
// format controls what is written: "cert" (leaf only), "chain" (intermediates only),
// or "fullchain" (leaf + intermediates).
func RunDownload(ctx context.Context, client *api.Client, printer *output.Printer, id int, outPath, format string) error {
c, err := client.GetCert(ctx, id)
if err != nil {
return err
Expand All @@ -124,16 +160,48 @@ func RunDownload(ctx context.Context, client *api.Client, printer *output.Printe
return fmt.Errorf("certificate %d has no PEM data", id)
}

if format == "" {
format = FormatCert
}

var pemData string
switch format {
case FormatCert:
pemData = c.CrtPem
case FormatChain:
if c.ChainPem == "" {
return fmt.Errorf("certificate %d has no intermediate chain data", id)
}
pemData = c.ChainPem
case FormatFullchain:
chain, err := client.GetCertChain(ctx, id)
if err != nil {
return fmt.Errorf("fetch chain: %w", err)
}
pemData = chain.FullChainPem
default:
return fmt.Errorf("invalid format %q: must be cert, chain, or fullchain", format)
}

if outPath == "" {
outPath = cnFromCert(c) + ".crt"
var suffix string
switch format {
case FormatChain:
suffix = ".chain.crt"
case FormatFullchain:
suffix = ".fullchain.crt"
default:
suffix = ".crt"
}
outPath = cnFromCert(c) + suffix
}

if err := os.WriteFile(outPath, []byte(c.CrtPem), 0o644); err != nil {
if err := os.WriteFile(outPath, []byte(pemData), 0o644); err != nil {
return fmt.Errorf("write certificate: %w", err)
}

printer.JSON(map[string]string{"path": outPath})
printer.Success("Certificate saved to %s", outPath)
printer.JSON(map[string]string{"path": outPath, "format": format})
printer.Success("Certificate (%s) saved to %s", format, outPath)
return nil
}

Expand Down
Loading
Loading