Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
Addresses the gaps identified in the last audit.

Restore (was a stub returning "not yet implemented"). Every repository shares
one connection pool, so the database cannot be swapped underneath a live
server. Restore is therefore two-phase: RestoreBackup validates the file and
stages it beside the database; db.New applies it before the pool is opened,
which is the only safe moment. The database being replaced is preserved as
<db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot
replay the old journal over the restored file. Validation is strict — SQLite
integrity_check plus a schema probe — because applying an unrelated file
would destroy the install. GET/DELETE /api/v1/backups/restore inspect and
cancel a staged restore. The CLI does both phases at once, since it runs
standalone; `orchestrad backup` was also a stub and now works.

Secret key. With nothing configured the key is generated once and persisted
to <data>/secret.key, so restarts reuse it and moving the stack to another
server is a matter of copying the data directory. Upgrades are handled: if a
database already exists the install was silently running on the legacy
built-in default, so that value is adopted and written out rather than
replaced — generating a fresh key there would make every stored credential
undecryptable. The file is owner-only (ACL-restricted on Windows).

Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the
architectures the release binaries already covered. The Dockerfile
cross-compiles via TARGETARCH rather than emulating, so arm64 costs little.

CSRF: the middleware previously checked only that a header was *present* and
was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens
are now nonce + HMAC-SHA256 signed with the application secret, validated
properly, and the middleware is mounted on /api/v1. Bearer and API-key
requests are not CSRF-reachable and pass through untouched, so this is
transparent to the SPA and to API clients.

Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not
the source of truth — <data>/tls holds the key, so portability is unaffected
and a non-exportable server key is the better posture), the PFX password is
written to server.pfx.password beside the bundle so an operator importing it
by hand does not have to hunt for a password they never chose, and the
"renewed" log line now reflects whether a leaf was actually issued instead of
guessing from its age.

Verified live: backup -> stage -> restart applies and preserves the previous
database; secret key generated, adopted, and read back across restarts with
the credential check confirming decryptability; CSRF endpoint issues real
signed tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:57:15 -04:00

100 lines
3.2 KiB
Go

// Package api - CSRF protection.
//
// CSRF only matters for credentials the browser attaches automatically. The SPA
// authenticates with a bearer token it holds in localStorage and sets on each
// request, and API clients send X-API-Key — neither is ambient, so neither is
// forgeable cross-site, and both skip these checks. What the middleware guards
// is cookie-authenticated mutation.
//
// Tokens are stateless and signed: <nonce>.<base64 HMAC-SHA256(nonce)> keyed by
// the application secret. That means no server-side store to expire or
// replicate, while a token still cannot be minted by an attacker who cannot
// read the secret.
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"net/http"
"strings"
)
// CSRF issues and validates CSRF tokens.
type CSRF struct {
secret []byte
}
// NewCSRF creates a CSRF issuer/validator keyed by the application secret.
func NewCSRF(secret []byte) *CSRF {
return &CSRF{secret: secret}
}
// IssueToken returns a fresh signed token.
func (c *CSRF) IssueToken() (string, error) {
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("generating csrf nonce: %w", err)
}
n := base64.RawURLEncoding.EncodeToString(nonce)
return n + "." + c.sign(n), nil
}
// ValidToken reports whether token was issued by this server.
func (c *CSRF) ValidToken(token string) bool {
nonce, sig, ok := strings.Cut(token, ".")
if !ok || nonce == "" || sig == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sig), []byte(c.sign(nonce))) == 1
}
func (c *CSRF) sign(nonce string) string {
mac := hmac.New(sha256.New, c.secret)
mac.Write([]byte(nonce))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Handler serves GET /api/v1/auth/csrf.
func (c *CSRF) Handler(w http.ResponseWriter, r *http.Request) {
token, err := c.IssueToken()
if err != nil {
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Could not issue a CSRF token")
return
}
WriteJSON(w, http.StatusOK, map[string]string{"token": token})
}
// Middleware rejects cookie-authenticated mutating requests that do not carry a
// valid X-CSRF-Token. Safe methods pass, and so do requests that authenticate
// with an explicit Authorization/X-API-Key header, which CSRF cannot forge.
func (c *CSRF) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isReadMethod(r.Method) {
next.ServeHTTP(w, r)
return
}
// Explicit credentials are not attached by the browser on a cross-site
// request, so these are not CSRF-reachable.
if r.Header.Get("X-API-Key") != "" || strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
next.ServeHTTP(w, r)
return
}
// No ambient credential either: nothing to protect. Let it through so
// the auth middleware produces the 401.
if _, err := r.Cookie(DocsCookieName); err != nil {
next.ServeHTTP(w, r)
return
}
if !c.ValidToken(r.Header.Get("X-CSRF-Token")) {
WriteError(w, http.StatusForbidden, ErrCodeForbidden,
"A valid X-CSRF-Token header is required for cookie-authenticated requests (get one from GET /api/v1/auth/csrf)")
return
}
next.ServeHTTP(w, r)
})
}