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
13 changes: 6 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add `certkitValidateCert` WASM function for browser-based certificate validation ([`d8b9759`])
- Add concurrent AIA resolution — fetches up to `Concurrency` URLs in parallel per depth round (default 20, WASM uses 50) ([`d8b9759`])
- Add `serial` field to WASM `getState()` certificate data — hex-encoded serial number ([`d8b9759`])
- Add paste support to web UI drop zone — Ctrl+V / Cmd+V pastes PEM or certificate text directly without needing a file ([`837e5e8`])
- Add `certkitValidateCert` WASM function for browser-based certificate validation ([`392878a`])
- Add concurrent AIA resolution — fetches up to `Concurrency` URLs in parallel per depth round (default 20, WASM uses 50) ([`392878a`])
- Add `serial` field to WASM `getState()` certificate data — hex-encoded serial number ([`392878a`])
- Add paste support to web UI drop zone — Ctrl+V / Cmd+V pastes PEM or certificate text directly without needing a file ([`392878a`])

### Changed

- Replace Inspect/Verify tab navigation with unified category tabs (Leaf, Intermediate, Root, Keys) — certificates are now organized by type with click-to-expand detail rows showing validation checks and metadata ([`d8b9759`])
- Replace Inspect/Verify tab navigation with unified category tabs (Leaf, Intermediate, Root, Keys) — certificates are now organized by type with click-to-expand detail rows showing validation checks and metadata ([`392878a`])

## [0.8.0] - 2026-02-22

Expand Down Expand Up @@ -573,8 +573,7 @@ Initial release.
[0.1.1]: https://github.com/sensiblebit/certkit/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/sensiblebit/certkit/releases/tag/v0.1.0

[`d8b9759`]: https://github.com/sensiblebit/certkit/commit/d8b9759
[`837e5e8`]: https://github.com/sensiblebit/certkit/commit/837e5e8
[`392878a`]: https://github.com/sensiblebit/certkit/commit/392878a
[`e70e8e5`]: https://github.com/sensiblebit/certkit/commit/e70e8e5
[`0fa55af`]: https://github.com/sensiblebit/certkit/commit/0fa55af
[`b69caef`]: https://github.com/sensiblebit/certkit/commit/b69caef
Expand Down
247 changes: 4 additions & 243 deletions cmd/wasm/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,11 @@ package main

import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"fmt"
"math"
"strings"
"syscall/js"
"time"

"github.com/sensiblebit/certkit"
"github.com/sensiblebit/certkit/internal/certstore"
)

Expand All @@ -42,7 +35,10 @@ func validateCertificate(_ js.Value, args []js.Value) any {
}
defer storeMu.RUnlock()

result, err := runValidation(ctx, globalStore, ski)
result, err := certstore.RunValidation(ctx, certstore.RunValidationInput{
Store: globalStore,
SKIColon: ski,
})
if err != nil {
reject.Invoke(js.Global().Get("Error").New(err.Error()))
return
Expand All @@ -60,238 +56,3 @@ func validateCertificate(_ js.Value, args []js.Value) any {
handler.Release()
return p
}

type validationResult struct {
Subject string `json:"subject"`
SANs []string `json:"sans"`
NotAfter string `json:"not_after"`
Valid bool `json:"valid"`
Checks []validationCheck `json:"checks"`
}

type validationCheck struct {
Name string `json:"name"`
Status string `json:"status"` // "pass", "fail", "warn"
Detail string `json:"detail"`
}

// runValidation looks up a cert by SKI and runs all checks against it.
func runValidation(_ context.Context, store *certstore.MemStore, skiColon string) (*validationResult, error) {
skiHex := strings.ReplaceAll(skiColon, ":", "")
skiHex = strings.ToLower(skiHex)

allCerts := store.AllCerts()
rec, ok := allCerts[skiHex]
if !ok {
return nil, fmt.Errorf("certificate with SKI %s not found", skiColon)
}

leaf := rec.Cert
now := time.Now()
intermediatePool := store.IntermediatePool()

roots, err := certkit.MozillaRootPool()
if err != nil {
return nil, fmt.Errorf("loading Mozilla root pool: %w", err)
}

var checks []validationCheck
checks = append(checks, checkExpiration(leaf, now))
checks = append(checks, checkKeyStrength(leaf))
checks = append(checks, checkSignature(leaf))
checks = append(checks, checkTrustChain(checkTrustChainInput{
Leaf: leaf,
Intermediates: intermediatePool,
Roots: roots,
Now: now,
})...)

hasFail := false
for _, c := range checks {
if c.Status == "fail" {
hasFail = true
break
}
}

sans := leaf.DNSNames
if sans == nil {
sans = []string{}
}

return &validationResult{
Subject: certstore.FormatCN(leaf),
SANs: sans,
NotAfter: leaf.NotAfter.UTC().Format(time.RFC3339),
Valid: !hasFail,
Checks: checks,
}, nil
}

func checkExpiration(cert *x509.Certificate, now time.Time) validationCheck {
if now.Before(cert.NotBefore) {
return validationCheck{
Name: "Expiration",
Status: "fail",
Detail: fmt.Sprintf("Not valid until %s", cert.NotBefore.UTC().Format("Jan 2, 2006")),
}
}
if now.After(cert.NotAfter) {
return validationCheck{
Name: "Expiration",
Status: "fail",
Detail: fmt.Sprintf("Expired %s", cert.NotAfter.UTC().Format("Jan 2, 2006")),
}
}
remaining := cert.NotAfter.Sub(now)
days := int(math.Ceil(remaining.Hours() / 24))
return validationCheck{
Name: "Expiration",
Status: "pass",
Detail: fmt.Sprintf("Expires %s (%d days)", cert.NotAfter.UTC().Format("Jan 2, 2006"), days),
}
}

func checkKeyStrength(cert *x509.Certificate) validationCheck {
switch pub := cert.PublicKey.(type) {
case *rsa.PublicKey:
bits := pub.N.BitLen()
if bits < 2048 {
return validationCheck{
Name: "Key Strength",
Status: "fail",
Detail: fmt.Sprintf("RSA %d-bit (minimum 2048)", bits),
}
}
return validationCheck{
Name: "Key Strength",
Status: "pass",
Detail: fmt.Sprintf("RSA %d-bit", bits),
}
case *ecdsa.PublicKey:
curve := pub.Curve.Params().Name
bits := pub.Curve.Params().BitSize
return validationCheck{
Name: "Key Strength",
Status: "pass",
Detail: fmt.Sprintf("ECDSA %s (%d-bit)", curve, bits),
}
case ed25519.PublicKey:
return validationCheck{
Name: "Key Strength",
Status: "pass",
Detail: "Ed25519 (256-bit)",
}
default:
return validationCheck{
Name: "Key Strength",
Status: "warn",
Detail: "Unknown key type",
}
}
}

func checkSignature(cert *x509.Certificate) validationCheck {
switch cert.SignatureAlgorithm { //nolint:exhaustive // only flagging known-weak algorithms
case x509.MD2WithRSA, x509.MD5WithRSA:
return validationCheck{
Name: "Signature",
Status: "fail",
Detail: cert.SignatureAlgorithm.String(),
}
case x509.SHA1WithRSA, x509.ECDSAWithSHA1:
return validationCheck{
Name: "Signature",
Status: "warn",
Detail: cert.SignatureAlgorithm.String() + " (legacy)",
}
default:
return validationCheck{
Name: "Signature",
Status: "pass",
Detail: cert.SignatureAlgorithm.String(),
}
}
}

// checkTrustChainInput holds parameters for checkTrustChain.
type checkTrustChainInput struct {
Leaf *x509.Certificate
Intermediates *x509.CertPool
Roots *x509.CertPool
Now time.Time
}

// checkTrustChain returns two checks: "Trust Chain" (path) and "Trusted Root".
func checkTrustChain(input checkTrustChainInput) []validationCheck {
leaf := input.Leaf
intermediates := input.Intermediates
roots := input.Roots
if roots == nil {
return []validationCheck{
{Name: "Trust Chain", Status: "fail", Detail: "Could not load root store"},
{Name: "Trusted Root", Status: "fail", Detail: "Could not load root store"},
}
}

// Mozilla roots are self-trust-anchors.
if certkit.IsMozillaRoot(leaf) {
return []validationCheck{
{Name: "Trust Chain", Status: "pass", Detail: leaf.Subject.CommonName + " (root)"},
{Name: "Trusted Root", Status: "pass", Detail: leaf.Subject.CommonName + " (Mozilla)"},
}
}

opts := x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if input.Now.After(leaf.NotAfter) {
opts.CurrentTime = leaf.NotBefore.Add(time.Second)
}

chains, err := leaf.Verify(opts)
if err != nil || len(chains) == 0 {
chainCheck := validationCheck{
Name: "Trust Chain",
Status: "fail",
Detail: "Could not build chain to trusted root",
}
rootCheck := validationCheck{
Name: "Trusted Root",
Status: "fail",
Detail: "No trusted root found",
}
return []validationCheck{chainCheck, rootCheck}
}

// Use the first (shortest) verified chain.
chain := chains[0]
var pathParts []string
for _, cert := range chain {
cn := cert.Subject.CommonName
if cn == "" && len(cert.Subject.Organization) > 0 {
cn = cert.Subject.Organization[0]
}
pathParts = append(pathParts, cn)
}
chainDetail := strings.Join(pathParts, " → ")

root := chain[len(chain)-1]
rootCN := root.Subject.CommonName
if rootCN == "" && len(root.Subject.Organization) > 0 {
rootCN = root.Subject.Organization[0]
}
rootStatus := "pass"
rootDetail := rootCN + " (Mozilla)"
if !certkit.IsMozillaRoot(root) {
rootStatus = "warn"
rootDetail = rootCN + " (not in Mozilla root store)"
}

return []validationCheck{
{Name: "Trust Chain", Status: "pass", Detail: chainDetail},
{Name: "Trusted Root", Status: rootStatus, Detail: rootDetail},
}
}
12 changes: 5 additions & 7 deletions internal/certstore/aia.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ func ResolveAIA(ctx context.Context, input ResolveAIAInput) []string {

var warnings []string
seen := make(map[string]bool)
processed := make(map[string]bool) // track unique certs across rounds

// needsResolution reports whether a cert's issuer is missing from the
// store and not a known Mozilla root. Certs that are themselves Mozilla
Expand All @@ -100,13 +99,8 @@ func ResolveAIA(ctx context.Context, input ResolveAIAInput) []string {
return true
}

// Count total certs needing resolution up front for progress reporting.
progressTotal := 0
for _, rec := range input.Store.AllCertsFlat() {
if needsResolution(rec) {
progressTotal++
}
}
processed := make(map[string]bool)

for range maxDepth {
var queue []*CertRecord
Expand All @@ -120,6 +114,10 @@ func ResolveAIA(ctx context.Context, input ResolveAIAInput) []string {
break
}

// Recount progressTotal each round to include newly-discovered
// intermediates, preventing completed from exceeding total.
progressTotal = len(processed) + len(queue)

// Phase 1: Collect unique work items and pre-validate URLs.
// Only the main goroutine touches `seen` — no concurrent access.
var work []aiaWorkItem
Expand Down
Loading