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

140 lines
4.5 KiB
Go

package pki
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// Export writes the full chain to dir in PEM, DER, and PKCS#12 form. Every call
// overwrites the files, so it is safe to call on each renewal to keep the
// exports current. Private-key files are written 0600.
//
// Files written:
//
// root.crt / root.key root CA (PEM)
// intermediate.crt / intermediate.key intermediate CA (PEM)
// server.crt / server.key leaf (PEM)
// fullchain.pem leaf + intermediate + root (PEM)
// *.cer DER encodings of each certificate
// server.pfx leaf key + leaf cert + CA chain (PKCS#12)
func (c *Chain) Export(dir, pfxPassword string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating tls dir: %w", err)
}
writes := []struct {
name string
data []byte
mode os.FileMode
}{
{"root.crt", certPEM(c.Root.DER), 0o644},
{"root.key", keyPEM(c.Root.Key), 0o600},
{"intermediate.crt", certPEM(c.Intermediate.DER), 0o644},
{"intermediate.key", keyPEM(c.Intermediate.Key), 0o600},
{"server.crt", certPEM(c.Leaf.DER), 0o644},
{"server.key", keyPEM(c.Leaf.Key), 0o600},
{"fullchain.pem", c.FullChainPEM(), 0o644},
// DER encodings.
{"root.cer", c.Root.DER, 0o644},
{"intermediate.cer", c.Intermediate.DER, 0o644},
{"server.cer", c.Leaf.DER, 0o644},
}
for _, w := range writes {
if err := os.WriteFile(filepath.Join(dir, w.name), w.data, w.mode); err != nil {
return fmt.Errorf("writing %s: %w", w.name, err)
}
}
// PKCS#12 bundle: leaf key + leaf cert + CA chain (intermediate, root).
pfx, err := pkcs12.Modern.Encode(
c.Leaf.Key,
c.Leaf.Certificate,
[]*x509.Certificate{c.Intermediate.Certificate, c.Root.Certificate},
pfxPassword,
)
if err != nil {
return fmt.Errorf("encoding pfx: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "server.pfx"), pfx, 0o600); err != nil {
return fmt.Errorf("writing server.pfx: %w", err)
}
// Write the PFX password beside the bundle. The two live in the same
// owner-only directory, so this grants no access that the .pfx itself
// doesn't already give — and without it an operator importing the bundle
// by hand has to go digging through settings for a password they never
// chose.
if err := os.WriteFile(filepath.Join(dir, "server.pfx.password"), []byte(pfxPassword+"\n"), 0o600); err != nil {
return fmt.Errorf("writing server.pfx.password: %w", err)
}
return nil
}
// LoadCA reads the root and intermediate CA (cert + key) previously written by
// Export from dir, so renewals can re-issue leaves under the same chain.
// Returns (nil, nil, nil) when the CA files are not present yet.
func LoadCA(dir string) (root, intermediate *CertKey, err error) {
rootCrt := filepath.Join(dir, "root.crt")
if _, statErr := os.Stat(rootCrt); os.IsNotExist(statErr) {
return nil, nil, nil
}
root, err = loadCertKey(filepath.Join(dir, "root.crt"), filepath.Join(dir, "root.key"))
if err != nil {
return nil, nil, fmt.Errorf("loading root CA: %w", err)
}
intermediate, err = loadCertKey(filepath.Join(dir, "intermediate.crt"), filepath.Join(dir, "intermediate.key"))
if err != nil {
return nil, nil, fmt.Errorf("loading intermediate CA: %w", err)
}
return root, intermediate, nil
}
func loadCertKey(certPath, keyPath string) (*CertKey, error) {
certBytes, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
cert, err := ParseCertPEM(certBytes)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyBytes)
if err != nil {
return nil, err
}
return &CertKey{Certificate: cert, DER: cert.Raw, Key: key}, nil
}
// ParseCertPEM parses the first CERTIFICATE block from PEM bytes.
func ParseCertPEM(b []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(b)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("no CERTIFICATE PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
// ParseKeyPEM parses an RSA private key from a PKCS#8 or PKCS#1 PEM block.
func ParseKeyPEM(b []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(b)
if block == nil {
return nil, fmt.Errorf("no PRIVATE KEY PEM block found")
}
if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
if rk, ok := k.(*rsa.PrivateKey); ok {
return rk, nil
}
return nil, fmt.Errorf("private key is not RSA")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}