From 8e4a7b12f6c0626ec18968b8e03f64a13f60155f Mon Sep 17 00:00:00 2001 From: Tharindu Dharmarathna Date: Thu, 2 Jul 2026 16:32:49 +0530 Subject: [PATCH] include claude rules --- .claude/rules/authentication_authorization.md | 317 ++++++++++++++ .claude/rules/error-handling.md | 103 +++++ .claude/rules/file-access.md | 234 +++++++++++ .../rules/js-authentication-authorization.md | 397 ++++++++++++++++++ .claude/rules/js-error-handling.md | 141 +++++++ .claude/rules/js-file-access.md | 225 ++++++++++ .claude/rules/js-post-quantum-cryptography.md | 264 ++++++++++++ .claude/rules/post-quantum-cryptography.md | 248 +++++++++++ 8 files changed, 1929 insertions(+) create mode 100644 .claude/rules/authentication_authorization.md create mode 100644 .claude/rules/error-handling.md create mode 100644 .claude/rules/file-access.md create mode 100644 .claude/rules/js-authentication-authorization.md create mode 100644 .claude/rules/js-error-handling.md create mode 100644 .claude/rules/js-file-access.md create mode 100644 .claude/rules/js-post-quantum-cryptography.md create mode 100644 .claude/rules/post-quantum-cryptography.md diff --git a/.claude/rules/authentication_authorization.md b/.claude/rules/authentication_authorization.md new file mode 100644 index 0000000000..eb4c5d5e43 --- /dev/null +++ b/.claude/rules/authentication_authorization.md @@ -0,0 +1,317 @@ +# Go Authentication and Authorization Rules + +Standards and patterns for ensuring secure authentication, authorization, token verification, and multi-tenant isolation across all Go services. + +--- + +## GO-AUTH-001: Fail-Closed Authentication + +### Severity + +Critical + +### Description + +Authentication processes must always fail-closed. If an authentication check encounters an error, execution must terminate immediately, denying access to the resource. + +### Rationale + +Allowing execution to continue after an error, or failing to return early, can lead to authentication bypasses where unauthenticated requests are accidentally processed as valid. + +### Non-Compliant Code + +```go +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + err := validateToken(token) + if err != nil { + // ERROR: Logs the error but falls through to the next handler + log.Printf("auth failed: %v", err) + } + next.ServeHTTP(w, r) + }) +} + +``` + +### Compliant Code + +```go +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("Authorization") + err := validateToken(token) + if err != nil { + log.Printf("auth failed: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + "message": "Invalid or expired credentials.", + }) + return // CORRECT: Execution terminates immediately + } + next.ServeHTTP(w, r) + }) +} + +``` +--- + +## GO-AUTH-002: Strict Asymmetric JWT Verification + +### Severity + +Critical + +### Description + +JWT signature verification must strictly enforce asymmetric algorithms (`RSA` or `EdDSA`). Symmetric algorithms (`HS256`, `HS384`, `HS512`) and the `none` algorithm must be explicitly rejected during signature validation. + +### Rationale + +If symmetric key algorithms are accepted by an asymmetric verification sequence, an attacker can sign a malicious JWT using the public key as a HMAC secret key, completely bypassing token signature verification. + +### Non-Compliant Code + +```go +// ERROR: Accepts any algorithm provided in the token header +token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + return publicKey, nil +}) + +``` + +### Compliant Code + +```go +// CORRECT: Explicitly checks and restricts allowed signing methods +token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + switch token.Method.(type) { + case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS, *jwt.SigningMethodEd25519: + return publicKey, nil + default: + return nil, fmt.Errorf("unexpected or forbidden signing method: %v", token.Header["alg"]) + } +}) + +``` + +--- + +## GO-AUTH-003: Secure Token Handling and Logging + +### Severity + +Medium + +### Description + +Raw authentication tokens, credentials, or secrets must never be written to application logs on failure. If part of a token is required for correlation or debugging, it must be masked to only show identifiers. + +### Rationale + +Logging raw tokens exposes sensitive credentials to log management platforms, increasing the surface area for account takeovers if log data is leaked or compromised. + +### Non-Compliant Code + +```go +// ERROR: Logging the full authentication token in plain text +if err != nil { + log.Printf("failed to parse token %s: %v", r.Header.Get("Authorization"), err) +} + +``` + +### Compliant Code + +```go +// CORRECT: Masking the token before logging +func maskToken(token string) string { + if len(token) <= 8 { + return "[MASKED]" + } + return token[:4] + "..." + token[len(token)-4:] +} + +if err != nil { + masked := maskToken(r.Header.Get("Authorization")) + log.Printf("failed to parse token %s: %v", masked, err) +} + +``` + +--- + +## GO-AUTH-004: Routing and Path Traversal Protection + +### Severity + +High + +### Description + +Authentication and authorization layers must protect against routing anomalies. Applications must use clean paths to evaluate middleware execution, preventing attackers from bypassing auth controls via path traversal sequence tricks (`..`). + +### Rationale + +Attackers manipulate URLs (e.g., `//auth/../private`) to confuse naive path matching algorithms, tricking security layers into treating a restricted path as an unauthenticated/public path. + +### Non-Compliant Code + +```go +// ERROR: String prefix matching on raw path is vulnerable to bypasses +if strings.HasPrefix(r.URL.Path, "/public/") { + next.ServeHTTP(w, r) // Bypasses auth checks + return +} + +``` + +### Compliant Code + +```go +// CORRECT: Path sanitization and structured router group scoping +func NewRouter() http.Handler { + mux := http.NewServeMux() // Go 1.22+ handles routing cleaning automatically + + // Explicit public routes + mux.HandleFunc("GET /public/", publicHandler) + + // Explicitly protected sub-router or structured middleware chaining + protectedMux := http.NewServeMux() + protectedMux.HandleFunc("GET /private/", privateHandler) + + mux.Handle("/private/", AuthMiddleware(protectedMux)) + return mux +} + +``` + +--- + +## GO-AUTH-005: Multi-Tenant Isolation (Anti-Privilege Escalation) + +### Severity + +Critical + +### Description + +Data queries and state mutations must enforce structural boundaries using multi-tenant context verification. User actions must be strictly constrained to their authorized `organization_id` or `tenant_id` pulled securely from the parsed token claims. + +### Rationale + +Relying strictly on an identifier provided directly in the request body or URL path parameters allows users to perform Cross-Organization Privilege Escalation by swapping resource IDs. + +### Non-Compliant Code + +```go +// ERROR: Trusting the organization ID from input without matching JWT identity +func DeleteUserHandler(w http.ResponseWriter, r *http.Request) { + targetOrgID := r.URL.Query().Get("org_id") + userID := r.URL.Query().Get("user_id") + + db.Where("id = ? AND organization_id = ?", userID, targetOrgID).Delete(&User{}) +} + +``` + +### Compliant Code + +```go +// CORRECT: Forcing database execution context to rely on JWT claims context +func DeleteUserHandler(w http.ResponseWriter, r *http.Request) { + // Extracted safely inside AuthMiddleware and injected into Request Context + ctxOrgID, ok := r.Context().Value(OrgIDContextKey).(string) + if !ok || ctxOrgID == "" { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + userID := r.URL.Query().Get("user_id") + + // Query is strictly sandboxed inside the tenant domain checked by security token + err := db.Where("id = ? AND organization_id = ?", userID, ctxOrgID).Delete(&User{}).Error + if err != nil { + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } +} + +``` + +--- + +## GO-AUTH-006: HTTP Method Case-Insensitive Normalization + +### Severity + +High + +### Description + +HTTP method strings sourced from user input — API definitions, CRD specs, policy configurations, and access control exception lists — must be normalized to uppercase with `strings.ToUpper()` at the earliest point of extraction, before any comparison, map key construction, or route configuration. + +### Rationale + +RFC 7231 defines HTTP methods as case-sensitive and standard methods (`GET`, `POST`, etc.) are uppercase. However, user-supplied method values (e.g., from Kubernetes CRD fields, OpenAPI spec submissions, or policy attachments) may arrive in any case. Two classes of exploit are possible when normalization is missing: + +1. **Access control bypass:** Security policy registries (deny lists, scope maps, exception sets) are built from one code path while incoming request methods come from another. If one path stores `"get"` and the other stores `"GET"`, map key lookups (`key.method != policyMethod`) silently fail to match, causing deny rules to never fire. +2. **Envoy route mismatch:** Gateway route translators embed the method string directly in Envoy's `Exact:` header matcher for the `:method` pseudo-header. A lowercase `"get"` produces a route that matches nothing (all real HTTP clients send `"GET"`), creating a silent routing failure that can be exploited to reach backends without going through the intended policy chain. + +### Non-Compliant Code + +```go +// ERROR: Raw method from CRD fed into route key and Envoy matcher — case not normalized +for _, op := range apiData.Operations { + routeKey := GenerateRouteName(string(op.Method), context, version, op.Path, vhost) + rdc.Routes[routeKey] = &models.Route{ + Method: string(op.Method), // Lowercase "get" → Envoy Exact match never fires + } +} + +// ERROR: Exception methods from user spec stored without normalization +for i, m := range ex.Methods { + methods[i] = string(m) // "get" != "GET" in deny-list map key comparison +} + +// ERROR: Policy methods expanded without normalization +for i, m := range methods { + expanded[i] = string(m) // Case-sensitive comparison against WILDCARD_HTTP_METHODS fails +} +``` + +### Compliant Code + +```go +// CORRECT: Normalize at the point of extraction from user-supplied data +for _, op := range apiData.Operations { + opMethod := strings.ToUpper(string(op.Method)) // Normalize once, use everywhere + routeKey := GenerateRouteName(opMethod, context, version, op.Path, vhost) + rdc.Routes[routeKey] = &models.Route{ + Method: opMethod, // Always uppercase — Envoy Exact match fires correctly + } +} + +// CORRECT: Exception methods normalized before building deny-list keys +for i, m := range ex.Methods { + methods[i] = strings.ToUpper(string(m)) // "get" → "GET", matches registry keys +} + +// CORRECT: Policy methods normalized on expansion +for i, m := range methods { + expanded[i] = strings.ToUpper(string(m)) // Consistent with WILDCARD_HTTP_METHODS constants +} + +// CORRECT: RDC route method normalized before Envoy header matcher construction +method := strings.ToUpper(rdcRoute.Method) +// ... method is then used in: HeaderMatcher_StringMatch { Exact: method } +``` + +> **Verification Checklist before outputting code:** +> * Is every `string(op.Method)`, `string(m)`, or equivalent extraction from a user-supplied typed method value wrapped in `strings.ToUpper()`? (If no, add normalization at the extraction site.) +> * Are route keys generated for lookup and creation using the same normalized method string? (Inconsistent case between build and lookup sites causes silent key misses.) +> * Does any Envoy `Exact:` header matcher for `:method` receive a value that may be lowercase? (If yes, apply `strings.ToUpper()` before passing to the matcher.) +> * Are access control deny-list or scope-registry map keys built from normalized strings? (A mixed-case key will silently bypass all deny/allow lookups keyed on the uppercase constant.) \ No newline at end of file diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md new file mode 100644 index 0000000000..c5d94089e7 --- /dev/null +++ b/.claude/rules/error-handling.md @@ -0,0 +1,103 @@ +# Rule: Go Error & Payload Validation Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing Go (`.go`) code responsible for handling HTTP/gRPC responses, error generation, middleware, and payload construction. The goal is to enforce strict security boundaries, prevent information disclosure, and mitigate user enumeration attacks. + +--- + +## Directives + +### 1. Data Leakage & Internal Exposure + +* **Zero Internal Details:** Never expose raw database errors (e.g., `sql.ErrNoRows`), stack traces, internal microservice names, network topologies, or file system paths to the client. +* **Sanitization:** Wrap internal errors using standard Go idiomatic patterns (`fmt.Errorf("something went wrong: %w", err)`) for internal logging, but map them to sterile, user-facing error objects before JSON/XML marshaling. + +### 2. Vendor Header Abstraction + +* **No Leaky Headers:** Ensure no custom HTTP headers specific to cloud vendors, API gateways, or third-party tools (e.g., `X-Amz-*`, `X-Cloudflare-*`, `Cf-Ray`, `X-Vercel-*`) are forwarded or generated in client-facing error responses. +* **Standardization:** Use only standard HTTP headers or predefined, platform-agnostic internal custom headers (e.g., `X-Request-ID`). + +### 3. Dynamic Value Generation & Source Obfuscation + +* **No Source Identifiers:** Dynamically generated strings (such as tracking IDs, error tokens, or correlation keys) must not contain hardcoded substrings that identify the source file, function name, environment name, or developer aliases. +* **Implementation:** Use high-entropy random generators (e.g., UUIDv4, crypto/rand, or ULID) for tokens rather than concatenating strings like `"ERR_USER_SERVICE_LINE_42_" + timestamp`. + +### 4. Unified Authentication Failures + +* **Constant-Time/Constant-Response Auth:** Do not differentiate client-facing responses for authentication failures. Whether a token/credential is invalid, expired, missing, or revoked, the API must return the exact same payload and HTTP status code. +* **Allowed Status:** `HTTP 401 Unauthorized` +* **Standard Payload:** + ```json + { + "error": "unauthorized", + "message": "Invalid or expired credentials." + } + ``` + +* *Note:* Internal logs can log the specific reason (e.g., "token expired") for debugging, but the HTTP response writer must remain completely generic to prevent credential probing. + +--- + +## Code Examples for Enforcement + +### ❌ Anti-Pattern (What to Reject) + +```go +// BAD: Leaks internal DB state, uses vendor headers, reveals specific auth failure, and hardcodes source tags. +func HandleLogin(w http.ResponseWriter, r *http.Request) { + err := authenticateUser(r) + if err == ErrTokenExpired { + w.Header().Set("X-AWS-Gateway-Error", "true") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "code": "AUTH_FAILED_EXPIRED_TOKEN_MAIN_GO_L82", + "message": "Your token has expired. Please log in again.", + }) + return + } + if err == sql.ErrNoRows { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } +} + +``` + +### Best Practice (What to Generate) + +```go +// GOOD: Sterile payloads, generic auth responses, clean headers, and anonymous tracking IDs. +func HandleLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + err := authenticateUser(r) + + if err != nil { + // Log the specific detailed reason internally for developers + logger.LogInternalError(ctx, "Authentication failed deeply: %v", err) + + // Generate a pure, anonymous correlation ID + trackID := uuid.New().String() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + "message": "Invalid or expired credentials.", + "tracking_id": trackID, + }) + return + } +} + +``` + +--- + +> **Verification Checklist before outputting code:** +> * Does this error message reveal *why* the auth failed to the client? (If yes, make it generic). +> * Does the generated ID contain hardcoded source markers? (If yes, use a random crypto string/UUID). +> * Are there any `X-Amz` or similar infrastructure headers bleeding through? (If yes, strip them). +> +> \ No newline at end of file diff --git a/.claude/rules/file-access.md b/.claude/rules/file-access.md new file mode 100644 index 0000000000..90d21c183b --- /dev/null +++ b/.claude/rules/file-access.md @@ -0,0 +1,234 @@ +# Rule: Go File Access Security Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing Go (`.go`) code that handles file reads, file uploads, archive extraction, database storage of file metadata, or any operation that touches the filesystem or processes byte streams from user-provided input. The goal is to prevent path traversal attacks, information disclosure via filesystem access, and resource exhaustion via unbounded stream consumption. + +--- + +## Directives + +### 1. Path Traversal Prevention (Clean Path Validation) + +* **Enforce Containment:** Any file read operation must resolve the final absolute path and assert it is strictly within the intended root directory. A request for `../../etc/passwd` must be rejected before `os.Open` is ever called. +* **Use `filepath.Clean` + prefix check:** Clean the joined path and verify it has the expected directory prefix, with a path separator suffix to prevent partial prefix matches (e.g., `/allowed/dir` matching `/allowed/directory-other`). +* **Reject Null Bytes and Encoded Traversals:** Strip or reject inputs containing `\x00`, `%2e%2e`, `%2f`, or any URL-encoded traversal sequences before path resolution. + +### 2. Database / Storage — Filename Only, No Paths + +* **Strip Directory Component:** Before persisting any file reference to a database, cache, or configuration store, call `filepath.Base()` to discard any directory prefix supplied by the user. +* **Re-derive Paths at Runtime:** The full access path must be constructed server-side by joining a server-controlled root with the stored bare filename. The stored value must never be used as-is to open a file. + +### 3. In-Memory File Processing (No Intermediate Filesystem Writes) + +* **Prefer `bytes.Buffer` / `io.Reader` Pipelines:** When parsing, transforming, or hashing uploaded content in Go, pipe data through in-memory readers rather than writing to `os.TempFile` or a persistent path. +* **`os.CreateTemp` is Last Resort:** Only write to disk if a third-party library requires a file path. Immediately `defer os.Remove(tmp.Name())` and keep the file in a tightly scoped directory controlled by the application. +* **No User-Controlled Filenames on Disk:** Never derive a temp file path from user input. Use OS-generated names (e.g., `os.CreateTemp("", "upload-*")`). + +### 4. ZIP / Archive File Handling — Specific-File Restriction + +* **Allowlist Entry Paths:** When processing a ZIP or tar archive, validate every entry's `Name` field against an explicit allowlist or regex of permitted relative paths. Reject any entry whose cleaned path escapes the destination root (the "zip slip" attack). +* **Single-File Extraction:** If the API is designed to read one specific file from an archive, locate that entry by exact name and discard all others without extracting them. +* **Reject Absolute Paths and Traversals in Entry Names:** Any archive entry whose name starts with `/`, contains `..`, or resolves outside the target directory must be rejected immediately and an error returned — partial extraction must not occur. +* **Limit Entry Count and Compressed Ratio:** Apply a maximum entry count and a decompression ratio guard to mitigate zip bomb attacks. + +### 5. Configurable Input Stream Size Limits + +* **Never Read Unbounded Streams:** Wrap every `io.Reader` that originates from user or network input with `io.LimitReader` before reading into memory. +* **Externalize Limits to Configuration:** The byte ceiling must come from application configuration (environment variable, config file) — never hardcoded. Provide a safe default that is used when the configuration key is absent. +* **Return a Meaningful Error on Overflow:** If the limit is hit, return `HTTP 413 Request Entity Too Large` with a generic message. Do not expose the configured limit value in the error response. + +--- + +## Code Examples for Enforcement + +### ❌ Anti-Pattern (What to Reject) + +```go +// BAD: Path traversal, storing full path, unbounded read, zip slip, hardcoded limit. +func ServeUserFile(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("file") + path := "/var/app/uploads/" + name // No path cleaning — traversal possible + + data, _ := os.ReadFile(path) // Reads /etc/passwd if name = "../../etc/passwd" + w.Write(data) +} + +func SaveFileMeta(db *sql.DB, uploadPath string) { + db.Exec("INSERT INTO files (path) VALUES (?)", uploadPath) // Stores full path +} + +func ProcessUpload(r *http.Request) { + body, _ := io.ReadAll(r.Body) // Unbounded — susceptible to OOM + processBytes(body) +} + +func ExtractZip(src, destDir string) { + zr, _ := zip.OpenReader(src) + for _, f := range zr.File { + outPath := filepath.Join(destDir, f.Name) // Zip slip: f.Name may be "../../evil" + os.MkdirAll(filepath.Dir(outPath), 0755) + rc, _ := f.Open() + out, _ := os.Create(outPath) + io.Copy(out, rc) // No decompression ratio guard + } +} +``` + +### Best Practice (What to Generate) + +```go +// config.go — stream limits come from application configuration +type FileConfig struct { + UploadRootDir string + MaxUploadBytes int64 + MaxZipEntries int + MaxZipRatio float64 +} + +func LoadFileConfig() FileConfig { + maxBytes, _ := strconv.ParseInt(os.Getenv("MAX_UPLOAD_BYTES"), 10, 64) + if maxBytes <= 0 { + maxBytes = 10 << 20 // 10 MiB safe default + } + maxEntries, _ := strconv.Atoi(os.Getenv("MAX_ZIP_ENTRIES")) + if maxEntries <= 0 { + maxEntries = 500 + } + maxRatio, _ := strconv.ParseFloat(os.Getenv("MAX_ZIP_RATIO"), 64) + if maxRatio <= 0 { + maxRatio = 20.0 + } + return FileConfig{ + UploadRootDir: os.Getenv("UPLOAD_ROOT_DIR"), + MaxUploadBytes: maxBytes, + MaxZipEntries: maxEntries, + MaxZipRatio: maxRatio, + } +} + +// GOOD: Path traversal prevention — containment check. +func safeJoin(root, userInput string) (string, error) { + // Strip any null bytes or encoded separators before joining + cleaned := filepath.Clean(filepath.Join(root, filepath.FromSlash(path.Clean("/"+userInput)))) + // root must end with separator for prefix check to be exact + rootWithSep := filepath.Clean(root) + string(filepath.Separator) + if !strings.HasPrefix(cleaned, rootWithSep) { + return "", fmt.Errorf("path escapes root directory") + } + return cleaned, nil +} + +func ServeUserFile(cfg FileConfig, w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("file") + safePath, err := safeJoin(cfg.UploadRootDir, name) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + http.ServeFile(w, r, safePath) +} + +// GOOD: Store only the bare filename, never the full path. +func SaveFileMeta(db *sql.DB, uploadPath string) error { + bareFilename := filepath.Base(uploadPath) + _, err := db.Exec("INSERT INTO files (name) VALUES (?)", bareFilename) + return err +} + +// GOOD: Derive the real path server-side from the stored bare filename. +func OpenStoredFile(cfg FileConfig, db *sql.DB, fileID int) ([]byte, error) { + var name string + if err := db.QueryRow("SELECT name FROM files WHERE id = ?", fileID).Scan(&name); err != nil { + return nil, err + } + safePath, err := safeJoin(cfg.UploadRootDir, name) + if err != nil { + return nil, fmt.Errorf("invalid stored filename") + } + return os.ReadFile(safePath) +} + +// GOOD: In-memory processing — no intermediate disk write. +func ProcessUpload(cfg FileConfig, r *http.Request) ([]byte, error) { + limited := io.LimitReader(r.Body, cfg.MaxUploadBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(data)) > cfg.MaxUploadBytes { + return nil, fmt.Errorf("payload exceeds maximum allowed size") + } + // Work on data entirely in memory; no os.WriteFile / os.TempFile here. + return processBytes(data), nil +} + +// GOOD: ZIP extraction with zip-slip protection, entry limit, and decompression ratio guard. +var ErrZipSlip = fmt.Errorf("archive entry escapes destination directory") + +func ExtractSingleEntry(cfg FileConfig, zipData []byte, entryName, destDir string) error { + zr, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return err + } + + if len(zr.File) > cfg.MaxZipEntries { + return fmt.Errorf("archive exceeds maximum entry count") + } + + for _, f := range zr.File { + // Reject absolute paths and traversal sequences in entry names + cleanedEntry := path.Clean("/" + f.Name) + if strings.Contains(f.Name, "..") || filepath.IsAbs(f.Name) { + return ErrZipSlip + } + + // Strip the leading slash added by path.Clean so comparison is against a relative path + relEntry := strings.TrimPrefix(cleanedEntry, "/") + + // Only extract the one requested entry — match by full normalized relative path, + // not just basename, to avoid picking the wrong entry when names collide across dirs. + if relEntry != path.Clean(entryName) { + continue + } + + destPath, err := safeJoin(destDir, relEntry) + if err != nil { + return ErrZipSlip + } + + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + // Decompression ratio guard: take the tighter of ratio-based limit and MaxUploadBytes. + // Cap (not widen) at MaxUploadBytes so small compressed entries cannot expand arbitrarily. + maxDecompressed := int64(float64(f.CompressedSize64) * cfg.MaxZipRatio) + if maxDecompressed > cfg.MaxUploadBytes { + maxDecompressed = cfg.MaxUploadBytes + } + limited := io.LimitReader(rc, maxDecompressed+1) + data, err := io.ReadAll(limited) + if err != nil { + return err + } + if int64(len(data)) > maxDecompressed { + return fmt.Errorf("decompressed entry exceeds allowed ratio") + } + + return os.WriteFile(destPath, data, 0600) + } + return fmt.Errorf("requested entry not found in archive") +} +``` + +--- + +> **Verification Checklist before outputting code:** +> * Is every file path resolved with `filepath.Clean` and checked against the root with a separator-suffixed prefix? (If no, add `safeJoin`). +> * Is only the bare filename (`filepath.Base`) stored in the database or any external storage? (If the full path is stored, strip it). +> * Is file processing done via `io.Reader` pipelines without intermediate `os.WriteFile` / `os.TempFile`? (If disk writes exist, remove them unless a third-party library strictly requires a path). +> * Are all archive entries validated against the destination root before extraction? (If not, apply `safeJoin` on every entry). +> * Is every inbound `io.Reader` wrapped in `io.LimitReader` with a limit sourced from configuration? (If hardcoded or absent, externalize to config with a safe default). diff --git a/.claude/rules/js-authentication-authorization.md b/.claude/rules/js-authentication-authorization.md new file mode 100644 index 0000000000..78839fbade --- /dev/null +++ b/.claude/rules/js-authentication-authorization.md @@ -0,0 +1,397 @@ +# Rule: JavaScript (Express/Passport) Authentication and Authorization Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) code in `portals/developer-portal` related to authentication middleware, Passport.js strategies, JWT verification (`jose`, `jsonwebtoken`), session handling, route protection, and multi-tenant data access via Sequelize. This is the JavaScript counterpart to `authentication_authorization.md` (Go). + +--- + +## JS-AUTH-001: Fail-Closed Authentication + +### Severity + +Critical + +### Description + +Every authentication middleware and Passport strategy must terminate the request immediately on failure. Missing `return` before `next()` in error branches, or calling `next()` without error propagation, creates silent bypasses where unauthenticated requests reach protected handlers. + +### Rationale + +Express middleware chains continue to the next handler unless execution is explicitly halted. An omitted `return` after a `res.status(401)` call in async code, or after `next()` in a Passport `done(null, false)` path without a `failureRedirect`/`failWithError`, allows the request to fall through to protected route handlers. + +### Non-Compliant Code + +```js +// BAD: Missing return — execution falls through after 401 +function authMiddleware(req, res, next) { + const token = req.headers['authorization']; + verifyToken(token, (err) => { + if (err) { + res.status(401).json({ error: 'unauthorized' }); // No return — falls through + } + next(); // Executes even when err was truthy + }); +} + +// BAD: Passport strategy calls next() after done(null, false) +passport.use(new CustomStrategy(async (req, done) => { + const user = await User.findOne({ where: { email: req.body.email } }); + if (!user) done(null, false); // Missing return — may continue + done(null, user); +})); +``` + +### Compliant Code + +```js +// GOOD: Explicit return halts execution on every failure path +function authMiddleware(req, res, next) { + const token = req.headers['authorization']; + verifyToken(token, (err) => { + if (err) { + return res.status(401).json({ // return prevents fall-through + error: 'unauthorized', + message: 'Invalid or expired credentials.', + }); + } + next(); + }); +} + +// GOOD: Passport strategy uses return on every done() path +passport.use(new CustomStrategy(async (req, done) => { + try { + const user = await User.findOne({ where: { email: req.body.email } }); + if (!user) return done(null, false); // return terminates this branch + return done(null, user); + } catch (err) { + return done(err); + } +})); +``` + +--- + +## JS-AUTH-002: Strict Asymmetric JWT Verification + +### Severity + +Critical + +### Description + +JWT verification using `jose` or `jsonwebtoken` must explicitly restrict the allowed signing algorithm to an asymmetric algorithm (`RS256`, `RS384`, `RS512`, `PS256`, `EdDSA`). Symmetric algorithms (`HS256`, `HS384`, `HS512`) and the `none` algorithm must be rejected. Never accept the algorithm from the token header without an explicit allowlist. + +### Rationale + +An attacker can forge a JWT signed with the server's RSA public key using HMAC-SHA256 (treating the public key as the HMAC secret) if symmetric algorithms are not rejected, completely bypassing signature verification. + +### Non-Compliant Code + +```js +// BAD (jsonwebtoken): No algorithm restriction — accepts whatever the token header claims +const decoded = jwt.verify(tokenStr, publicKey); + +// BAD (jose): Missing algorithms option — defaults to accepting anything +const { payload } = await jwtVerify(tokenStr, publicKey); + +// BAD: Accepts 'none' algorithm — signs nothing, verifies nothing +const decoded = jwt.verify(tokenStr, '', { algorithms: ['none'] }); +``` + +### Compliant Code + +```js +// GOOD (jose — preferred, already in project dependencies) +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI)); + +async function verifyJwt(tokenStr) { + const { payload } = await jwtVerify(tokenStr, JWKS, { + algorithms: ['RS256', 'RS384', 'RS512', 'PS256'], // Explicit asymmetric-only allowlist + issuer: process.env.JWT_ISSUER, + audience: process.env.JWT_AUDIENCE, + }); + return payload; +} + +// GOOD (jsonwebtoken — if used in legacy code paths) +const decoded = jwt.verify(tokenStr, publicKey, { + algorithms: ['RS256', 'RS384', 'RS512'], // Explicit allowlist; 'none' is never present +}); +``` + +--- + +## JS-AUTH-003: Secure Token Handling and Logging + +### Severity + +Medium + +### Description + +Raw JWT strings, passwords, session tokens, or OAuth client secrets must never appear in Winston log output. When a token identifier is needed for log correlation, mask it to show only a short prefix and suffix. + +### Rationale + +Log aggregation systems (e.g., ELK, Loki, Application Insights — which is configured in this project) persist log lines. A full token in a log line becomes an attack surface if logs are leaked, and Application Insights forwards them to external telemetry. + +### Non-Compliant Code + +```js +// BAD: Full token in log output, forwarded to Application Insights +logger.warn(`Failed to verify token: ${req.headers['authorization']}`); + +// BAD: Logging the raw password on failure +logger.error(`Login failed for ${req.body.email} with password ${req.body.password}`); +``` + +### Compliant Code + +```js +// utils/maskSensitive.js +function maskToken(token) { + if (!token || token.length <= 8) return '[MASKED]'; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +function maskPassword(_password) { + return '[REDACTED]'; +} + +// routes/auth.js — GOOD: Only masked values reach the logger +logger.warn('Token verification failed', { + token: maskToken(req.headers['authorization']), + reason: err.code, // jose error codes like 'ERR_JWT_EXPIRED' are safe to log +}); + +logger.error('Login failed', { + email: req.body.email, + password: maskPassword(req.body.password), // Never log the actual password +}); +``` + +--- + +## JS-AUTH-004: Routing and Path Traversal Protection + +### Severity + +High + +### Description + +Route path matching for authentication bypass decisions must operate on normalized, decoded paths. Raw `req.url` or `req.path` values can contain URL-encoded traversal sequences (`%2F`, `%2E%2E`) that confuse naive `startsWith` prefix guards. + +### Rationale + +A request to `//public/%2e%2e/private/secret` can bypass a guard checking `req.path.startsWith('/public/')` on some middleware stacks because Express normalizes `req.path` but `req.url` retains the encoded form. + +### Non-Compliant Code + +```js +// BAD: Raw string prefix check on req.url — bypassable with encoded traversals +app.use((req, res, next) => { + if (req.url.startsWith('/public/')) { + return next(); // Skips auth — bypassable via /public/%2e%2e/admin + } + authMiddleware(req, res, next); +}); +``` + +### Compliant Code + +```js +// GOOD: Use Express router groups to scope middleware structurally, +// not string matching on raw URLs. +const publicRouter = express.Router(); +publicRouter.get('/health', healthHandler); +publicRouter.get('/docs', docsHandler); +app.use('/public', publicRouter); // Express normalizes path before matching + +const protectedRouter = express.Router(); +protectedRouter.use(authMiddleware); // Applied to ALL routes in this router +protectedRouter.get('/profile', profileHandler); +protectedRouter.delete('/users/:id', deleteUserHandler); +app.use('/api', protectedRouter); + +// NOTE: Do NOT apply a second decodeURIComponent() pass to req.path — Express already +// decodes it. A double-decode can be exploited via double-encoded traversal sequences. +// Prefer router group scoping (above) as the only safe pattern. +``` + +--- + +## JS-AUTH-005: Multi-Tenant Isolation (Anti-Privilege Escalation) + +### Severity + +Critical + +### Description + +Sequelize queries that access or mutate tenant-scoped data must include the `organizationId` (or equivalent tenant scope) sourced from the verified JWT claims stored in `req.user`, never from user-supplied request parameters, body fields, or query strings. + +### Rationale + +A user can craft a request with a different `org_id` in the query string or body to read or delete another tenant's data (Insecure Direct Object Reference). The only trustworthy tenant identifier is the one extracted from the signed JWT by `authMiddleware`. + +### Non-Compliant Code + +```js +// BAD: orgId is taken from user-controlled query parameter +async function deleteUserHandler(req, res) { + const orgId = req.query.org_id; // Attacker-controlled + const userId = req.params.userId; + + await User.destroy({ where: { id: userId, organizationId: orgId } }); + res.status(204).send(); +} +``` + +### Compliant Code + +```js +// middleware/auth.js — GOOD: authMiddleware injects verified claims into req.user +async function authMiddleware(req, res, next) { + try { + const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, ''); + const { payload } = await jwtVerify(token, JWKS, { + algorithms: ['RS256', 'RS384', 'RS512'], + issuer: process.env.JWT_ISSUER, + }); + req.user = { + id: payload.sub, + organizationId: payload.org_id, // From JWT — not from the request + roles: payload.roles ?? [], + }; + next(); + } catch (err) { + logger.warn('Auth failed', { reason: err.code }); + return res.status(401).json({ + error: 'unauthorized', + message: 'Invalid or expired credentials.', + }); + } +} + +// routes/users.js — GOOD: tenant scope is always from req.user +async function deleteUserHandler(req, res, next) { + try { + const { organizationId } = req.user; // From verified JWT + if (!organizationId) { + return res.status(403).json({ error: 'forbidden' }); + } + + const userId = req.params.userId; + + // Query is strictly sandboxed to the authenticated tenant + const deleted = await User.destroy({ + where: { id: userId, organizationId }, + }); + + if (!deleted) return res.status(404).json({ error: 'not_found' }); + res.status(204).send(); + } catch (err) { + next(err); + } +} +``` + +--- + +## JS-AUTH-006: HTTP Method Case-Insensitive Normalization + +### Severity + +High + +### Description + +HTTP method strings sourced from user input — API definitions, OpenAPI spec submissions, policy configurations, and access control exception lists — must be normalized to uppercase with `.toUpperCase()` at the earliest point of extraction, before any comparison, object key construction, or route/policy configuration. + +### Rationale + +RFC 7231 defines HTTP methods as case-sensitive and standard methods (`GET`, `POST`, etc.) are uppercase. However, user-supplied method values (e.g., from OpenAPI spec path keys, API policy bodies, or exception lists) may arrive in any case. Two classes of exploit are possible when normalization is missing: + +1. **Access control bypass:** Security registries (deny lists, scope maps, exception sets) are built from one code path while incoming request methods come from another. If one path stores `"get"` and the other stores `"GET"`, object property lookups and `Set.has()` calls silently miss, causing deny rules to never fire. +2. **Route and policy mismatch:** Express router method matching (`.get()`, `.post()`) and OpenAPI operation lookups use lowercase keys by convention. If a user-supplied method is compared directly without normalization, the lookup silently returns `undefined`, causing the operation's policy or schema to be skipped entirely. + +The JavaScript counterpart of the Go rule (GO-AUTH-006) — same exploit class, same fix, different syntax. + +### Non-Compliant Code + +```js +// BAD: OpenAPI path method keys from user spec compared without normalization +const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']); +const operations = Object.keys(pathItem) + .filter(method => HTTP_METHODS.has(method)); // 'GET' silently excluded from Set + +// BAD: Exception/deny-list method stored without normalization +const deniedMethods = exceptionList.map(ex => ex.method); // May be 'get', 'Get', 'GET' +if (deniedMethods.includes(incomingMethod)) { /* may silently miss */ } + +// BAD: req.method checked with mixed-case string literal +if (req.method === 'options') { // Express sets req.method to uppercase — always misses + return res.sendStatus(204); +} + +// BAD: Method from user-submitted config compared case-sensitively +if (operationMethod === 'POST') { /* fails if user submitted 'post' */ } +``` + +### Compliant Code + +```js +// GOOD: Normalize OpenAPI path method keys at extraction — filter then uppercase +const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']); +const operations = Object.keys(pathItem) + .filter(method => HTTP_METHODS.has(method.toLowerCase())) // Accept any case on input + .map(method => ({ + method: method.toUpperCase(), // Store/compare as uppercase from here on + schema: pathItem[method], + })); + +// GOOD: Deny-list normalized at build time +const deniedMethods = new Set( + exceptionList.map(ex => ex.method.toUpperCase()) // Normalize once on ingestion +); +if (deniedMethods.has(incomingMethod.toUpperCase())) { /* always matches */ } + +// GOOD: req.method from Express is always uppercase — compare with uppercase literals +if (req.method === 'OPTIONS') { // Correct — Express guarantees uppercase + return res.sendStatus(204); +} + +// GOOD: Normalize user-submitted method at ingestion before any comparison or storage +function normalizeMethod(raw) { + const upper = raw.toUpperCase(); + const VALID = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); + if (!VALID.has(upper)) { + throw Object.assign(new Error(`Invalid HTTP method: ${raw}`), { statusCode: 400 }); + } + return upper; +} + +// In route/policy builders: +const method = normalizeMethod(userInput.method); // Normalized; safe for all downstream use +``` + +> **Verification Checklist before outputting code:** +> * Is every method string from a user-submitted body, OpenAPI spec key, or policy config wrapped in `.toUpperCase()` at the point of extraction? (If no, add normalization there.) +> * Does `req.method` appear in comparisons against lowercase string literals (e.g., `'options'`, `'get'`)? (Express always uppercases `req.method` — the literal must be uppercase too.) +> * Are deny-list or scope-map lookups using a `Set` or object keyed on normalized (uppercase) methods? (A mixed-case key silently misses every lookup.) +> * Does any OpenAPI path-method key filtering use `.toLowerCase()` for the `Set.has()` check and `.toUpperCase()` for the stored/compared value? (Both are needed — filter input in lowercase, store output in uppercase.) + +--- + +> **Verification Checklist before outputting code:** +> * Does every authentication error branch have a `return` before `next()` or `res.status()`? (If no, add `return`). +> * Does JWT verification include an explicit `algorithms` array containing only asymmetric algorithms? (If no, add the allowlist). +> * Is any raw token, password, or secret passed directly to `logger.*`? (If yes, apply `maskToken` / `maskPassword`). +> * Is route protection applied via Express router group scoping rather than raw `req.url` string matching? (If not, restructure to router groups). +> * Does every Sequelize query for tenant-scoped data use `organizationId` from `req.user`, not from `req.query`/`req.body`/`req.params`? (If not, source it from `req.user`). +> * Is every HTTP method string from user input normalized with `.toUpperCase()` before comparison, map/Set lookup, or policy registration? (If no, add normalization at the ingestion point.) diff --git a/.claude/rules/js-error-handling.md b/.claude/rules/js-error-handling.md new file mode 100644 index 0000000000..596bfd206d --- /dev/null +++ b/.claude/rules/js-error-handling.md @@ -0,0 +1,141 @@ +# Rule: JavaScript (Express) Error & Payload Validation Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) code in `portals/developer-portal` responsible for Express route handlers, error middleware, response construction, and request processing. The goal is to enforce strict security boundaries, prevent information disclosure, and mitigate user enumeration attacks. This is the JavaScript counterpart to `error-handling.md` (Go). + +--- + +## Directives + +### 1. Data Leakage & Internal Exposure + +* **Zero Internal Details:** Never forward raw ORM/database errors (e.g., Sequelize `ValidationError`, `DatabaseError`, `UniqueConstraintError`), stack traces, internal service names, or file system paths to the client response. +* **Centralised Error Middleware:** All unhandled errors must flow through a single Express error-handling middleware `(err, req, res, next)` registered last. Route handlers must call `next(err)` — they must never serialise the raw `Error` object directly. +* **Strip `err.stack` and `err.message`:** Before sending any error response, explicitly omit `err.stack`, `err.message`, and `err.sql` (Sequelize) from the serialised payload. + +### 2. Vendor Header Abstraction + +* **No Leaky Headers:** Never forward or set response headers that disclose infrastructure details — including `X-Amz-*`, `X-Powered-By`, `Server`, `X-Vercel-*`, or `Cf-Ray`. +* **Disable Express Default:** Set `app.disable('x-powered-by')` in application bootstrap so Express does not advertise itself. +* **Standardisation:** Respond only with standard HTTP headers or the platform-specific `X-Request-ID` correlation header. + +### 3. Dynamic Value Generation & Source Obfuscation + +* **No Source Identifiers:** Tracking IDs, correlation tokens, and error tokens must be generated with `crypto.randomUUID()` (Node.js built-in, no extra dependency) or the `uuid` package already present in the project. Never concatenate file names, line numbers, function names, or environment labels into these values. +* **Avoid `Date.now()` Alone:** A raw timestamp is not a tracking ID — it is guessable and carries a time leak. Always combine with a UUID or use UUID alone. + +### 4. Unified Authentication Failures + +* **Constant-Response Auth:** All authentication failures — wrong password, expired token, missing token, revoked token, invalid signature — must return the same HTTP status and payload. Do not branch on the error type in the response path. +* **Allowed Status:** `HTTP 401 Unauthorized` +* **Standard Payload:** + ```json + { + "error": "unauthorized", + "message": "Invalid or expired credentials." + } + ``` +* **Internal Logging Only:** Log the specific failure reason (`token expired`, `signature invalid`, `user not found`) internally via Winston before sending the generic response. Never include it in the body. + +--- + +## Code Examples for Enforcement + +### ❌ Anti-Pattern (What to Reject) + +```js +// BAD: Leaks Sequelize error, sets X-Powered-By equivalent header, reveals auth failure reason, +// uses source-tagged tracking ID. +app.post('/login', async (req, res) => { + try { + const user = await User.findOne({ where: { email: req.body.email } }); + if (!user) { + return res.status(401).json({ error: 'User not found' }); // Enumeration leak + } + const valid = await bcrypt.compare(req.body.password, user.password); + if (!valid) { + return res.status(401).json({ error: 'Wrong password' }); // Enumeration leak + } + res.json({ token: generateToken(user) }); + } catch (err) { + res.setHeader('X-Error-Source', 'auth-service-login-handler'); // Leaky header + res.status(500).json({ + error: err.message, // Exposes raw DB/stack info + code: `AUTH_ROUTE_LOGIN_L18_${Date.now()}`, // Source-tagged, guessable ID + stack: err.stack, // Stack trace in response + }); + } +}); +``` + +### Best Practice (What to Generate) + +```js +// app.js — bootstrap +const app = express(); +app.disable('x-powered-by'); // Remove Express fingerprint header + +// errors/AppError.js — sterile error class for internal propagation +class AppError extends Error { + constructor(statusCode, clientMessage) { + super(clientMessage); + this.statusCode = statusCode; + this.clientMessage = clientMessage; + } +} + +// middleware/errorHandler.js — single exit point for all errors +function errorHandler(err, req, res, next) { // eslint-disable-line no-unused-vars + const trackingId = crypto.randomUUID(); // Node.js built-in — no source tags + + // Log the full internal detail, never send it to the client + logger.error('Unhandled error', { + trackingId, + message: err.message, + stack: err.stack, + path: req.path, + method: req.method, + }); + + const statusCode = err.statusCode ?? 500; + const clientMessage = err.clientMessage ?? 'An unexpected error occurred.'; + + res.status(statusCode).json({ + error: clientMessage, + tracking_id: trackingId, + }); +} +app.use(errorHandler); + +// routes/auth.js — GOOD: unified auth failure, generic response +app.post('/login', async (req, res, next) => { + try { + const user = await User.findOne({ where: { email: req.body.email } }); + const valid = user && await bcrypt.compare(req.body.password, user.password); + + if (!valid) { + // Log the specific reason internally only — never log PII such as email + logger.warn('Authentication failed', { + reason: user ? 'invalid_password' : 'user_not_found', + }); + // Return identical response regardless of reason + return res.status(401).json({ + error: 'unauthorized', + message: 'Invalid or expired credentials.', + }); + } + res.json({ token: generateToken(user) }); + } catch (err) { + next(err); // Delegate to errorHandler — never serialise raw err here + } +}); +``` + +--- + +> **Verification Checklist before outputting code:** +> * Does any error response branch reveal *why* auth failed (user not found vs wrong password)? (If yes, collapse to a single generic response). +> * Is `err.message`, `err.stack`, or an ORM error object present in any `res.json()` call? (If yes, remove and route through `errorHandler`). +> * Does the generated ID contain file names, line numbers, or timestamps alone? (If yes, replace with `crypto.randomUUID()`). +> * Is `app.disable('x-powered-by')` present in bootstrap and are leaky headers absent from `res.setHeader` calls? (If no, add the disable call). diff --git a/.claude/rules/js-file-access.md b/.claude/rules/js-file-access.md new file mode 100644 index 0000000000..b66904be0f --- /dev/null +++ b/.claude/rules/js-file-access.md @@ -0,0 +1,225 @@ +# Rule: JavaScript (Node.js/Express) File Access Security Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) code in `portals/developer-portal` that handles file uploads (multer), archive extraction (unzipper), file reads from disk, or byte-stream processing from user input. This is the JavaScript counterpart to `file-access.md` (Go). The project uses **multer memory storage** — uploaded bytes live in `req.file.buffer`; this rule reinforces that pattern and extends it to ZIP handling, path safety, and configurable stream limits sourced from the `DP_*` config system. + +--- + +## Directives + +### 1. Path Traversal Prevention (Clean Path Validation) + +* **Enforce Containment:** Any file read or write derived from user input must resolve to an absolute path with `path.resolve()` and verify the result starts with the expected root directory (with a trailing separator to prevent partial prefix matches). +* **Reject Traversal Sequences:** Strip or reject input containing `..`, `\x00` (null byte), or URL-encoded variants (`%2e%2e`, `%2f`, `%00`) before any `fs.*` call. +* **Never Concatenate User Input into Paths:** Use `path.join(root, path.basename(userInput))` as the minimum safe form; prefer the `safeJoin` pattern below for full containment validation. + +### 2. Database / Storage — Filename Only, No Paths + +* **Store Only the Basename:** Before persisting any file reference to Sequelize models or the SQLite/PostgreSQL database, extract the bare name using `path.basename()`. Never store a full path, relative path, or directory component in any database column. +* **Re-derive Paths Server-Side:** When reading a stored file, construct the access path by joining a server-controlled root (from config) with the stored bare filename. The stored value must never be used as-is as an argument to `fs.readFile` or similar. + +### 3. In-Memory File Processing (No Intermediate Filesystem Writes) + +* **Prefer Buffers and Streams:** multer is already configured with `memoryStorage()` in this project — uploaded content arrives as `req.file.buffer`. Continue processing from that buffer via `Buffer`, `stream.Readable.from()`, or `unzipper.Open.buffer()`. Do not write the buffer to disk before processing. +* **`fs.writeFile` / `fs.mkdtemp` Are Last Resort:** Only write to disk if a third-party dependency strictly requires a file path. Immediately clean up with `fs.unlink` in a `finally` block and use `os.tmpdir()` with a process-generated name — never a user-supplied name. +* **No User-Controlled Temp Names:** Use `crypto.randomUUID()` (or the `uuid` package) to generate temporary filenames, never `req.body.filename` or similar. + +### 4. ZIP / Archive File Handling — Specific-File Restriction + +* **Validate Every Entry Name:** When iterating over a ZIP using `unzipper`, check each entry's `path` property against traversal patterns before extracting. Reject entries whose cleaned path escapes the destination root (zip slip). +* **Single-Entry Extraction:** When the API targets one specific file inside an archive, locate it by exact name and call `entry.autodrain()` on all other entries. Never extract the full archive speculatively. +* **Reject Absolute Paths and `..` in Entry Names:** Any entry whose `path` starts with `/`, contains `..`, or resolves outside the destination must trigger an error — not a silent skip — so callers can surface it. +* **Limit Entry Count and Decompression Size:** Apply a maximum entry count and a maximum uncompressed byte ceiling per entry (sourced from config) to mitigate zip-bomb attacks. + +### 5. Configurable Input Stream Size Limits + +* **Never Accept Unbounded Input:** multer's `limits.fileSize` and Express's `bodyParser` limits must be set from configuration values, not hardcoded. +* **Externalize to the `DP_*` Config System:** Read the byte ceiling from the YAML config or `DP_MAX_UPLOAD_BYTES` / `DP_MAX_ZIP_ENTRIES` / `DP_MAX_ZIP_RATIO` environment variables. Apply a safe default when the key is absent. +* **Return HTTP 413 on Overflow:** If multer or stream limits are exceeded, the error handler must respond with `HTTP 413 Request Entity Too Large` and a generic message. Do not echo the configured limit in the response body. + +--- + +## Code Examples for Enforcement + +### ❌ Anti-Pattern (What to Reject) + +```js +// BAD: Path traversal — user controls the path completely +app.get('/files', (req, res) => { + const filePath = './uploads/' + req.query.name; // ../../etc/passwd bypasses root + res.sendFile(path.resolve(filePath)); +}); + +// BAD: Storing full path in DB +await FileModel.create({ filePath: req.file.originalname }); // may contain /path/evil + +// BAD: Writing upload buffer to disk with a user-supplied name +fs.writeFile(`/tmp/${req.file.originalname}`, req.file.buffer, callback); + +// BAD: ZIP extraction with no zip-slip protection, hardcoded limit +const zip = await unzipper.Open.buffer(req.file.buffer); +for (const entry of zip.files) { + const outPath = path.join('/var/app/content', entry.path); // Zip slip + entry.stream().pipe(fs.createWriteStream(outPath)); // Unbounded decompression +} + +// BAD: multer with hardcoded 50 MB limit +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }); +``` + +### Best Practice (What to Generate) + +```js +// config/configLoader.js — load file limits from DP_* env / YAML config +function getFileConfig(config) { + return { + uploadRootDir: config.uploads?.rootDir || path.join(process.cwd(), 'uploads'), + maxUploadBytes: parseInt(process.env.DP_MAX_UPLOAD_BYTES || config.uploads?.maxBytes) || 10 * 1024 * 1024, + maxZipEntries: parseInt(process.env.DP_MAX_ZIP_ENTRIES || config.uploads?.maxZipEntries) || 500, + maxZipRatio: parseFloat(process.env.DP_MAX_ZIP_RATIO || config.uploads?.maxZipRatio) || 20.0, + }; +} + +// utils/fileSafety.js — GOOD: path containment helper +const path = require('path'); +const crypto = require('crypto'); + +function safeJoin(root, userInput) { + // Strip null bytes and decode percent-encoding before resolving + const sanitised = userInput.replace(/\0/g, '').replace(/%2e/gi, '.').replace(/%2f/gi, '/'); + const resolved = path.resolve(root, path.basename(sanitised)); // basename strips all dirs + const rootWithSep = path.resolve(root) + path.sep; + if (!resolved.startsWith(rootWithSep)) { + throw new Error('Path escapes root directory'); + } + return resolved; +} + +// GOOD: Serve file with containment check +app.get('/files', (req, res, next) => { + try { + const safePath = safeJoin(fileConfig.uploadRootDir, req.query.name); + res.sendFile(safePath); + } catch { + res.status(404).json({ error: 'not_found' }); + } +}); + +// GOOD: Store only the bare filename in the database +async function saveFileMeta(originalName, mimeType) { + const bareName = path.basename(originalName); // Strips any directory component + return FileModel.create({ fileName: bareName, mimeType }); +} + +// GOOD: Re-derive path server-side from stored bare filename +async function openStoredFile(fileId) { + const record = await FileModel.findByPk(fileId); + if (!record) throw new Error('file_not_found'); + const safePath = safeJoin(fileConfig.uploadRootDir, record.fileName); + return fs.promises.readFile(safePath); +} + +// GOOD: multer with configurable limits from config system +function createUploadMiddleware(fileConfig) { + return multer({ + storage: multer.memoryStorage(), // Keep buffer in memory — no disk write + limits: { fileSize: fileConfig.maxUploadBytes }, + }); +} + +// Handle multer size-limit error — return 413, not 500 +app.use((err, req, res, next) => { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ + error: 'payload_too_large', + message: 'Uploaded file exceeds the maximum allowed size.', + // Do NOT include the actual limit value here + }); + } + next(err); +}); + +// GOOD: In-memory ZIP processing with zip-slip protection, entry count, and decompression guard +const unzipper = require('unzipper'); + +async function extractSingleEntry(zipBuffer, targetEntryName, fileConfig) { + const zip = await unzipper.Open.buffer(zipBuffer); + + if (zip.files.length > fileConfig.maxZipEntries) { + throw Object.assign(new Error('Archive exceeds maximum entry count'), { statusCode: 413 }); + } + + let found = null; + + for (const entry of zip.files) { + const entryName = entry.path; + + // Reject absolute paths and traversal sequences in entry names + if ( + path.isAbsolute(entryName) || + entryName.includes('..') || + entryName.includes('\0') + ) { + throw Object.assign(new Error('Archive contains unsafe entry path'), { statusCode: 422 }); + } + + // Match by full normalized path — basename comparison picks the wrong file when + // multiple entries share a filename across different directories. + const normalizedEntry = path.posix.normalize(entryName); + const normalizedTarget = path.posix.normalize(targetEntryName); + if (normalizedEntry !== normalizedTarget) { + // Drain and discard non-target entries — do not extract them + await entry.autodrain(); + continue; + } + + if (found) { + // Duplicate path in archive — reject to prevent ambiguous extraction + throw Object.assign(new Error('Archive contains duplicate entry path'), { statusCode: 422 }); + } + found = entry; + } + + if (!found) { + throw Object.assign(new Error('Requested entry not found in archive'), { statusCode: 404 }); + } + + // Collect bytes with a decompression-ratio guard. + // Take the tighter of the ratio-based limit and maxUploadBytes (Math.min, not Math.max). + // Fall back to maxUploadBytes alone when compressedSize is unknown (0). + const compressedSize = found.compressedSize || 0; + const maxDecompressed = compressedSize > 0 + ? Math.min(compressedSize * fileConfig.maxZipRatio, fileConfig.maxUploadBytes) + : fileConfig.maxUploadBytes; + + const chunks = []; + let totalBytes = 0; + const stream = found.stream(); + + await new Promise((resolve, reject) => { + stream.on('data', (chunk) => { + totalBytes += chunk.length; + if (totalBytes > maxDecompressed) { + stream.destroy(); + reject(Object.assign(new Error('Decompressed entry exceeds allowed size'), { statusCode: 413 })); + return; + } + chunks.push(chunk); + }); + stream.on('end', resolve); + stream.on('error', reject); + }); + + return Buffer.concat(chunks); +} +``` + +--- + +> **Verification Checklist before outputting code:** +> * Is every file path constructed with `path.resolve()` and verified against the root with a trailing `path.sep` prefix check? (If no, use `safeJoin`). +> * Is only `path.basename()` of the filename stored in Sequelize models or database columns? (If the full path is stored, strip it). +> * Is file processing done from `req.file.buffer` in memory without any `fs.writeFile` / `fs.mkdtemp` intermediate step? (If disk writes exist, remove them or wrap in a `finally` cleanup). +> * Is every ZIP entry's `path` checked for `..`, absolute paths, and null bytes before any extraction? (If not, add checks and call `entry.autodrain()` on skipped entries). +> * Are multer `limits.fileSize`, `maxZipEntries`, and `maxZipRatio` sourced from the `DP_*` config system rather than hardcoded? (If hardcoded, move to config with a safe default). diff --git a/.claude/rules/js-post-quantum-cryptography.md b/.claude/rules/js-post-quantum-cryptography.md new file mode 100644 index 0000000000..863a8063a6 --- /dev/null +++ b/.claude/rules/js-post-quantum-cryptography.md @@ -0,0 +1,264 @@ +# Rule: JavaScript (Node.js/Express) Post-Quantum Cryptography Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing JavaScript (`.js`) code in `portals/developer-portal` that performs key exchange, digital signatures, encryption, or any operation relying on the hardness of integer factorisation or discrete-logarithm problems (RSA, ECDH, ECDSA, Node.js built-in `crypto.generateKeyPair` with classic algorithms). The goal is to ensure cryptographic primitives remain secure against adversaries with a cryptographically relevant quantum computer (CRQC), following NIST FIPS 203 (ML-KEM / Kyber), FIPS 204 (ML-DSA / Dilithium), and FIPS 205 (SLH-DSA / SPHINCS+). This is the JavaScript counterpart to `post-quantum-cryptography.md` (Go). + +--- + +## Directives + +### 1. Prohibited Quantum-Vulnerable Algorithms + +* **Never use for new code:** RSA (any key size), ECDH, ECDSA, Ed25519/Ed448, X25519, or classic Diffie-Hellman in new key-exchange or signing code paths. +* **Existing code:** Annotate any remaining quantum-vulnerable call with `// TODO(pqc): migrate` and open a tracking issue. Do not leave undocumented quantum-vulnerable cryptography. +* **Symmetric exception:** AES-256-GCM, ChaCha20-Poly1305, and SHA-3 / BLAKE3 are considered quantum-safe at their current sizes. Prefer 256-bit variants; avoid AES-128 and SHA-256 for new long-lived keys or digests. +* **`node:crypto` built-ins:** `crypto.generateKeyPair('rsa', ...)`, `crypto.createECDH(...)`, `crypto.sign` with `'RSA-SHA256'` are all quantum-vulnerable — do not introduce them in new paths. + +### 2. Approved Algorithm Selection + +| Purpose | NIST Standard | Algorithm | npm Package | +|---|---|---|---| +| Key Encapsulation (KEM) | FIPS 203 | ML-KEM-768 (Kyber-768) | `liboqs-node` or `@noble/post-quantum` (kyber768) | +| Digital Signatures | FIPS 204 | ML-DSA-65 (Dilithium3) | `liboqs-node` or `@noble/post-quantum` (dilithium3) | +| Hash-based Signatures | FIPS 205 | SLH-DSA-SHA2-128s | `liboqs-node` | +| Symmetric Encryption | — | AES-256-GCM, ChaCha20-Poly1305 | `node:crypto` | +| Hashing | — | SHA3-256 / SHA3-512 | `node:crypto` (`sha3-256`), `@noble/hashes` | + +Prefer `@noble/post-quantum` for pure-JS environments (no native bindings, audited by security researchers). Use `liboqs-node` when FIPS 140-3 validation or HSM integration is required. + +Use security level `-768` / `dilithium3` (NIST Level 3) as minimum. Escalate to `-1024` / `dilithium5` for long-lived keys or high-assurance contexts. + +### 3. Hybrid Classical + PQC (Transition Period) + +* **Mandate Hybrid KEM** during the transition: combine X25519 + ML-KEM-768 so that security degrades gracefully to classical if the PQC primitive is found to have a flaw, and to PQC if a CRQC appears (IETF RFC 9180 pattern). +* **Do not deploy PQC standalone** until the chosen library has reached a stable 1.x release with a public security audit. +* **TLS:** Node.js 22+ with OpenSSL 3.2+ supports `X25519MLKEM768` via `tls.createServer({ ecdhCurve: 'X25519MLKEM768:X25519' })`. List the hybrid curve first. + +### 4. Key and Ciphertext Size Awareness + +* ML-KEM-768 public keys are **1184 bytes** and ciphertexts are **1088 bytes**. Do not store them in database columns or Sequelize `STRING` / `VARCHAR(512)` fields sized for RSA keys. Use `BLOB` / `BYTEA` or `TEXT` (base64). +* ML-DSA-65 signatures are **3309 bytes**. JWTs or HTTP headers embedding PQC signatures must account for this — avoid setting PQC signatures in `Authorization` headers where size limits apply; use the request body instead. +* Never truncate PQC keys or signatures for storage convenience. + +### 5. Randomness and Nonce Safety + +* Key generation must use `crypto.randomBytes` (Node.js built-in `node:crypto`) — never `Math.random()`, `Date.now()`, or third-party PRNGs without a `crypto/rand` equivalent. +* AES-256-GCM nonces (12 bytes / 96 bits) must be freshly generated per encryption using `crypto.randomBytes(12)`. Never reuse a nonce under the same key. Rotate the key after 2³² encryptions. +* For `@noble/post-quantum` KEM: `kyber768.encapsulate(recipientPublicKey)` generates its own randomness internally from `crypto.getRandomValues`; do not supply external randomness unless the API explicitly requires it. + +### 6. No Algorithm Negotiation in Sensitive Paths + +* **Never accept the algorithm from a JWT header or request payload** in authentication or key-exchange flows. Allowlist exact algorithm identifiers and reject any deviation with a generic `401 Unauthorized`. +* In `jose` JWS/JWT verification, always pass `algorithms: ['ML-DSA-65']` (or the IANA-registered codepoint when standardised). Never accept `'none'` or legacy `'RS256'`. + +--- + +## Code Examples for Enforcement + +### ❌ Anti-Pattern (What to Reject) + +```js +// BAD: ECDH key exchange — quantum-vulnerable +const ecdh = crypto.createECDH('prime256v1'); +ecdh.generateKeys(); +const sharedSecret = ecdh.computeSecret(peerPublicKey); // Broken by Shor's algorithm + +// BAD: RSA encryption — quantum-vulnerable +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 4096 }); +const ciphertext = crypto.publicEncrypt(publicKey, plaintext); + +// BAD: PQC KEM used without classical hybrid +const { cipherText, sharedSecret } = kyber768.encapsulate(recipientPub); +// No X25519 leg — no fallback if ML-KEM is broken + +// BAD: AES-128 for long-lived keys +const key = crypto.randomBytes(16); // AES-128 — only ~80-bit post-quantum security + +// BAD: Nonce reuse +const FIXED_NONCE = Buffer.alloc(12, 0); // Never reuse — nonce collision breaks AES-GCM +const cipher = crypto.createCipheriv('aes-256-gcm', key, FIXED_NONCE); + +// BAD: Undocumented legacy algorithm +function signLegacy(data, privateKey) { + return crypto.sign('SHA256', data, privateKey); // No TODO(pqc) — silently quantum-vulnerable +} +``` + +### Best Practice (What to Generate) + +```js +// crypto/hybridKem.js +// Hybrid KEM: X25519 + ML-KEM-768 (NIST FIPS 203). +// Security holds if either primitive is unbroken. + +const { x25519 } = require('@noble/curves/ed25519'); // classical leg +const { ml_kem768 } = require('@noble/post-quantum/ml-kem'); // PQC leg — FIPS 203 +const { sha3_256 } = require('@noble/hashes/sha3'); +const crypto = require('node:crypto'); + +/** + * Encapsulate a shared secret for a recipient. + * @param {Uint8Array} recipientClassicalPub - 32-byte X25519 public key + * @param {Uint8Array} recipientPqcPub - 1184-byte ML-KEM-768 public key + * @returns {{ ciphertext: { classical: Uint8Array, pqc: Uint8Array }, sharedSecret: Uint8Array }} + */ +function encapsulate(recipientClassicalPub, recipientPqcPub) { + // Classical leg: ephemeral X25519 + const ephemeralPriv = x25519.utils.randomPrivateKey(); // uses crypto.getRandomValues internally + const ephemeralPub = x25519.getPublicKey(ephemeralPriv); + const classicalShared = x25519.getSharedSecret(ephemeralPriv, recipientClassicalPub); + + // PQC leg: ML-KEM-768 (FIPS 203) + const { cipherText: pqcCT, sharedSecret: pqcShared } = ml_kem768.encapsulate(recipientPqcPub); + + // Combine: SHA3-256(classicalShared || pqcShared || ephemeralPub || pqcCT) + // Binding all inputs prevents downgrade to one leg by an active attacker. + const combined = sha3_256( + Buffer.concat([classicalShared, pqcShared, ephemeralPub, pqcCT]) + ); + + return { + ciphertext: { classical: ephemeralPub, pqc: pqcCT }, + sharedSecret: combined, + }; +} + +/** + * Decapsulate a shared secret from a received ciphertext. + */ +function decapsulate(ciphertext, classicalPriv, pqcPriv) { + const classicalShared = x25519.getSharedSecret(classicalPriv, ciphertext.classical); + const pqcShared = ml_kem768.decapsulate(ciphertext.pqc, pqcPriv); + + return sha3_256( + Buffer.concat([classicalShared, pqcShared, ciphertext.classical, ciphertext.pqc]) + ); +} + +module.exports = { encapsulate, decapsulate }; +``` + +```js +// crypto/pqcSign.js +// ML-DSA-65 (NIST FIPS 204 / Dilithium3) digital signatures. +// Signature size: 3309 bytes. Public key: 1952 bytes. + +const { ml_dsa65 } = require('@noble/post-quantum/ml-dsa'); // FIPS 204 + +function generateSigningKeypair() { + return ml_dsa65.keygen(); // { publicKey: Uint8Array(1952), secretKey: Uint8Array(...) } +} + +function sign(message, secretKey) { + return ml_dsa65.sign(secretKey, message); // Uint8Array — 3309 bytes +} + +function verify(message, signature, publicKey) { + const valid = ml_dsa65.verify(publicKey, message, signature); + if (!valid) { + throw Object.assign(new Error('Signature verification failed'), { statusCode: 401 }); + } +} + +module.exports = { generateSigningKeypair, sign, verify }; +``` + +```js +// crypto/symmetricEncryption.js +// AES-256-GCM with fresh per-message nonce. Quantum-safe at 256-bit key size. + +const crypto = require('node:crypto'); + +const KEY_BYTES = 32; // AES-256 — 128-bit post-quantum security (Grover halves strength) +const NONCE_BYTES = 12; // 96-bit GCM nonce — must be unique per (key, message) pair + +function generateKey() { + return crypto.randomBytes(KEY_BYTES); // Never use Math.random() or Date.now() +} + +function encrypt(plaintext, key) { + const nonce = crypto.randomBytes(NONCE_BYTES); // Fresh per encryption + const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); // 16-byte GCM authentication tag + return Buffer.concat([nonce, authTag, ciphertext]); // Prepend nonce and tag for decryption +} + +function decrypt(payload, key) { + const nonce = payload.subarray(0, NONCE_BYTES); + const authTag = payload.subarray(NONCE_BYTES, NONCE_BYTES + 16); + const ciphertext = payload.subarray(NONCE_BYTES + 16); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +module.exports = { generateKey, encrypt, decrypt }; +``` + +```js +// tls/tlsConfig.js +// GOOD: TLS 1.3 with hybrid X25519+ML-KEM-768 (Node.js 22+ / OpenSSL 3.2+). + +const tls = require('node:tls'); +const fs = require('node:fs'); + +function createPqcTlsServer(requestHandler) { + return tls.createServer( + { + key: fs.readFileSync(process.env.TLS_KEY_PATH), + cert: fs.readFileSync(process.env.TLS_CERT_PATH), + minVersion: 'TLSv1.3', + // X25519MLKEM768 is the hybrid PQC key share (FIPS 203 + X25519). + // Listed first so it is offered as the preferred key share in ClientHello. + // X25519 is a classical fallback for peers without PQC support. + ecdhCurve: 'X25519MLKEM768:X25519', + }, + requestHandler + ); +} + +module.exports = { createPqcTlsServer }; +``` + +```js +// db/migrations/20260706_pqc_key_columns.js +// GOOD: Sequelize migration sized for PQC artifacts. +// ML-KEM-768 public key = 1184 bytes, ciphertext = 1088 bytes. +// ML-DSA-65 public key = 1952 bytes, signature = 3309 bytes. +// Use DataTypes.BLOB or TEXT (base64) — never STRING(512) sized for RSA. + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('session_keys', 'pqc_public_key', { + type: Sequelize.DataTypes.BLOB, // ML-KEM-768 public key (1184 B) + allowNull: true, + }); + await queryInterface.addColumn('session_keys', 'pqc_ciphertext', { + type: Sequelize.DataTypes.BLOB, // ML-KEM-768 ciphertext (1088 B) + allowNull: true, + }); + await queryInterface.addColumn('api_signing_keys', 'pqc_public_key', { + type: Sequelize.DataTypes.BLOB, // ML-DSA-65 public key (1952 B) + allowNull: true, + }); + // TODO(pqc): remove legacy rsa_public_key column after all clients migrate + }, + down: async (queryInterface) => { + await queryInterface.removeColumn('session_keys', 'pqc_public_key'); + await queryInterface.removeColumn('session_keys', 'pqc_ciphertext'); + await queryInterface.removeColumn('api_signing_keys', 'pqc_public_key'); + }, +}; +``` + +--- + +> **Verification Checklist before outputting code:** +> * Does any new key exchange use `crypto.createECDH`, `generateKeyPair('rsa', ...)`, or `x25519` without a `// TODO(pqc): migrate` comment? (If yes, migrate to hybrid KEM or add the comment with a tracking issue.) +> * Is the PQC KEM used in isolation (no X25519 leg)? (If yes, add a hybrid X25519+ML-KEM-768 construction.) +> * Are key, ciphertext, or signature sizes accounted for in Sequelize model definitions and HTTP payload budgets? (ML-KEM-768 CT = 1088 B, ML-DSA-65 sig = 3309 B — never `STRING(512)`.) +> * Does any nonce or key generation use `Math.random()`, `Date.now()`, or a non-`crypto.randomBytes` source? (If yes, replace with `crypto.randomBytes`.) +> * Does TLS configuration include `X25519MLKEM768` as the first entry in `ecdhCurve`? (If no, add it for Node.js 22+ services.) +> * Does any JWT/JWS verification in `jose` accept the algorithm from the token header without an explicit allowlist? (If yes, add `algorithms: ['ML-DSA-65']` or the appropriate asymmetric-only list.) diff --git a/.claude/rules/post-quantum-cryptography.md b/.claude/rules/post-quantum-cryptography.md new file mode 100644 index 0000000000..db42325b45 --- /dev/null +++ b/.claude/rules/post-quantum-cryptography.md @@ -0,0 +1,248 @@ +# Rule: Go Post-Quantum Cryptography Standards + +## Context & Scope + +Apply this rule whenever writing, refactoring, or reviewing Go (`.go`) code that performs key exchange, digital signatures, encryption, or any operation that relies on the hardness of integer factorisation or discrete-logarithm problems (RSA, ECDH, ECDSA, DH). The goal is to ensure cryptographic primitives remain secure against adversaries with access to a cryptographically relevant quantum computer (CRQC), following NIST SP 800-208 and the NIST PQC Round 4 / Final standards (FIPS 203 Kyber/ML-KEM, FIPS 204 Dilithium/ML-DSA, FIPS 205 SPHINCS+/SLH-DSA). + +--- + +## Directives + +### 1. Prohibited Quantum-Vulnerable Algorithms + +* **Never use for new code:** RSA (any key size), ECDH, ECDSA, Ed25519/Ed448, X25519/X448, or classic Diffie-Hellman for key establishment or digital signatures in new code paths. +* **Existing code:** Mark any use of the above with a `// TODO(pqc): migrate` comment and open a tracking issue. Do not silently leave quantum-vulnerable code undocumented. +* **Symmetric exception:** AES-256, ChaCha20-Poly1305, and SHA-3/BLAKE3 are considered quantum-safe at their current key/digest sizes (Grover halves effective strength; 256-bit → 128-bit effective). AES-128 and SHA-256 are borderline — prefer 256-bit variants in new code. + +### 2. Approved Algorithm Selection + +| Purpose | NIST Standard | Algorithm | Go Package | +|---|---|---|---| +| Key Encapsulation (KEM) | FIPS 203 | ML-KEM-768 (Kyber-768) | `github.com/cloudflare/circl/kem/kyber/kyber768` | +| Digital Signatures | FIPS 204 | ML-DSA-65 (Dilithium3) | `github.com/cloudflare/circl/sign/dilithium/mode3` | +| Hash-based Signatures | FIPS 205 | SLH-DSA-SHA2-128s (SPHINCS+) | `github.com/cloudflare/circl/sign/sphincsplus` | +| Symmetric Encryption | — | AES-256-GCM, ChaCha20-Poly1305 | `crypto/aes`, `golang.org/x/crypto/chacha20poly1305` | +| Hashing | — | SHA-3-256 / SHA-3-512, BLAKE3 | `golang.org/x/crypto/sha3` | + +Use security level `-768` / `mode3` (NIST Level 3) as the minimum. Escalate to `-1024` / `mode5` for long-lived keys or high-assurance contexts. + +### 3. Hybrid Classical + PQC (Transition Period) + +* **Mandate Hybrid KEM** during the transition: combine X25519 + ML-KEM-768 so that security degrades gracefully to classical if the PQC primitive is found to have a flaw, and to PQC if a CRQC appears. This is the pattern recommended by IETF RFC 9180 hybrid KEM and NIST SP 800-227. +* **Do not use PQC standalone** until your deployment has validated interoperability and library maturity at v1.0+. +* **TLS:** Prefer Go 1.23+ `crypto/tls` with `X25519MLKEM768` (`tls.X25519MLKEM768`) as the first key share in `CurvePreferences`. Remove P-256 and P-384 from the curve list for new services. + +### 4. Key and Ciphertext Size Awareness + +* ML-KEM-768 public keys are **1184 bytes** and ciphertexts are **1088 bytes** — do not store them in database columns sized for RSA public keys (typically 512 bytes for 4096-bit). Size schema migrations accordingly. +* ML-DSA-65 signatures are **3309 bytes** — JWT/JWS payloads that embed signatures must account for this. Avoid base64-encoding large PQC artifacts in HTTP headers. +* Never truncate PQC keys or signatures for storage convenience; truncation silently invalidates all cryptographic guarantees. + +### 5. Randomness and Nonce Safety + +* Key generation must use `crypto/rand` exclusively — never `math/rand`, `time.Now().UnixNano()`, or seeded PRNGs. +* AES-GCM nonces (96-bit) must be generated fresh per encryption with `crypto/rand.Read`; never reuse a nonce under the same key. After 2³² encryptions under one key, rotate the key. +* For ML-KEM: the `Encapsulate` function in CIRCL generates the randomness internally from `crypto/rand`; do not pass external randomness unless explicitly required by the API. + +### 6. No Algorithm Negotiation in Sensitive Paths + +* **Never accept the algorithm from the peer** in authentication or key-exchange flows. Allowlist the exact algorithm identifiers expected and reject any deviation with a generic error — algorithm confusion attacks apply equally to PQC negotiation. +* In JWS/JWT contexts, set `algorithms: ["ML-DSA-65"]` (or the registered IANA codepoint once standardised) explicitly; never accept `"alg": "none"` or legacy `"alg": "RS256"`. + +--- + +## Code Examples for Enforcement + +### ❌ Anti-Pattern (What to Reject) + +```go +// BAD: ECDH key exchange — quantum-vulnerable +priv, _ := ecdh.P256().GenerateKey(rand.Reader) +pub := priv.PublicKey() +shared, _ := priv.ECDH(peerPub) // Broken by Shor's algorithm + +// BAD: RSA encryption — quantum-vulnerable +rsaKey, _ := rsa.GenerateKey(rand.Reader, 4096) +cipher, _ := rsa.EncryptOAEP(sha256.New(), rand.Reader, &rsaKey.PublicKey, plaintext, nil) + +// BAD: No hybrid — PQC used in isolation before library maturity confirmed +import "github.com/cloudflare/circl/kem/kyber/kyber768" +pub, priv, _ := kyber768.GenerateKeyPair() +ct, sharedSecret, _ := kyber768.Encapsulate(pub) // Sole key exchange — no classical fallback + +// BAD: AES-128 for long-lived session keys +key := make([]byte, 16) // AES-128: only ~80-bit post-quantum security +rand.Read(key) + +// BAD: Undocumented legacy algorithm +func signLegacy(data []byte, key *rsa.PrivateKey) []byte { + sig, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, data) + return sig + // No TODO(pqc) comment — silently quantum-vulnerable +} +``` + +### Best Practice (What to Generate) + +```go +// crypto/pqc/hybrid_kem.go +// Hybrid KEM: X25519 + ML-KEM-768 (NIST FIPS 203) +// Both shared secrets are combined — security holds if either primitive is unbroken. + +package pqc + +import ( + "crypto/ecdh" + "crypto/rand" + "crypto/sha3" + "fmt" + + "github.com/cloudflare/circl/kem/kyber/kyber768" +) + +type HybridKEMCiphertext struct { + ClassicalECDHPublic []byte // Ephemeral X25519 public key (32 bytes) + PQCCiphertext []byte // ML-KEM-768 ciphertext (1088 bytes) +} + +// Encapsulate derives a shared secret for the given recipient hybrid public key. +// Returns (ciphertext, 32-byte shared secret, error). +func Encapsulate(recipientClassical *ecdh.PublicKey, recipientPQC *kyber768.PublicKey) (HybridKEMCiphertext, []byte, error) { + // Classical leg: ephemeral X25519 + ephemeral, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return HybridKEMCiphertext{}, nil, fmt.Errorf("x25519 key generation failed: %w", err) + } + classicalShared, err := ephemeral.ECDH(recipientClassical) + if err != nil { + return HybridKEMCiphertext{}, nil, fmt.Errorf("x25519 ecdh failed: %w", err) + } + + // PQC leg: ML-KEM-768 (FIPS 203) + pqcCT, pqcShared, err := kyber768.Encapsulate(recipientPQC) + if err != nil { + return HybridKEMCiphertext{}, nil, fmt.Errorf("ml-kem encapsulation failed: %w", err) + } + + // Combine: SHA3-256(classicalShared || pqcShared || classicalPublic || pqcCiphertext) + // Binding all inputs prevents a downgrade to one leg by an active attacker. + h := sha3.New256() + h.Write(classicalShared) + h.Write(pqcShared) + h.Write(ephemeral.PublicKey().Bytes()) + h.Write(pqcCT) + combined := h.Sum(nil) + + ct := HybridKEMCiphertext{ + ClassicalECDHPublic: ephemeral.PublicKey().Bytes(), + PQCCiphertext: pqcCT, + } + return ct, combined, nil +} + +// Decapsulate recovers the shared secret from a ciphertext. +func Decapsulate(ct HybridKEMCiphertext, classicalPriv *ecdh.PrivateKey, pqcPriv *kyber768.PrivateKey) ([]byte, error) { + ephemeralPub, err := ecdh.X25519().NewPublicKey(ct.ClassicalECDHPublic) + if err != nil { + return nil, fmt.Errorf("invalid classical public key in ciphertext: %w", err) + } + classicalShared, err := classicalPriv.ECDH(ephemeralPub) + if err != nil { + return nil, fmt.Errorf("x25519 decapsulation failed: %w", err) + } + + pqcShared, err := kyber768.Decapsulate(pqcPriv, ct.PQCCiphertext) + if err != nil { + return nil, fmt.Errorf("ml-kem decapsulation failed: %w", err) + } + + h := sha3.New256() + h.Write(classicalShared) + h.Write(pqcShared) + h.Write(ct.ClassicalECDHPublic) + h.Write(ct.PQCCiphertext) + return h.Sum(nil), nil +} + +// crypto/pqc/signing.go +// ML-DSA-65 (NIST FIPS 204 / Dilithium3) digital signatures. +// Signature size: 3309 bytes. Public key size: 1952 bytes. + +package pqc + +import ( + "crypto/rand" + "fmt" + + mode3 "github.com/cloudflare/circl/sign/dilithium/mode3" +) + +// Sign signs msg with the ML-DSA-65 private key. +func Sign(msg []byte, priv mode3.PrivateKey) ([]byte, error) { + sig := mode3.Sign(&priv, msg) + if sig == nil { + return nil, fmt.Errorf("ml-dsa signing failed") + } + return sig, nil +} + +// Verify verifies an ML-DSA-65 signature. +// Returns a typed error on failure — never returns nil error with invalid result. +func Verify(msg, sig []byte, pub mode3.PublicKey) error { + if !mode3.Verify(&pub, msg, sig) { + return fmt.Errorf("signature verification failed") + } + return nil +} + +// tls/tls_config.go +// GOOD: TLS 1.3 with hybrid X25519+ML-KEM-768 key share (Go 1.23+). +// X25519MLKEM768 is listed first to ensure it is offered in ClientHello. + +package tlsconfig + +import ( + "crypto/tls" +) + +func NewServerTLSConfig(cert tls.Certificate) *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS13, + CurvePreferences: []tls.CurveID{ + tls.X25519MLKEM768, // Hybrid PQC-safe key share (FIPS 203 + X25519) + tls.X25519, // Classical fallback for peers without PQC support + }, + Certificates: []tls.Certificate{cert}, + // Cipher suites in TLS 1.3 are fixed; no further configuration needed. + } +} + +// db/migrations/20260706_pqc_key_columns.go +// GOOD: Schema sized for PQC artifacts. +// ML-KEM-768 public key = 1184 bytes, ciphertext = 1088 bytes. +// ML-DSA-65 public key = 1952 bytes, signature = 3309 bytes. +// Use BYTEA (PostgreSQL) or BLOB — never VARCHAR(512) sized for RSA. + +const MigrationPQCKeyColumns = ` +ALTER TABLE session_keys + ADD COLUMN IF NOT EXISTS pqc_public_key BYTEA, -- ML-KEM-768 public key (1184 B) + ADD COLUMN IF NOT EXISTS pqc_ciphertext BYTEA; -- ML-KEM-768 ciphertext (1088 B) + +ALTER TABLE api_signing_keys + ADD COLUMN IF NOT EXISTS pqc_public_key BYTEA, -- ML-DSA-65 public key (1952 B) + ADD COLUMN IF NOT EXISTS pqc_signature BYTEA; -- ML-DSA-65 signature (3309 B) +` + +// TODO(pqc): migrate — existing RSA key columns (rsa_public_key VARCHAR(1024)) remain +// for backward-compat during transition. Remove after all clients are upgraded. +``` + +--- + +> **Verification Checklist before outputting code:** +> * Does any new key exchange use RSA, ECDH, X25519, or ECDSA without a `// TODO(pqc): migrate` comment? (If yes, migrate to hybrid KEM or add the comment with a tracking issue.) +> * Is the PQC KEM used in isolation (not hybrid)? (If yes, wrap it in a hybrid X25519+ML-KEM-768 construction.) +> * Are key, ciphertext, or signature byte sizes accounted for in database column types and HTTP payload budgets? (ML-KEM-768 CT = 1088 B, ML-DSA-65 sig = 3309 B — never VARCHAR.) +> * Does any nonce or key generation use a non-`crypto/rand` source? (If yes, replace with `crypto/rand.Read`.) +> * Does TLS configuration include `tls.X25519MLKEM768` as the first `CurvePreferences` entry? (If no, add it for Go 1.23+ services.) +> * Does any JWT/JWS verification accept the algorithm from the token header? (If yes, add an explicit asymmetric-only allowlist.)