···19192020# Build frontend assets (Tailwind CSS, JS bundle, SVG icons)
2121RUN npm ci
2222-RUN npm run css:build && npm run css:copy-hold && npm run js:build:hold && npm run icons:build
2222+go generate ./...
23232424# Conditionally add billing tag based on build arg
2525RUN if [ "$BILLING_ENABLED" = "true" ]; then \
···11+package appview
22+33+import (
44+ "crypto/rand"
55+ "crypto/rsa"
66+ "crypto/x509"
77+ "crypto/x509/pkix"
88+ "database/sql"
99+ "encoding/pem"
1010+ "fmt"
1111+ "log/slog"
1212+ "math/big"
1313+ "os"
1414+ "path/filepath"
1515+ "time"
1616+1717+ "atcr.io/pkg/appview/db"
1818+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1919+)
2020+2121+// loadOAuthKey loads the OAuth P-256 key with priority: DB → file → generate.
2222+// Keys loaded from file or newly generated are stored in the DB.
2323+func loadOAuthKey(database *sql.DB, keyPath string) (*atcrypto.PrivateKeyP256, error) {
2424+ // Try database first
2525+ data, err := db.GetCryptoKey(database, "oauth_p256")
2626+ if err != nil {
2727+ return nil, fmt.Errorf("failed to query crypto_keys: %w", err)
2828+ }
2929+ if data != nil {
3030+ key, err := atcrypto.ParsePrivateBytesP256(data)
3131+ if err != nil {
3232+ return nil, fmt.Errorf("failed to parse OAuth key from database: %w", err)
3333+ }
3434+ slog.Info("Loaded OAuth P-256 key from database")
3535+ return key, nil
3636+ }
3737+3838+ // Try file fallback
3939+ if keyPath != "" {
4040+ if fileData, err := os.ReadFile(keyPath); err == nil {
4141+ key, err := atcrypto.ParsePrivateBytesP256(fileData)
4242+ if err != nil {
4343+ return nil, fmt.Errorf("failed to parse OAuth key from file %s: %w", keyPath, err)
4444+ }
4545+ // Migrate to database
4646+ if err := db.PutCryptoKey(database, "oauth_p256", fileData); err != nil {
4747+ return nil, fmt.Errorf("failed to store OAuth key in database: %w", err)
4848+ }
4949+ slog.Info("Migrated OAuth P-256 key from file to database", "path", keyPath)
5050+ return key, nil
5151+ }
5252+ }
5353+5454+ // Generate new key
5555+ p256Key, err := atcrypto.GeneratePrivateKeyP256()
5656+ if err != nil {
5757+ return nil, fmt.Errorf("failed to generate OAuth P-256 key: %w", err)
5858+ }
5959+6060+ keyBytes := p256Key.Bytes()
6161+ if err := db.PutCryptoKey(database, "oauth_p256", keyBytes); err != nil {
6262+ return nil, fmt.Errorf("failed to store generated OAuth key in database: %w", err)
6363+ }
6464+ slog.Info("Generated new OAuth P-256 key and stored in database")
6565+6666+ return p256Key, nil
6767+}
6868+6969+// loadJWTKeyAndCert loads the JWT RSA key from DB (with file fallback) and generates
7070+// a self-signed certificate. The cert is always regenerated and written to certPath
7171+// on disk because the distribution library reads it via os.Open().
7272+func loadJWTKeyAndCert(database *sql.DB, keyPath, certPath string) (*rsa.PrivateKey, []byte, error) {
7373+ rsaKey, err := loadRSAKey(database, keyPath)
7474+ if err != nil {
7575+ return nil, nil, err
7676+ }
7777+7878+ // Generate cert and write to disk for distribution library
7979+ certDER, err := generateAndWriteCert(rsaKey, certPath)
8080+ if err != nil {
8181+ return nil, nil, err
8282+ }
8383+8484+ return rsaKey, certDER, nil
8585+}
8686+8787+// loadRSAKey loads the RSA private key with priority: DB → file → generate.
8888+func loadRSAKey(database *sql.DB, keyPath string) (*rsa.PrivateKey, error) {
8989+ // Try database first
9090+ data, err := db.GetCryptoKey(database, "jwt_rsa")
9191+ if err != nil {
9292+ return nil, fmt.Errorf("failed to query crypto_keys: %w", err)
9393+ }
9494+ if data != nil {
9595+ key, err := parseRSAKeyPEM(data)
9696+ if err != nil {
9797+ return nil, fmt.Errorf("failed to parse RSA key from database: %w", err)
9898+ }
9999+ slog.Info("Loaded JWT RSA key from database")
100100+ return key, nil
101101+ }
102102+103103+ // Try file fallback
104104+ if keyPath != "" {
105105+ if fileData, err := os.ReadFile(keyPath); err == nil {
106106+ key, err := parseRSAKeyPEM(fileData)
107107+ if err != nil {
108108+ return nil, fmt.Errorf("failed to parse RSA key from file %s: %w", keyPath, err)
109109+ }
110110+ // Migrate to database
111111+ if err := db.PutCryptoKey(database, "jwt_rsa", fileData); err != nil {
112112+ return nil, fmt.Errorf("failed to store RSA key in database: %w", err)
113113+ }
114114+ slog.Info("Migrated JWT RSA key from file to database", "path", keyPath)
115115+ return key, nil
116116+ }
117117+ }
118118+119119+ // Generate new key
120120+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
121121+ if err != nil {
122122+ return nil, fmt.Errorf("failed to generate RSA key: %w", err)
123123+ }
124124+125125+ keyPEM := pem.EncodeToMemory(&pem.Block{
126126+ Type: "RSA PRIVATE KEY",
127127+ Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
128128+ })
129129+ if err := db.PutCryptoKey(database, "jwt_rsa", keyPEM); err != nil {
130130+ return nil, fmt.Errorf("failed to store generated RSA key in database: %w", err)
131131+ }
132132+ slog.Info("Generated new JWT RSA key and stored in database")
133133+134134+ return rsaKey, nil
135135+}
136136+137137+func parseRSAKeyPEM(data []byte) (*rsa.PrivateKey, error) {
138138+ block, _ := pem.Decode(data)
139139+ if block == nil || block.Type != "RSA PRIVATE KEY" {
140140+ return nil, fmt.Errorf("failed to decode PEM block containing RSA private key")
141141+ }
142142+ return x509.ParsePKCS1PrivateKey(block.Bytes)
143143+}
144144+145145+// generateAndWriteCert creates a self-signed certificate from the RSA key and writes
146146+// it to certPath. Returns the DER-encoded certificate bytes for the JWT x5c header.
147147+func generateAndWriteCert(rsaKey *rsa.PrivateKey, certPath string) ([]byte, error) {
148148+ template := x509.Certificate{
149149+ SerialNumber: big.NewInt(1),
150150+ Subject: pkix.Name{
151151+ Organization: []string{"ATCR"},
152152+ CommonName: "ATCR Token Signing Certificate",
153153+ },
154154+ NotBefore: time.Now(),
155155+ NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
156156+ KeyUsage: x509.KeyUsageDigitalSignature,
157157+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
158158+ BasicConstraintsValid: true,
159159+ }
160160+161161+ certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &rsaKey.PublicKey, rsaKey)
162162+ if err != nil {
163163+ return nil, fmt.Errorf("failed to create certificate: %w", err)
164164+ }
165165+166166+ // Write cert to disk for distribution library
167167+ certPEM := pem.EncodeToMemory(&pem.Block{
168168+ Type: "CERTIFICATE",
169169+ Bytes: certDER,
170170+ })
171171+172172+ dir := filepath.Dir(certPath)
173173+ if err := os.MkdirAll(dir, 0700); err != nil {
174174+ return nil, fmt.Errorf("failed to create cert directory: %w", err)
175175+ }
176176+ if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
177177+ return nil, fmt.Errorf("failed to write certificate: %w", err)
178178+ }
179179+180180+ slog.Info("Generated JWT signing certificate", "path", certPath)
181181+ return certDER, nil
182182+}
+26
pkg/appview/db/crypto_keys.go
···11+package db
22+33+import "database/sql"
44+55+// GetCryptoKey retrieves a key by name from the database.
66+// Returns nil, nil if no key with that name exists.
77+func GetCryptoKey(db DBTX, name string) ([]byte, error) {
88+ var data []byte
99+ err := db.QueryRow("SELECT key_data FROM crypto_keys WHERE name = ?", name).Scan(&data)
1010+ if err == sql.ErrNoRows {
1111+ return nil, nil
1212+ }
1313+ if err != nil {
1414+ return nil, err
1515+ }
1616+ return data, nil
1717+}
1818+1919+// PutCryptoKey stores a key in the database, replacing any existing key with the same name.
2020+func PutCryptoKey(db DBTX, name string, data []byte) error {
2121+ _, err := db.Exec(
2222+ "INSERT INTO crypto_keys (name, key_data) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET key_data = excluded.key_data",
2323+ name, data,
2424+ )
2525+ return err
2626+}
···11+description: Create crypto_keys table for storing signing keys in the database
22+query: |
33+ CREATE TABLE IF NOT EXISTS crypto_keys (
44+ name TEXT PRIMARY KEY,
55+ key_data BLOB NOT NULL,
66+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
77+ );
+6
pkg/appview/db/schema.sql
···237237 FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
238238);
239239CREATE INDEX IF NOT EXISTS idx_repo_pages_did ON repo_pages(did);
240240+241241+CREATE TABLE IF NOT EXISTS crypto_keys (
242242+ name TEXT PRIMARY KEY,
243243+ key_data BLOB NOT NULL,
244244+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
245245+);
+19-19
pkg/appview/handlers/scan_result_test.go
···84848585 body := rr.Body.String()
86868787- // Should contain severity badges
8888- if !strings.Contains(body, "badge-error") {
8989- t.Error("Expected body to contain badge-error for critical vulnerabilities")
8787+ // Should contain vuln-strip severity boxes
8888+ if !strings.Contains(body, "vuln-box-critical") {
8989+ t.Error("Expected body to contain vuln-box-critical for critical vulnerabilities")
9090 }
9191- if !strings.Contains(body, "C:2") {
9292- t.Error("Expected body to contain 'C:2' for critical count")
9191+ if !strings.Contains(body, `data-tip="Critical">2<`) {
9292+ t.Error("Expected critical count of 2")
9393 }
9494- if !strings.Contains(body, "badge-warning") {
9595- t.Error("Expected body to contain badge-warning for high vulnerabilities")
9494+ if !strings.Contains(body, "vuln-box-high") {
9595+ t.Error("Expected body to contain vuln-box-high for high vulnerabilities")
9696 }
9797- if !strings.Contains(body, "H:5") {
9898- t.Error("Expected body to contain 'H:5' for high count")
9797+ if !strings.Contains(body, `data-tip="High">5<`) {
9898+ t.Error("Expected high count of 5")
9999 }
100100- if !strings.Contains(body, "M:10") {
101101- t.Error("Expected body to contain 'M:10' for medium count")
100100+ if !strings.Contains(body, `data-tip="Medium">10<`) {
101101+ t.Error("Expected medium count of 10")
102102 }
103103- if !strings.Contains(body, "L:3") {
104104- t.Error("Expected body to contain 'L:3' for low count")
103103+ if !strings.Contains(body, `data-tip="Low">3<`) {
104104+ t.Error("Expected low count of 3")
105105 }
106106 // Should be clickable (has openVulnDetails)
107107 if !strings.Contains(body, "openVulnDetails") {
···267267268268 body := rr.Body.String()
269269270270- if !strings.Contains(body, "C:3") {
271271- t.Error("Expected body to contain 'C:3'")
270270+ if !strings.Contains(body, `data-tip="Critical">3<`) {
271271+ t.Error("Expected critical count of 3")
272272 }
273273 // Zero-count badges should NOT appear
274274 if strings.Contains(body, "H:0") {
···346346 }
347347348348 // abc123 should have vulnerability badges
349349- if !strings.Contains(body, "C:2") {
350350- t.Error("Expected body to contain 'C:2' for abc123")
349349+ if !strings.Contains(body, `data-tip="Critical">2<`) {
350350+ t.Error("Expected critical count of 2 for abc123")
351351 }
352352 // def456 should have clean badge
353353 if !strings.Contains(body, "Clean") {
···430430 if !strings.Contains(body, `id="scan-badge-abc123"`) {
431431 t.Error("Expected OOB span for abc123")
432432 }
433433- if !strings.Contains(body, "C:1") {
434434- t.Error("Expected body to contain 'C:1'")
433433+ if !strings.Contains(body, `data-tip="Critical">1<`) {
434434+ t.Error("Expected critical count of 1")
435435 }
436436}