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
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ repos:
- id: go-test

# ── JS / TS ──
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.1.0
- repo: local
hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
types_or: [javascript, ts, css, html]
exclude: wasm_exec\.js$

- repo: local
hooks:
- id: vitest
name: vitest
entry: bash -c 'cd web && npm test'
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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`])

### 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`])

## [0.8.0] - 2026-02-22

### Added
Expand Down Expand Up @@ -562,6 +573,8 @@ 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
[`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
20 changes: 18 additions & 2 deletions cmd/wasm/aia.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,26 @@ import (
// resolveAIA walks AIA CA Issuers URLs for all non-root certificates in the
// store, fetching missing intermediates via JavaScript. Delegates the algorithm
// to the shared certstore.ResolveAIA with a JS fetch transport.
//
// Progress is dispatched to JS via setTimeout so the browser event loop can
// update the progress bar without blocking the AIA goroutine.
func resolveAIA(ctx context.Context, s *certstore.MemStore) []string {
return certstore.ResolveAIA(ctx, certstore.ResolveAIAInput{
Store: s,
Fetch: jsFetchURL,
Store: s,
Fetch: jsFetchURL,
Concurrency: 50,
OnProgress: func(completed, total int) {
var cb js.Func
cb = js.FuncOf(func(_ js.Value, _ []js.Value) any {
defer cb.Release()
fn := js.Global().Get("certkitOnAIAProgress")
if fn.Type() == js.TypeFunction {
fn.Invoke(completed, total)
}
return nil
})
js.Global().Call("setTimeout", cb, 0)
},
})
}

Expand Down
76 changes: 74 additions & 2 deletions cmd/wasm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ package main

import (
"context"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"fmt"
Expand All @@ -32,6 +34,7 @@ func main() {
js.Global().Set("certkitGetState", js.FuncOf(getState))
js.Global().Set("certkitExportBundles", js.FuncOf(exportBundlesJS))
js.Global().Set("certkitReset", js.FuncOf(resetStore))
js.Global().Set("certkitValidateCert", js.FuncOf(validateCertificate))

// Block forever — WASM modules must not exit.
select {}
Expand Down Expand Up @@ -148,15 +151,18 @@ func getState(_ js.Value, _ []js.Value) any {
type certInfo struct {
SKI string `json:"ski"`
CN string `json:"cn"`
Serial string `json:"serial"`
CertType string `json:"cert_type"`
KeyType string `json:"key_type"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
Expired bool `json:"expired"`
HasKey bool `json:"has_key"`
Trusted bool `json:"trusted"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
SANs []string `json:"sans"`
EKUs []string `json:"ekus"`
Source string `json:"source"`
}

Expand Down Expand Up @@ -210,18 +216,35 @@ func getState(_ js.Value, _ []js.Value) any {
trusted = certkit.VerifyChainTrust(certkit.VerifyChainTrustInput{Cert: rec.Cert, Roots: roots, Intermediates: intermediatePool})
}

serial := ""
if rec.Cert.SerialNumber != nil {
serial = strings.ToUpper(rec.Cert.SerialNumber.Text(16))
}

ekus := formatEKUs(rec.Cert.ExtKeyUsage)
if ekus == nil {
ekus = []string{}
}
sans := rec.Cert.DNSNames
if sans == nil {
sans = []string{}
}

ci := certInfo{
SKI: certkit.ColonHex(hexToBytes(ski)),
CN: certstore.FormatCN(rec.Cert),
Serial: serial,
CertType: rec.CertType,
KeyType: rec.KeyType,
NotBefore: rec.NotBefore.UTC().Format(time.RFC3339),
NotAfter: rec.NotAfter.UTC().Format(time.RFC3339),
Expired: expired,
HasKey: hasKey,
Trusted: trusted,
Issuer: rec.Cert.Issuer.CommonName,
SANs: rec.Cert.DNSNames,
Subject: formatDN(rec.Cert.Subject),
Issuer: formatDN(rec.Cert.Issuer),
SANs: sans,
EKUs: ekus,
Source: rec.Source,
}
resp.Certs = append(resp.Certs, ci)
Expand Down Expand Up @@ -303,6 +326,55 @@ func resetStore(_ js.Value, _ []js.Value) any {
return true
}

// formatDN formats a pkix.Name as a comma-separated string of its components.
func formatDN(name pkix.Name) string {
var parts []string
for _, c := range name.Country {
parts = append(parts, "C="+c)
}
for _, s := range name.Province {
parts = append(parts, "ST="+s)
}
for _, l := range name.Locality {
parts = append(parts, "L="+l)
}
for _, o := range name.Organization {
parts = append(parts, "O="+o)
}
for _, ou := range name.OrganizationalUnit {
parts = append(parts, "OU="+ou)
}
if name.CommonName != "" {
parts = append(parts, "CN="+name.CommonName)
}
return strings.Join(parts, ", ")
}

var extKeyUsageNames = map[x509.ExtKeyUsage]string{
x509.ExtKeyUsageAny: "Any",
x509.ExtKeyUsageServerAuth: "Server Authentication",
x509.ExtKeyUsageClientAuth: "Client Authentication",
x509.ExtKeyUsageCodeSigning: "Code Signing",
x509.ExtKeyUsageEmailProtection: "Email Protection",
x509.ExtKeyUsageTimeStamping: "Time Stamping",
x509.ExtKeyUsageOCSPSigning: "OCSP Signing",
x509.ExtKeyUsageMicrosoftServerGatedCrypto: "Microsoft Server Gated Crypto",
x509.ExtKeyUsageNetscapeServerGatedCrypto: "Netscape Server Gated Crypto",
}

// formatEKUs returns human-readable names for extended key usages.
func formatEKUs(ekus []x509.ExtKeyUsage) []string {
var out []string
for _, eku := range ekus {
if name, ok := extKeyUsageNames[eku]; ok {
out = append(out, name)
} else {
out = append(out, fmt.Sprintf("Unknown (%d)", int(eku)))
}
}
return out
}

// hexToBytes decodes a hex string to bytes, returning nil on error.
func hexToBytes(h string) []byte {
b, _ := hex.DecodeString(h)
Expand Down
Loading