63701dd086
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>
140 lines
4.5 KiB
Go
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)
|
|
}
|