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
3 changes: 3 additions & 0 deletions internal/risk/risk.go
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,9 @@ var toolLevels = func() map[string]Level {
// Offline OCSP response decoder (RFC 6960) — query-based revocation
// status, the per-certificate counterpart to crl_decode. Read-only. v0.771.
"ocsp_decode",
// Offline X.509 chain linkage verifier (signature links + ordering +
// expiry; not full path validation). Read-only. v0.772.
"x509_chain_verify",
// Offline WebAuthn/FIDO2 authenticator-data decoder (flags,
// sign-count, AAGUID, credential ID + COSE key). Read-only,
// transmits nothing. v0.756.
Expand Down
4 changes: 3 additions & 1 deletion internal/tools/registry_size_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3059,7 +3059,9 @@ func TestRegistrySize(t *testing.T) {
// completing the X.509 PKI decoder family). internal/tools + internal/x509decode.
// v0.771.0 added ocsp_decode (offline OCSP response decoder, RFC 6960 — completes the
// X.509 revocation picture alongside crl_decode). internal/tools + internal/x509decode.
const expected = 701
// v0.772.0 added x509_chain_verify (offline X.509 chain linkage / ordering / expiry
// verifier). internal/tools + internal/x509decode.
const expected = 702
if initialRegistrySize != expected {
t.Errorf("registry names at init = %d, want %d (wave-by-wave checked in §D of runbook)",
initialRegistrySize, expected)
Expand Down
7 changes: 4 additions & 3 deletions internal/tools/tool_search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,9 +387,10 @@ func TestToolSearch_AuditVerifyDiscoverability(t *testing.T) {
// and CRL decoders.
func TestToolSearch_CSRCRLDiscoverability(t *testing.T) {
checks := map[string][]string{
"csr_decode": {"certificate signing request", "csr decode", "pkcs10 request"},
"crl_decode": {"x509 crl", "crl decode", "revoked certificates"},
"ocsp_decode": {"ocsp", "ocsp response", "certificate revocation status"},
"csr_decode": {"certificate signing request", "csr decode", "pkcs10 request"},
"crl_decode": {"x509 crl", "crl decode", "revoked certificates"},
"ocsp_decode": {"ocsp", "ocsp response", "certificate revocation status"},
"x509_chain_verify": {"certificate chain", "verify chain", "chain of trust"},
}
for name, queries := range checks {
for _, q := range queries {
Expand Down
46 changes: 43 additions & 3 deletions internal/tools/x509_csr_crl.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

// x509_csr_crl.go registers csr_decode and crl_decode, completing the X.509
// PKI decoder family (certificate + request + revocation) alongside
// x509_certificate_decode. Both delegate to internal/x509decode.
// x509_csr_crl.go registers the X.509 PKI tools beyond the single-certificate
// decoder: csr_decode (request), crl_decode + ocsp_decode (revocation), and
// x509_chain_verify (chain linkage). All delegate to internal/x509decode.

package tools

Expand All @@ -20,6 +20,7 @@ func init() { //nolint:gochecknoinits
Register(csrDecodeSpec)
Register(crlDecodeSpec)
Register(ocspDecodeSpec)
Register(x509ChainVerifySpec)
}

var csrDecodeSpec = Spec{
Expand Down Expand Up @@ -129,3 +130,42 @@ func ocspDecodeHandler(_ context.Context, _ *Deps, p map[string]any) (string, er
out, _ := json.MarshalIndent(res, "", " ")
return string(out), nil
}

var x509ChainVerifySpec = Spec{
Name: "x509_chain_verify",
Description: "Verify an **X.509 certificate chain** — the natural next step after `x509_certificate_decode` " +
"and the #1 PKI debugging task. Paste a PEM bundle (multiple `-----BEGIN CERTIFICATE-----` blocks, in " +
"leaf→root order) or a hex-DER concatenation. Checks the **signature linkage** between adjacent " +
"certificates (each must be signed by the next, by a parent that is a CA permitted to sign), and reports:\n" +
"- **ordered** — whether every adjacent link verifies in the supplied order;\n" +
"- **reaches_self_signed_root** — whether a self-signed trust-anchor terminates the chain;\n" +
"- **any_expired** — whether any cert is past NotAfter (a common 'chain looks right but is rejected' cause);\n" +
"- per-certificate subject/issuer/CA/expiry and per-link validity (with the exact error on a broken link).\n\n" +
"Diagnoses wrong ordering, a missing intermediate, or an expired link. Signature/linkage only — not full " +
"RFC 5280 path validation against a root store. Offline, read-only. Low risk.",
Schema: json.RawMessage(`{
"type":"object",
"properties":{
"input":{"type":"string","description":"PEM bundle (leaf->root order) or hex-DER concatenation of the chain"}
},
"required":["input"]
}`),
Required: []string{"input"},
Risk: risk.Low,
Group: GroupHostTools,
AgentOnly: false,
Handler: x509ChainVerifyHandler,
}

func x509ChainVerifyHandler(_ context.Context, _ *Deps, p map[string]any) (string, error) {
raw := str(p, "input")
if strings.TrimSpace(raw) == "" {
return "", fmt.Errorf("x509_chain_verify: 'input' is required")
}
res, err := x509decode.VerifyChain(raw)
if err != nil {
return "", fmt.Errorf("x509_chain_verify: %w", err)
}
out, _ := json.MarshalIndent(res, "", " ")
return string(out), nil
}
41 changes: 41 additions & 0 deletions internal/tools/x509_csr_crl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,44 @@ func TestOCSPDecodeTool(t *testing.T) {
t.Error("missing input should error")
}
}

func TestX509ChainVerifyTool(t *testing.T) {
spec, ok := Get("x509_chain_verify")
if !ok {
t.Fatal("x509_chain_verify not registered")
}
far := time.Now().Add(24 * time.Hour)
// Root CA (self-signed).
rootKey, _ := rsa.GenerateKey(rand.Reader, 2048)
rootTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Root CA"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: far,
IsCA: true, KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true,
}
rootDER, _ := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
rootCert, _ := x509.ParseCertificate(rootDER)
// Leaf signed by root.
leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
leafTmpl := &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "leaf.example.com"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: far,
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
leafDER, _ := x509.CreateCertificate(rand.Reader, leafTmpl, rootCert, &leafKey.PublicKey, rootKey)
leafCert, _ := x509.ParseCertificate(leafDER)

var b strings.Builder
b.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw}))
b.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootCert.Raw}))

out, err := spec.Handler(context.Background(), &Deps{}, map[string]any{"input": b.String()})
if err != nil {
t.Fatalf("handler: %v", err)
}
if !strings.Contains(out, `"ordered": true`) || !strings.Contains(out, `"reaches_self_signed_root": true`) {
t.Errorf("chain should verify ordered to root:\n%s", out)
}
if _, err := spec.Handler(context.Background(), &Deps{}, map[string]any{}); err == nil {
t.Error("missing input should error")
}
}
172 changes: 172 additions & 0 deletions internal/x509decode/chain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package x509decode

import (
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
"time"
)

// ChainCert is the per-certificate summary in a verified chain.
type ChainCert struct {
Position int `json:"position"` // 0 = leaf
SubjectDN string `json:"subject_dn"`
IssuerDN string `json:"issuer_dn"`
SelfIssued bool `json:"self_issued"` // subject == issuer
IsCA bool `json:"is_ca"`
NotAfter string `json:"not_after"`
Expired bool `json:"expired"`
}

// ChainLink reports whether certificate i is validly signed by certificate
// i+1 (the candidate parent immediately above it in the supplied order).
type ChainLink struct {
ChildPosition int `json:"child_position"`
ParentPosition int `json:"parent_position"`
ChildSubject string `json:"child_subject"`
ParentSubject string `json:"parent_subject"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}

// ChainResult is the decoded + linkage-verified view of a certificate chain.
type ChainResult struct {
Source string `json:"source"` // PEM | DER
Count int `json:"count"`
Certs []ChainCert `json:"certs"`
Links []ChainLink `json:"links,omitempty"`

// Ordered is true when every adjacent link verifies, i.e. the certs are
// in leaf -> ... -> root order and each is signed by the next.
Ordered bool `json:"ordered"`
// ReachesSelfSignedRoot is true when the last certificate is self-issued
// and its own signature verifies (a trust-anchor root is present at the
// end of the chain).
ReachesSelfSignedRoot bool `json:"reaches_self_signed_root"`
// AnyExpired flags whether any certificate in the chain is past its
// NotAfter — a common cause of "chain looks right but is rejected".
AnyExpired bool `json:"any_expired"`

Note string `json:"note"`
}

// VerifyChain parses every certificate in a PEM bundle (or a single hex-DER
// certificate) and checks the signature linkage between adjacent certificates
// in the order supplied: each certificate must be signed by the next one up.
// It reports ordering, whether a self-signed root terminates the chain, and
// per-certificate expiry — the information an operator needs to diagnose the
// usual "the chain is present but not trusted" failures (wrong order, missing
// intermediate, expired link).
//
// Linkage uses crypto/x509's CheckSignatureFrom, which verifies the
// cryptographic signature AND that the parent is a CA permitted to sign
// certificates. It does NOT perform full RFC 5280 path validation (name
// constraints, policies, or trust against a root store) — expiry is reported
// per certificate but is not folded into link validity.
func VerifyChain(input string) (*ChainResult, error) {
certs, source, err := parseChainCerts(input)
if err != nil {
return nil, err
}
now := time.Now()

res := &ChainResult{Source: source, Count: len(certs), Ordered: true}
for i, c := range certs {
expired := now.After(c.NotAfter)
if expired {
res.AnyExpired = true
}
res.Certs = append(res.Certs, ChainCert{
Position: i,
SubjectDN: c.Subject.String(),
IssuerDN: c.Issuer.String(),
SelfIssued: c.Subject.String() == c.Issuer.String(),
IsCA: c.IsCA,
NotAfter: c.NotAfter.UTC().Format(time.RFC3339),
Expired: expired,
})
}

for i := 0; i+1 < len(certs); i++ {
child, parent := certs[i], certs[i+1]
link := ChainLink{
ChildPosition: i,
ParentPosition: i + 1,
ChildSubject: child.Subject.String(),
ParentSubject: parent.Subject.String(),
}
if err := child.CheckSignatureFrom(parent); err != nil {
link.Valid = false
link.Error = err.Error()
res.Ordered = false
} else {
link.Valid = true
}
res.Links = append(res.Links, link)
}

// A self-signed root terminates the chain when the last cert is
// self-issued and verifies its own signature.
if last := certs[len(certs)-1]; last.Subject.String() == last.Issuer.String() {
res.ReachesSelfSignedRoot = last.CheckSignatureFrom(last) == nil
}

switch {
case len(certs) == 1:
res.Note = "single certificate — no chain links to verify"
case res.Ordered && res.ReachesSelfSignedRoot:
res.Note = "chain is correctly ordered and terminates in a self-signed root"
case res.Ordered:
res.Note = "chain links verify in order but no self-signed root is present (intermediate-only bundle)"
default:
res.Note = "chain does NOT link up in the supplied order — check ordering / a missing intermediate"
}
return res, nil
}

// parseChainCerts extracts every certificate from a PEM bundle, or a single
// certificate from hex-DER input.
func parseChainCerts(input string) ([]*x509.Certificate, string, error) {
s := strings.TrimSpace(input)
if s == "" {
return nil, "", fmt.Errorf("x509decode: empty input")
}
if strings.Contains(s, "-----BEGIN") {
var certs []*x509.Certificate
rest := []byte(s)
for {
block, remaining := pem.Decode(rest)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, "", fmt.Errorf("x509decode: parse certificate %d: %w", len(certs), err)
}
certs = append(certs, c)
}
rest = remaining
}
if len(certs) == 0 {
return nil, "", fmt.Errorf("x509decode: no CERTIFICATE block in PEM input")
}
return certs, "PEM", nil
}
b, err := parseHex(s)
if err != nil {
return nil, "", err
}
// A hex blob may itself be a concatenation of DER certificates.
certs, err := x509.ParseCertificates(b)
if err != nil {
return nil, "", fmt.Errorf("x509decode: parse DER: %w", err)
}
if len(certs) == 0 {
return nil, "", fmt.Errorf("x509decode: no certificate in DER input")
}
return certs, "DER", nil
}
Loading
Loading