Skip to content

Commit 28b3bca

Browse files
danielewoodclaude
andcommitted
Add WASM web app and Cloudflare Pages deployment
Browser-based certkit: drag-and-drop certificate/key files, inspect metadata, resolve chains via AIA, and export organized bundles as ZIP. All processing runs locally in the browser via WebAssembly. - cmd/wasm/: WASM entry point with in-memory store replacing SQLite, file ingestion pipeline, AIA chain resolution with Mozilla root matching, and selective bundle export - web/: Cloudflare Pages static site with CORS proxy for AIA fetching, cert table with filters (expired/unmatched/non-leaf/untrusted), checkbox selection for export, and dark/light theme support - GitHub Actions workflow deploys WASM + site on each version tag - Makefile targets: wasm, wasm-serve, clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da544a5 commit 28b3bca

14 files changed

Lines changed: 2313 additions & 1 deletion

File tree

.github/workflows/deploy-pages.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Deploy to Cloudflare Pages
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
jobs:
9+
deploy:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v6
15+
16+
- name: Setup Go
17+
uses: actions/setup-go@v6
18+
with:
19+
go-version: stable
20+
check-latest: true
21+
cache: true
22+
23+
- name: Build WASM
24+
run: make wasm
25+
26+
- name: Deploy to Cloudflare Pages
27+
uses: cloudflare/wrangler-action@v3
28+
with:
29+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
30+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
31+
workingDirectory: web
32+
command: pages deploy public --project-name certkit

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,8 @@ bundles/
2323
.vscode/
2424

2525
changes.md
26-
*.local.*
26+
*.local.*
27+
28+
# WASM build artifacts
29+
web/public/certkit.wasm
30+
web/public/wasm_exec.js

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add browser-based WASM build (`cmd/wasm/`) with drag-and-drop certificate/key processing, chain resolution, and ZIP bundle export
13+
- Add Cloudflare Pages deployment with CORS proxy for AIA certificate fetching (`web/`)
14+
- Add GitHub Actions workflow to build WASM and deploy to Cloudflare Pages on tag push
15+
- Add certificate trust validation against embedded Mozilla root store in WASM UI
16+
- Add selectable export: checkboxes to choose which matched bundles to include in ZIP
17+
- Add UI filters: hide expired, unmatched, non-leaf, and untrusted certificates
18+
1019
## [0.6.0] - 2026-02-14
1120

1221
### Added

Makefile

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.PHONY: build test vet wasm wasm-serve clean
2+
3+
build:
4+
go build -trimpath ./...
5+
6+
test:
7+
go test -race ./...
8+
9+
vet:
10+
go vet ./...
11+
12+
wasm:
13+
GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" \
14+
-o web/public/certkit.wasm ./cmd/wasm/
15+
cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" web/public/wasm_exec.js
16+
17+
wasm-serve: wasm
18+
@echo "Serving at http://localhost:8080"
19+
cd web/public && python3 -m http.server 8080
20+
21+
clean:
22+
rm -f web/public/certkit.wasm web/public/wasm_exec.js

cmd/wasm/aia.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
//go:build js && wasm
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"crypto/x509"
8+
"encoding/pem"
9+
"fmt"
10+
"syscall/js"
11+
12+
"github.com/breml/rootcerts/embedded"
13+
"github.com/sensiblebit/certkit"
14+
)
15+
16+
// mozillaRootSubjects is a lazily-built set of RawSubject bytes from Mozilla
17+
// root certificates. Used to skip AIA fetching when the issuer is already a
18+
// trusted root — we don't need to fetch roots, we already have them embedded.
19+
var mozillaRootSubjects map[string]bool
20+
21+
// getMozillaRootSubjects returns a set of RawSubject strings from all Mozilla
22+
// root certificates. Initialized once on first call.
23+
func getMozillaRootSubjects() map[string]bool {
24+
if mozillaRootSubjects != nil {
25+
return mozillaRootSubjects
26+
}
27+
mozillaRootSubjects = make(map[string]bool)
28+
pemData := []byte(embedded.MozillaCACertificatesPEM())
29+
for {
30+
var block *pem.Block
31+
block, pemData = pem.Decode(pemData)
32+
if block == nil {
33+
break
34+
}
35+
if block.Type != "CERTIFICATE" {
36+
continue
37+
}
38+
cert, err := x509.ParseCertificate(block.Bytes)
39+
if err != nil {
40+
continue
41+
}
42+
mozillaRootSubjects[string(cert.RawSubject)] = true
43+
}
44+
return mozillaRootSubjects
45+
}
46+
47+
// issuedByMozillaRoot reports whether the cert's issuer matches a Mozilla root
48+
// certificate's subject (by raw ASN.1 bytes).
49+
func issuedByMozillaRoot(cert *x509.Certificate) bool {
50+
return getMozillaRootSubjects()[string(cert.RawIssuer)]
51+
}
52+
53+
// resolveAIA walks the AIA CA Issuers URLs for all non-root certificates in the
54+
// store, fetching any missing intermediate issuers. Fetching is delegated to
55+
// JavaScript (certkitFetchURL) which handles direct fetch and CORS proxy fallback.
56+
//
57+
// Skips certificates whose issuer is already in the store or is a Mozilla root.
58+
// Only fetches intermediates — never roots.
59+
func resolveAIA(ctx context.Context, s *store) []string {
60+
var warnings []string
61+
seen := make(map[string]bool)
62+
63+
const maxDepth = 5
64+
for range maxDepth {
65+
var queue []*x509.Certificate
66+
for _, rec := range s.certs {
67+
if rec.CertType == "root" {
68+
continue
69+
}
70+
if s.hasIssuer(rec.Cert) {
71+
continue
72+
}
73+
if issuedByMozillaRoot(rec.Cert) {
74+
continue
75+
}
76+
queue = append(queue, rec.Cert)
77+
}
78+
79+
if len(queue) == 0 {
80+
break
81+
}
82+
83+
fetched := 0
84+
for _, cert := range queue {
85+
for _, aiaURL := range cert.IssuingCertificateURL {
86+
if seen[aiaURL] {
87+
continue
88+
}
89+
seen[aiaURL] = true
90+
91+
body, err := jsFetchURL(aiaURL)
92+
if err != nil {
93+
warnings = append(warnings, fmt.Sprintf(
94+
"Could not fetch issuer for %q from %s: %v. "+
95+
"Include the intermediate certificate file in your upload to resolve this.",
96+
cert.Subject.CommonName, aiaURL, err,
97+
))
98+
continue
99+
}
100+
101+
issuer, err := parseCertificateBytes(body)
102+
if err != nil {
103+
warnings = append(warnings, fmt.Sprintf(
104+
"Fetched %s but could not parse: %v",
105+
aiaURL, err,
106+
))
107+
continue
108+
}
109+
110+
if err := s.addCertificate(issuer, "AIA: "+aiaURL); err != nil {
111+
continue
112+
}
113+
fetched++
114+
}
115+
}
116+
117+
if fetched == 0 {
118+
break
119+
}
120+
}
121+
122+
return warnings
123+
}
124+
125+
// hasIssuer reports whether the store contains the issuer for the given cert.
126+
func (s *store) hasIssuer(cert *x509.Certificate) bool {
127+
for _, rec := range s.certs {
128+
if rec.Cert == cert {
129+
continue
130+
}
131+
if string(rec.Cert.RawSubject) == string(cert.RawIssuer) {
132+
return true
133+
}
134+
}
135+
return false
136+
}
137+
138+
// jsFetchURL calls the JavaScript certkitFetchURL function which handles
139+
// direct fetch with automatic CORS proxy fallback. Blocks until the JS
140+
// Promise resolves or rejects.
141+
func jsFetchURL(url string) ([]byte, error) {
142+
fetchFn := js.Global().Get("certkitFetchURL")
143+
if fetchFn.Type() != js.TypeFunction {
144+
return nil, fmt.Errorf("certkitFetchURL not defined")
145+
}
146+
147+
type result struct {
148+
data []byte
149+
err error
150+
}
151+
ch := make(chan result, 1)
152+
153+
promise := fetchFn.Invoke(url)
154+
155+
thenCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
156+
uint8Array := args[0]
157+
data := make([]byte, uint8Array.Length())
158+
js.CopyBytesToGo(data, uint8Array)
159+
ch <- result{data: data}
160+
return nil
161+
})
162+
163+
catchCb := js.FuncOf(func(_ js.Value, args []js.Value) any {
164+
errMsg := args[0].Get("message").String()
165+
ch <- result{err: fmt.Errorf("%s", errMsg)}
166+
return nil
167+
})
168+
169+
promise.Call("then", thenCb).Call("catch", catchCb)
170+
171+
r := <-ch
172+
thenCb.Release()
173+
catchCb.Release()
174+
return r.data, r.err
175+
}
176+
177+
// parseCertificateBytes tries to parse bytes as DER then PEM.
178+
func parseCertificateBytes(data []byte) (*x509.Certificate, error) {
179+
cert, err := x509.ParseCertificate(data)
180+
if err == nil {
181+
return cert, nil
182+
}
183+
pemCert, pemErr := certkit.ParsePEMCertificate(data)
184+
if pemErr == nil {
185+
return pemCert, nil
186+
}
187+
return nil, fmt.Errorf("not DER (%v) or PEM (%v)", err, pemErr)
188+
}

0 commit comments

Comments
 (0)