d865b83b
feat(server): server key material is encrypted at rest
a73x 2026-08-08 08:36
Commit message
docs/quickstart.md
| Old | New | ||
|---|---|---|---|
| @@ -217,6 +217,7 @@ Set `SERVER_ADDR`, paste the rest: | |||
| 217 | SERVER_ADDR=192.0.2.10 | 217 | SERVER_ADDR=192.0.2.10 |
| 218 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current | 218 | IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current |
| 219 | HOST_SECRET=$(openssl rand -hex 32) | 219 | HOST_SECRET=$(openssl rand -hex 32) |
| 220 | KEK=$(openssl rand -hex 32) | ||
| 220 | sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; } | 221 | sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; } |
| 221 | AMD64_SHA=$(sha amd64) | 222 | AMD64_SHA=$(sha amd64) |
| 222 | ARM64_SHA=$(sha arm64) | 223 | ARM64_SHA=$(sha arm64) |
| @@ -235,6 +236,7 @@ sudo tee /etc/eitri/server.json >/dev/null <<EOF | |||
| 235 | "public_url": "http://$SERVER_ADDR:8080" | 236 | "public_url": "http://$SERVER_ADDR:8080" |
| 236 | }, | 237 | }, |
| 237 | "host_secret": "$HOST_SECRET", | 238 | "host_secret": "$HOST_SECRET", |
| 239 | "key_encryption_key": "$KEK", | ||
| 238 | "default_images": { | 240 | "default_images": { |
| 239 | "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"}, | 241 | "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"}, |
| 240 | "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"} | 242 | "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"} |
| @@ -248,8 +250,16 @@ EOF | |||
| 248 | sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.json | 250 | sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.json |
| 249 | ``` | 251 | ``` |
| 250 | 252 | ||
| 251 | The chmod matters: `server.json` carries `host_secret`, so it is root-owned | 253 | The chmod matters: `server.json` carries `host_secret` and |
| 252 | and readable only via the `eitri` group—not world-readable. | 254 | `key_encryption_key`—the key that encrypts everything eitri signs with, the host |
| 255 | CA and gate host key in `/var/lib/eitri` included—so it is root-owned and | ||
| 256 | readable only via the `eitri` group, not world-readable. | ||
| 257 | |||
| 258 | Keep a copy of `server.json` somewhere other than the machine it runs on, and | ||
| 259 | somewhere other than your backups of `/var/lib/eitri`. Separating the two is | ||
| 260 | what makes a stolen disk useless; losing the config while keeping the disk is | ||
| 261 | what makes your own backups useless. Restoring this server elsewhere needs | ||
| 262 | both. | ||
| 253 | 263 | ||
| 254 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any | 264 | `advertise_*` is what hosts and your laptop dial. Not `127.0.0.1`. Any |
| 255 | cloud-init disk image works as a default image; the Ubuntu one boots out of | 265 | cloud-init disk image works as a default image; the Ubuntu one boots out of |
docs/shape.html
| Old | New | ||
|---|---|---|---|
| @@ -484,10 +484,18 @@ | |||
| 484 | ] | 484 | ] |
| 485 | }, | 485 | }, |
| 486 | { | 486 | { |
| 487 | "importPath": "internal/server/seal", | ||
| 488 | "plane": "control", | ||
| 489 | "synopsis": "Package seal encrypts the key material eitri holds, so that what rests on disk is not what signs.", | ||
| 490 | "imports": [] | ||
| 491 | }, | ||
| 492 | { | ||
| 487 | "importPath": "internal/server/sshca", | 493 | "importPath": "internal/server/sshca", |
| 488 | "plane": "control", | 494 | "plane": "control", |
| 489 | "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", | 495 | "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", |
| 490 | "imports": [] | 496 | "imports": [ |
| 497 | "internal/server/seal" | ||
| 498 | ] | ||
| 491 | }, | 499 | }, |
| 492 | { | 500 | { |
| 493 | "importPath": "internal/server/sshgate", | 501 | "importPath": "internal/server/sshgate", |
docs/shape.json
| Old | New | ||
|---|---|---|---|
| @@ -433,10 +433,18 @@ | |||
| 433 | ] | 433 | ] |
| 434 | }, | 434 | }, |
| 435 | { | 435 | { |
| 436 | "importPath": "internal/server/seal", | ||
| 437 | "plane": "control", | ||
| 438 | "synopsis": "Package seal encrypts the key material eitri holds, so that what rests on disk is not what signs.", | ||
| 439 | "imports": [] | ||
| 440 | }, | ||
| 441 | { | ||
| 436 | "importPath": "internal/server/sshca", | 442 | "importPath": "internal/server/sshca", |
| 437 | "plane": "control", | 443 | "plane": "control", |
| 438 | "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", | 444 | "synopsis": "Package sshca manages eitri's SSH key material: a persistent user CA (whose short-lived certs authenticate admins to the jump gate and VMs) and a persistent gate host key.", |
| 439 | "imports": [] | 445 | "imports": [ |
| 446 | "internal/server/seal" | ||
| 447 | ] | ||
| 440 | }, | 448 | }, |
| 441 | { | 449 | { |
| 442 | "importPath": "internal/server/sshgate", | 450 | "importPath": "internal/server/sshgate", |
internal/server/boot/boot.go
| Old | New | ||
|---|---|---|---|
| @@ -58,6 +58,14 @@ func run(cfgPath string) error { | |||
| 58 | return fmt.Errorf("config %s: %w", cfgPath, err) | 58 | return fmt.Errorf("config %s: %w", cfgPath, err) |
| 59 | } | 59 | } |
| 60 | 60 | ||
| 61 | // The key that seals every piece of key material this server holds, decoded | ||
| 62 | // once and handed to each place that seals or opens: the gate's key files | ||
| 63 | // (setupSSHGate). Load has already enforced it. | ||
| 64 | kek, err := cfg.KEKBytes() | ||
| 65 | if err != nil { | ||
| 66 | return fmt.Errorf("config: %w", err) | ||
| 67 | } | ||
| 68 | |||
| 61 | st, err := store.Open(cfg.DBPath, cfg.CIDRPool) | 69 | st, err := store.Open(cfg.DBPath, cfg.CIDRPool) |
| 62 | if err != nil { | 70 | if err != nil { |
| 63 | return fmt.Errorf("open store: %w", err) | 71 | return fmt.Errorf("open store: %w", err) |
| @@ -132,7 +140,7 @@ func run(cfgPath string) error { | |||
| 132 | // SSH jump gate (§B): nil when ssh_listen is unset (gate OFF); see | 140 | // SSH jump gate (§B): nil when ssh_listen is unset (gate OFF); see |
| 133 | // setupSSHGate. The listener itself is started below, once syncsvc.Service | 141 | // setupSSHGate. The listener itself is started below, once syncsvc.Service |
| 134 | // (the tunnel dialer) exists. | 142 | // (the tunnel dialer) exists. |
| 135 | sshGate, err := setupSSHGate(cfg) | 143 | sshGate, err := setupSSHGate(cfg, kek) |
| 136 | if err != nil { | 144 | if err != nil { |
| 137 | return err | 145 | return err |
| 138 | } | 146 | } |
internal/server/boot/sealedgate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,129 @@ | |||
| 1 | package boot | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | serverconfig "github.com/a73x/eitri/internal/server/config" | ||
| 10 | "github.com/a73x/eitri/internal/server/seal" | ||
| 11 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // gateConfig points a jump-gate config at a fresh directory, so each case gets | ||
| 16 | // its own key files. | ||
| 17 | func gateConfig(t *testing.T) serverconfig.Config { | ||
| 18 | t.Helper() | ||
| 19 | dir := t.TempDir() | ||
| 20 | return serverconfig.Config{ | ||
| 21 | SSHListen: "127.0.0.1:0", | ||
| 22 | SSHCAKey: filepath.Join(dir, "ssh_ca_key"), | ||
| 23 | SSHHostKey: filepath.Join(dir, "ssh_host_key"), | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | // kekFilled returns a distinct 32-byte key-encryption key per fill byte. | ||
| 28 | func kekFilled(fill byte) []byte { return bytes.Repeat([]byte{fill}, seal.KEKSize) } | ||
| 29 | |||
| 30 | // TestSetupSSHGateSealsWhatItWrites is the claim this wiring exists to make: | ||
| 31 | // the key material the gate creates is ciphertext on disk. A lifted volume or a | ||
| 32 | // nightly backup carries the file, and the file alone signs nothing. | ||
| 33 | func TestSetupSSHGateSealsWhatItWrites(t *testing.T) { | ||
| 34 | cfg := gateConfig(t) | ||
| 35 | |||
| 36 | g, err := setupSSHGate(cfg, kekFilled(0x2b)) | ||
| 37 | require.NoError(t, err) | ||
| 38 | require.NotNil(t, g) | ||
| 39 | |||
| 40 | for _, path := range []string{cfg.SSHCAKey, cfg.SSHHostKey} { | ||
| 41 | stored, rerr := os.ReadFile(path) | ||
| 42 | require.NoError(t, rerr) | ||
| 43 | assert.True(t, seal.IsSealed(string(stored)), | ||
| 44 | "%s must rest sealed, not as a readable key", filepath.Base(path)) | ||
| 45 | assert.NotContains(t, string(stored), "PRIVATE KEY", | ||
| 46 | "%s must not carry a parseable PEM", filepath.Base(path)) | ||
| 47 | } | ||
| 48 | |||
| 49 | // The gate is nonetheless usable: what it hands out is the opened key. | ||
| 50 | assert.NotEmpty(t, g.ca.HostCAAuthorizedKey()) | ||
| 51 | } | ||
| 52 | |||
| 53 | // TestSetupSSHGateKeepsItsIdentityAcrossBoots: the host CA is what every client | ||
| 54 | // pins with @cert-authority and what every VM's host certificate is signed by, | ||
| 55 | // so the same KEK must yield the same identity every time. | ||
| 56 | func TestSetupSSHGateKeepsItsIdentityAcrossBoots(t *testing.T) { | ||
| 57 | cfg := gateConfig(t) | ||
| 58 | kek := kekFilled(0x2b) | ||
| 59 | |||
| 60 | first, err := setupSSHGate(cfg, kek) | ||
| 61 | require.NoError(t, err) | ||
| 62 | second, err := setupSSHGate(cfg, kek) | ||
| 63 | require.NoError(t, err) | ||
| 64 | |||
| 65 | assert.Equal(t, string(first.ca.HostCAAuthorizedKey()), string(second.ca.HostCAAuthorizedKey()), | ||
| 66 | "a reboot must come back as the same CA every client already trusts") | ||
| 67 | assert.Equal(t, first.ca.HostKey().PublicKey().Marshal(), second.ca.HostKey().PublicKey().Marshal()) | ||
| 68 | } | ||
| 69 | |||
| 70 | // TestSetupSSHGateRefusesTheWrongKEK is the failure mode that matters most. A | ||
| 71 | // plane handed the wrong key_encryption_key cannot open its own host CA, and | ||
| 72 | // the only two things it could do are stop or mint a replacement. Minting one | ||
| 73 | // would present every client with an unknown CA and every VM with an | ||
| 74 | // uncertifiable host key — indistinguishable, from the outside, from an attack. | ||
| 75 | // So it stops, and it leaves the key exactly where it found it. | ||
| 76 | func TestSetupSSHGateRefusesTheWrongKEK(t *testing.T) { | ||
| 77 | cfg := gateConfig(t) | ||
| 78 | |||
| 79 | _, err := setupSSHGate(cfg, kekFilled(0x2b)) | ||
| 80 | require.NoError(t, err) | ||
| 81 | before, err := os.ReadFile(cfg.SSHCAKey) | ||
| 82 | require.NoError(t, err) | ||
| 83 | |||
| 84 | g, refusal := setupSSHGate(cfg, kekFilled(0x7c)) | ||
| 85 | require.Error(t, refusal, "a key that will not open must stop the server") | ||
| 86 | assert.Nil(t, g) | ||
| 87 | assert.NotContains(t, refusal.Error(), string(kekFilled(0x7c)), | ||
| 88 | "a failure must name none of the material") | ||
| 89 | |||
| 90 | after, err := os.ReadFile(cfg.SSHCAKey) | ||
| 91 | require.NoError(t, err) | ||
| 92 | assert.Equal(t, before, after, "the unreadable key must be left intact, never regenerated over") | ||
| 93 | } | ||
| 94 | |||
| 95 | // TestSetupSSHGateOffWritesNothing: with no listen address there is no gate, | ||
| 96 | // so there is no key to seal and the KEK is beside the point. | ||
| 97 | func TestSetupSSHGateOffWritesNothing(t *testing.T) { | ||
| 98 | cfg := gateConfig(t) | ||
| 99 | cfg.SSHListen = "" | ||
| 100 | |||
| 101 | g, err := setupSSHGate(cfg, kekFilled(0x2b)) | ||
| 102 | require.NoError(t, err) | ||
| 103 | assert.Nil(t, g, "no ssh_listen means no gate") | ||
| 104 | |||
| 105 | _, err = os.Stat(cfg.SSHCAKey) | ||
| 106 | assert.True(t, os.IsNotExist(err), "a gate that is off creates no key material") | ||
| 107 | } | ||
| 108 | |||
| 109 | // TestSetupSSHGateNeedsBothKeyPaths: a gate that is on but has nowhere to keep | ||
| 110 | // its keys is a misconfiguration, and it is caught at startup rather than at | ||
| 111 | // the first connection. | ||
| 112 | func TestSetupSSHGateNeedsBothKeyPaths(t *testing.T) { | ||
| 113 | for _, tc := range []struct{ name, clear string }{ | ||
| 114 | {"no ca key", "ca"}, | ||
| 115 | {"no host key", "host"}, | ||
| 116 | } { | ||
| 117 | t.Run(tc.name, func(t *testing.T) { | ||
| 118 | cfg := gateConfig(t) | ||
| 119 | if tc.clear == "ca" { | ||
| 120 | cfg.SSHCAKey = "" | ||
| 121 | } else { | ||
| 122 | cfg.SSHHostKey = "" | ||
| 123 | } | ||
| 124 | _, err := setupSSHGate(cfg, kekFilled(0x2b)) | ||
| 125 | require.Error(t, err) | ||
| 126 | assert.Contains(t, err.Error(), "ssh_ca_key and ssh_host_key are required") | ||
| 127 | }) | ||
| 128 | } | ||
| 129 | } | ||
internal/server/boot/sshgate.go
| Old | New | ||
|---|---|---|---|
| @@ -27,16 +27,18 @@ type sshGateSetup struct { | |||
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set | 29 | // setupSSHGate wires the SSH jump gate (§B): OFF unless ssh_listen is set |
| 30 | // (returns nil). When enabled, load or create the persistent user CA + gate | 30 | // (returns nil). When enabled, load or create the persistent host CA + gate |
| 31 | // host key (0600, never logged). | 31 | // host key (0600, sealed under kek, never logged). A key file that will not |
| 32 | func setupSSHGate(cfg serverconfig.Config) (*sshGateSetup, error) { | 32 | // open is a startup failure: sshca never regenerates over one, because a fresh |
| 33 | // host CA would invalidate every pin and every VM's host certificate at once. | ||
| 34 | func setupSSHGate(cfg serverconfig.Config, kek []byte) (*sshGateSetup, error) { | ||
| 33 | if cfg.SSHListen == "" { | 35 | if cfg.SSHListen == "" { |
| 34 | return nil, nil | 36 | return nil, nil |
| 35 | } | 37 | } |
| 36 | if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" { | 38 | if cfg.SSHCAKey == "" || cfg.SSHHostKey == "" { |
| 37 | return nil, errors.New("ssh_ca_key and ssh_host_key are required when ssh_listen is set") | 39 | return nil, errors.New("ssh_ca_key and ssh_host_key are required when ssh_listen is set") |
| 38 | } | 40 | } |
| 39 | sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey) | 41 | sshGate, err := sshca.New(cfg.SSHCAKey, cfg.SSHHostKey, kek) |
| 40 | if err != nil { | 42 | if err != nil { |
| 41 | return nil, fmt.Errorf("ssh ca: %w", err) | 43 | return nil, fmt.Errorf("ssh ca: %w", err) |
| 42 | } | 44 | } |
internal/server/config/config.go
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,21 @@ type Config struct { | |||
| 12 | // detect a stale token in an old config and warn the operator to remove it. | 12 | // detect a stale token in an old config and warn the operator to remove it. |
| 13 | AdminToken string `json:"admin_token"` | 13 | AdminToken string `json:"admin_token"` |
| 14 | HostSecret string `json:"host_secret"` | 14 | HostSecret string `json:"host_secret"` |
| 15 | CIDRPool string `json:"cidr_pool"` | 15 | // KeyEncryptionKey seals every piece of key material this server holds: the |
| 16 | // host CA and gate host key on disk (ssh_ca_key, ssh_host_key) and each | ||
| 17 | // tenant's opt-in managed CA in the database. 64 hex characters (32 bytes, | ||
| 18 | // minted with `openssl rand -hex 32`). | ||
| 19 | // | ||
| 20 | // It lives HERE, in the config, and nowhere near the data it protects — so a | ||
| 21 | // copied database, a nightly backup, or a lifted volume carries ciphertext | ||
| 22 | // and no signing power. That separation is the whole mechanism: keep this | ||
| 23 | // file's custody apart from the data's. | ||
| 24 | // | ||
| 25 | // Required, and never rotated in place. Losing it loses the host CA with it, | ||
| 26 | // and with the host CA goes the plane's identity — every `@cert-authority` | ||
| 27 | // pin and every VM host certificate names it. | ||
| 28 | KeyEncryptionKey string `json:"key_encryption_key"` | ||
| 29 | CIDRPool string `json:"cidr_pool"` | ||
| 16 | // DefaultImageURL/SHA are retired in favour of DefaultImages: one image for | 30 | // DefaultImageURL/SHA are retired in favour of DefaultImages: one image for |
| 17 | // the whole fleet is only correct while every host shares an architecture. | 31 | // the whole fleet is only correct while every host shares an architecture. |
| 18 | // Kept so the server can spot them in an old config and say what to write. | 32 | // Kept so the server can spot them in an old config and say what to write. |
internal/server/config/load.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ | |||
| 5 | package config | 5 | package config |
| 6 | 6 | ||
| 7 | import ( | 7 | import ( |
| 8 | "encoding/hex" | ||
| 8 | "encoding/json" | 9 | "encoding/json" |
| 9 | "fmt" | 10 | "fmt" |
| 10 | "log/slog" | 11 | "log/slog" |
| @@ -61,6 +62,13 @@ func validate(cfg Config) error { | |||
| 61 | if cfg.HostSecret == "" { | 62 | if cfg.HostSecret == "" { |
| 62 | return fmt.Errorf("host_secret is required") | 63 | return fmt.Errorf("host_secret is required") |
| 63 | } | 64 | } |
| 65 | // The key that seals this server's key material. Checked at boot rather than | ||
| 66 | // at first use: a plane whose KEK is missing or mistyped can open neither | ||
| 67 | // its own host CA nor the managed CAs it holds, and that is a refusal to | ||
| 68 | // start, not a surprise on some later request. | ||
| 69 | if _, err := cfg.KEKBytes(); err != nil { | ||
| 70 | return err | ||
| 71 | } | ||
| 64 | // The server is a pure OIDC relying party (spec §2): issuer, client_id and | 72 | // The server is a pure OIDC relying party (spec §2): issuer, client_id and |
| 65 | // public_url are required. Collect every missing key so the operator fixes | 73 | // public_url are required. Collect every missing key so the operator fixes |
| 66 | // server.json in one pass rather than one restart per key. | 74 | // server.json in one pass rather than one restart per key. |
| @@ -103,6 +111,26 @@ func validate(cfg Config) error { | |||
| 103 | return nil | 111 | return nil |
| 104 | } | 112 | } |
| 105 | 113 | ||
| 114 | // KEKBytes decodes key_encryption_key into the raw key that seals every piece | ||
| 115 | // of key material this server holds. Load enforces it, so a config that loaded | ||
| 116 | // has one; the boot wiring decodes it once and hands the bytes to each place | ||
| 117 | // that seals or opens. | ||
| 118 | // | ||
| 119 | // The errors state the rule and the command that satisfies it, and never echo | ||
| 120 | // the value — a key does not belong in a startup log. | ||
| 121 | func (c Config) KEKBytes() ([]byte, error) { | ||
| 122 | const size = 32 // AES-256 | ||
| 123 | if c.KeyEncryptionKey == "" { | ||
| 124 | return nil, fmt.Errorf("key_encryption_key is required (%d hex characters, minted with `openssl rand -hex %d`): "+ | ||
| 125 | "it encrypts the host CA and the gate host key at rest", size*2, size) | ||
| 126 | } | ||
| 127 | kek, err := hex.DecodeString(c.KeyEncryptionKey) | ||
| 128 | if err != nil || len(kek) != size { | ||
| 129 | return nil, fmt.Errorf("key_encryption_key must be %d hex characters (%d bytes, minted with `openssl rand -hex %d`)", size*2, size, size) | ||
| 130 | } | ||
| 131 | return kek, nil | ||
| 132 | } | ||
| 133 | |||
| 106 | // ParseDuration parses raw (a config duration string) for the knob named name | 134 | // ParseDuration parses raw (a config duration string) for the knob named name |
| 107 | // (the JSON field label echoed in errors). Empty raw keeps def. valid, when | 135 | // (the JSON field label echoed in errors). Empty raw keeps def. valid, when |
| 108 | // non-nil, is the knob's range rule; rule (e.g. ">= 0") spells it in the | 136 | // non-nil, is the knob's range rule; rule (e.g. ">= 0") spells it in the |
internal/server/config/load_test.go
| Old | New | ||
|---|---|---|---|
| @@ -8,9 +8,13 @@ import ( | |||
| 8 | "time" | 8 | "time" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | // kekHex is a valid key_encryption_key: 64 hex characters. | ||
| 12 | const kekHex = "2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b" | ||
| 13 | |||
| 11 | // minimal is a config that passes every validation rule. | 14 | // minimal is a config that passes every validation rule. |
| 12 | const minimal = `{ | 15 | const minimal = `{ |
| 13 | "host_secret": "s3cret", | 16 | "host_secret": "s3cret", |
| 17 | "key_encryption_key": "` + kekHex + `", | ||
| 14 | "advertise_http": "http://192.0.2.1:8080", | 18 | "advertise_http": "http://192.0.2.1:8080", |
| 15 | "advertise_quic": "192.0.2.1:8443", | 19 | "advertise_quic": "192.0.2.1:8443", |
| 16 | "oidc": { | 20 | "oidc": { |
| @@ -55,10 +59,49 @@ func TestLoadRequiresHostSecret(t *testing.T) { | |||
| 55 | } | 59 | } |
| 56 | } | 60 | } |
| 57 | 61 | ||
| 62 | // TestLoadValidatesTheKEK: the key that seals every tenant's managed-CA | ||
| 63 | // signing key is checked at boot, so a plane that would write keys nothing can | ||
| 64 | // read (or fail to read the ones it holds) refuses to start. | ||
| 65 | func TestLoadValidatesTheKEK(t *testing.T) { | ||
| 66 | for _, tc := range []struct { | ||
| 67 | name, kek string | ||
| 68 | ok bool | ||
| 69 | }{ | ||
| 70 | {"64 hex chars", kekHex, true}, | ||
| 71 | {"uppercase hex", strings.ToUpper(kekHex), true}, | ||
| 72 | {"missing", "", false}, | ||
| 73 | {"too short", kekHex[:62], false}, | ||
| 74 | {"too long", kekHex + "ab", false}, | ||
| 75 | {"odd length", kekHex[:63], false}, | ||
| 76 | {"not hex", strings.Repeat("z", 64), false}, | ||
| 77 | {"a passphrase", "correct horse battery staple correct horse battery staple xxxxxx", false}, | ||
| 78 | } { | ||
| 79 | t.Run(tc.name, func(t *testing.T) { | ||
| 80 | cfg, err := Load(write(t, strings.Replace(minimal, kekHex, tc.kek, 1))) | ||
| 81 | if !tc.ok { | ||
| 82 | if err == nil || !strings.Contains(err.Error(), "key_encryption_key") { | ||
| 83 | t.Fatalf("want a rejection naming key_encryption_key, got %v", err) | ||
| 84 | } | ||
| 85 | if strings.Contains(err.Error(), tc.kek) && tc.kek != "" { | ||
| 86 | t.Errorf("the error echoes the key: %v", err) | ||
| 87 | } | ||
| 88 | return | ||
| 89 | } | ||
| 90 | if err != nil { | ||
| 91 | t.Fatalf("valid kek rejected: %v", err) | ||
| 92 | } | ||
| 93 | kek, err := cfg.KEKBytes() | ||
| 94 | if err != nil || len(kek) != 32 { | ||
| 95 | t.Fatalf("KEKBytes: %v (%d bytes, want 32)", err, len(kek)) | ||
| 96 | } | ||
| 97 | }) | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 58 | // TestLoadNamesEveryMissingOIDCKey pins that the operator sees all missing | 101 | // TestLoadNamesEveryMissingOIDCKey pins that the operator sees all missing |
| 59 | // oidc keys in ONE error, not one restart per key. | 102 | // oidc keys in ONE error, not one restart per key. |
| 60 | func TestLoadNamesEveryMissingOIDCKey(t *testing.T) { | 103 | func TestLoadNamesEveryMissingOIDCKey(t *testing.T) { |
| 61 | _, err := Load(write(t, `{"host_secret": "s", "advertise_http": "h", "advertise_quic": "q"}`)) | 104 | _, err := Load(write(t, `{"host_secret": "s", "key_encryption_key": "`+kekHex+`", "advertise_http": "h", "advertise_quic": "q"}`)) |
| 62 | if err == nil { | 105 | if err == nil { |
| 63 | t.Fatal("empty oidc block accepted") | 106 | t.Fatal("empty oidc block accepted") |
| 64 | } | 107 | } |
internal/server/seal/seal.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,99 @@ | |||
| 1 | // Package seal encrypts the key material eitri holds, so that what rests on | ||
| 2 | // disk is not what signs. One key-encryption key — the server's | ||
| 3 | // key_encryption_key, which lives in its config and nowhere else — protects | ||
| 4 | // every piece: the host CA and gate host key in the data directory, and each | ||
| 5 | // tenant's opt-in managed CA in the database. A stolen database, a copied | ||
| 6 | // backup, or a lifted PVC yields ciphertext and nothing signable. | ||
| 7 | // | ||
| 8 | // The package is pure: bytes in, bytes out, no files and no store. Where each | ||
| 9 | // sealed thing lives is its own package's business. | ||
| 10 | package seal | ||
| 11 | |||
| 12 | import ( | ||
| 13 | "crypto/aes" | ||
| 14 | "crypto/cipher" | ||
| 15 | "crypto/rand" | ||
| 16 | "encoding/base64" | ||
| 17 | "errors" | ||
| 18 | "fmt" | ||
| 19 | "strings" | ||
| 20 | ) | ||
| 21 | |||
| 22 | // KEKSize is the key-encryption key's length in bytes: AES-256. | ||
| 23 | const KEKSize = 32 | ||
| 24 | |||
| 25 | // Version prefixes every sealed value. It is a version, not decoration: a | ||
| 26 | // second scheme can be added beside this one and told apart by the value | ||
| 27 | // itself, and anything unrecognized is refused rather than guessed at. It is | ||
| 28 | // also what lets a reader tell a sealed file from the plaintext PEM that used | ||
| 29 | // to sit there — no PEM starts with it. | ||
| 30 | const Version = "v1:" | ||
| 31 | |||
| 32 | // IsSealed reports whether s carries the sealed form. Callers reading a file | ||
| 33 | // that may predate sealing use it to tell the two apart. | ||
| 34 | func IsSealed(s string) bool { return strings.HasPrefix(s, Version) } | ||
| 35 | |||
| 36 | // Seal encrypts plaintext for storage. The sealed form is | ||
| 37 | // Version + base64(nonce || AES-256-GCM ciphertext), one fresh nonce per call. | ||
| 38 | // | ||
| 39 | // Errors never echo the plaintext or the KEK. | ||
| 40 | func Seal(kek []byte, plaintext string) (string, error) { | ||
| 41 | gcm, err := aead(kek) | ||
| 42 | if err != nil { | ||
| 43 | return "", err | ||
| 44 | } | ||
| 45 | nonce := make([]byte, gcm.NonceSize()) | ||
| 46 | if _, err := rand.Read(nonce); err != nil { | ||
| 47 | return "", fmt.Errorf("seal: nonce: %w", err) | ||
| 48 | } | ||
| 49 | blob := gcm.Seal(nonce, nonce, []byte(plaintext), nil) | ||
| 50 | return Version + base64.StdEncoding.EncodeToString(blob), nil | ||
| 51 | } | ||
| 52 | |||
| 53 | // Open decrypts a value written by Seal. Every failure — a form it does not | ||
| 54 | // recognize, a KEK that is not the one it was sealed with, a byte changed | ||
| 55 | // anywhere in the value — is the same refusal: the caller gets nothing. GCM's | ||
| 56 | // authentication is what makes tampering a failure rather than a subtly wrong | ||
| 57 | // key. | ||
| 58 | // | ||
| 59 | // Errors never echo the plaintext, the KEK, or the sealed value. | ||
| 60 | func Open(kek []byte, blob string) (string, error) { | ||
| 61 | body, ok := strings.CutPrefix(blob, Version) | ||
| 62 | if !ok { | ||
| 63 | return "", errors.New("open: unrecognized sealed format") | ||
| 64 | } | ||
| 65 | raw, err := base64.StdEncoding.DecodeString(body) | ||
| 66 | if err != nil { | ||
| 67 | return "", errors.New("open: sealed value is not valid base64") | ||
| 68 | } | ||
| 69 | gcm, err := aead(kek) | ||
| 70 | if err != nil { | ||
| 71 | return "", err | ||
| 72 | } | ||
| 73 | if len(raw) < gcm.NonceSize() { | ||
| 74 | return "", errors.New("open: sealed value is truncated") | ||
| 75 | } | ||
| 76 | plaintext, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil) | ||
| 77 | if err != nil { | ||
| 78 | return "", errors.New("open: sealed value failed authentication — " + | ||
| 79 | "key_encryption_key is not the key this was sealed with, or the stored value was altered") | ||
| 80 | } | ||
| 81 | return string(plaintext), nil | ||
| 82 | } | ||
| 83 | |||
| 84 | // aead builds the cipher both directions share, and is the one place the KEK's | ||
| 85 | // length is enforced. | ||
| 86 | func aead(kek []byte) (cipher.AEAD, error) { | ||
| 87 | if len(kek) != KEKSize { | ||
| 88 | return nil, fmt.Errorf("key encryption key must be %d bytes, got %d", KEKSize, len(kek)) | ||
| 89 | } | ||
| 90 | block, err := aes.NewCipher(kek) | ||
| 91 | if err != nil { | ||
| 92 | return nil, errors.New("key encryption key is unusable as an AES key") | ||
| 93 | } | ||
| 94 | gcm, err := cipher.NewGCM(block) | ||
| 95 | if err != nil { | ||
| 96 | return nil, errors.New("cipher is unusable") | ||
| 97 | } | ||
| 98 | return gcm, nil | ||
| 99 | } | ||
internal/server/seal/seal_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,113 @@ | |||
| 1 | package seal | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/base64" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // testKEK is a fixed key-encryption key; kek2 is a different one of the same | ||
| 14 | // size, for the wrong-key case. | ||
| 15 | var ( | ||
| 16 | testKEK = bytes.Repeat([]byte{0x2b}, KEKSize) | ||
| 17 | kek2 = bytes.Repeat([]byte{0x7f}, KEKSize) | ||
| 18 | ) | ||
| 19 | |||
| 20 | // samplePEM stands in for the key material this package protects — a private | ||
| 21 | // key PEM, whether it came from a file or a database column. | ||
| 22 | const samplePEM = "-----BEGIN OPENSSH PRIVATE KEY-----\nc29tZS1rZXktbWF0ZXJpYWw=\n-----END OPENSSH PRIVATE KEY-----\n" | ||
| 23 | |||
| 24 | // TestSealOpenRoundTrip: what was sealed comes back byte for byte. | ||
| 25 | func TestSealOpenRoundTrip(t *testing.T) { | ||
| 26 | blob, err := Seal(testKEK, samplePEM) | ||
| 27 | require.NoError(t, err) | ||
| 28 | |||
| 29 | got, err := Open(testKEK, blob) | ||
| 30 | require.NoError(t, err) | ||
| 31 | assert.Equal(t, samplePEM, got) | ||
| 32 | } | ||
| 33 | |||
| 34 | // TestSealHidesThePlaintext is the property the whole feature exists for: what | ||
| 35 | // gets stored carries no trace of the key. | ||
| 36 | func TestSealHidesThePlaintext(t *testing.T) { | ||
| 37 | blob, err := Seal(testKEK, samplePEM) | ||
| 38 | require.NoError(t, err) | ||
| 39 | |||
| 40 | assert.NotContains(t, blob, "PRIVATE KEY") | ||
| 41 | assert.NotContains(t, blob, samplePEM) | ||
| 42 | assert.True(t, IsSealed(blob), "the stored form is versioned, got %q", blob[:min(8, len(blob))]) | ||
| 43 | } | ||
| 44 | |||
| 45 | // TestIsSealedTellsTheFormsApart: a file written before sealing holds a PEM, | ||
| 46 | // and the load path decides what to do by looking at exactly this. | ||
| 47 | func TestIsSealedTellsTheFormsApart(t *testing.T) { | ||
| 48 | assert.False(t, IsSealed(samplePEM)) | ||
| 49 | assert.False(t, IsSealed("")) | ||
| 50 | assert.False(t, IsSealed("v2:abc")) | ||
| 51 | |||
| 52 | blob, err := Seal(testKEK, samplePEM) | ||
| 53 | require.NoError(t, err) | ||
| 54 | assert.True(t, IsSealed(blob)) | ||
| 55 | } | ||
| 56 | |||
| 57 | // TestSealUsesAFreshNonce: sealing the same key twice must not produce the same | ||
| 58 | // bytes, or a reader could tell two holders share a key. | ||
| 59 | func TestSealUsesAFreshNonce(t *testing.T) { | ||
| 60 | first, err := Seal(testKEK, "the-same-key") | ||
| 61 | require.NoError(t, err) | ||
| 62 | second, err := Seal(testKEK, "the-same-key") | ||
| 63 | require.NoError(t, err) | ||
| 64 | assert.NotEqual(t, first, second) | ||
| 65 | } | ||
| 66 | |||
| 67 | // TestOpenFailsClosed walks every way a sealed value can be wrong. All of them | ||
| 68 | // are the same outcome — nothing — and none of the errors quote the material. | ||
| 69 | func TestOpenFailsClosed(t *testing.T) { | ||
| 70 | blob, err := Seal(testKEK, samplePEM) | ||
| 71 | require.NoError(t, err) | ||
| 72 | |||
| 73 | for _, tc := range []struct { | ||
| 74 | name, blob string | ||
| 75 | kek []byte | ||
| 76 | }{ | ||
| 77 | {"wrong kek", blob, kek2}, | ||
| 78 | {"tampered ciphertext", flipLastByte(t, blob), testKEK}, | ||
| 79 | {"unknown prefix", "v2:" + strings.TrimPrefix(blob, Version), testKEK}, | ||
| 80 | {"no prefix at all", strings.TrimPrefix(blob, Version), testKEK}, | ||
| 81 | {"plaintext pem", samplePEM, testKEK}, | ||
| 82 | {"not base64", Version + "!!!not-base64!!!", testKEK}, | ||
| 83 | {"truncated", Version + base64.StdEncoding.EncodeToString([]byte("short")), testKEK}, | ||
| 84 | {"kek of the wrong length", blob, testKEK[:16]}, | ||
| 85 | {"no kek", blob, nil}, | ||
| 86 | } { | ||
| 87 | t.Run(tc.name, func(t *testing.T) { | ||
| 88 | got, err := Open(tc.kek, tc.blob) | ||
| 89 | require.Error(t, err) | ||
| 90 | assert.Empty(t, got) | ||
| 91 | assert.NotContains(t, err.Error(), "PRIVATE KEY", "errors never echo key material") | ||
| 92 | assert.NotContains(t, err.Error(), tc.blob, "errors never echo the sealed value") | ||
| 93 | }) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | // TestSealRefusesAWrongSizedKEK: a short or absent KEK is a configuration | ||
| 98 | // mistake, and sealing under one would be worse than refusing. | ||
| 99 | func TestSealRefusesAWrongSizedKEK(t *testing.T) { | ||
| 100 | for _, kek := range [][]byte{nil, {}, testKEK[:31], append(bytes.Clone(testKEK), 0x00)} { | ||
| 101 | _, err := Seal(kek, samplePEM) | ||
| 102 | assert.Error(t, err) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | // flipLastByte changes one byte of a sealed value's ciphertext. | ||
| 107 | func flipLastByte(t *testing.T, blob string) string { | ||
| 108 | t.Helper() | ||
| 109 | raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(blob, Version)) | ||
| 110 | require.NoError(t, err) | ||
| 111 | raw[len(raw)-1] ^= 0xff | ||
| 112 | return Version + base64.StdEncoding.EncodeToString(raw) | ||
| 113 | } | ||
internal/server/sshca/sealed_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,205 @@ | |||
| 1 | package sshca | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/server/seal" | ||
| 10 | "golang.org/x/crypto/ssh" | ||
| 11 | ) | ||
| 12 | |||
| 13 | // read returns a key file's contents as a string. | ||
| 14 | func read(t *testing.T, path string) string { | ||
| 15 | t.Helper() | ||
| 16 | b, err := os.ReadFile(path) | ||
| 17 | if err != nil { | ||
| 18 | t.Fatalf("read %s: %v", path, err) | ||
| 19 | } | ||
| 20 | return string(b) | ||
| 21 | } | ||
| 22 | |||
| 23 | // plaintextKey writes an unsealed key file — the shape a plane that predates | ||
| 24 | // sealing has on disk — and returns its path and public key. | ||
| 25 | func plaintextKey(t *testing.T, dir string) (path string, pub string) { | ||
| 26 | t.Helper() | ||
| 27 | pemBytes, signer, err := GenerateHostKey() | ||
| 28 | if err != nil { | ||
| 29 | t.Fatalf("GenerateHostKey: %v", err) | ||
| 30 | } | ||
| 31 | path = filepath.Join(dir, "ca") | ||
| 32 | if err := os.WriteFile(path, pemBytes, 0o600); err != nil { | ||
| 33 | t.Fatalf("write plaintext key: %v", err) | ||
| 34 | } | ||
| 35 | return path, AuthorizedKeyLine(signer.PublicKey()) | ||
| 36 | } | ||
| 37 | |||
| 38 | // TestFirstBootWritesASealedKey: a key generated today is never written in the | ||
| 39 | // clear, not even briefly. | ||
| 40 | func TestFirstBootWritesASealedKey(t *testing.T) { | ||
| 41 | path := filepath.Join(t.TempDir(), "ca") | ||
| 42 | signer, err := LoadOrCreate(path, testKEK) | ||
| 43 | if err != nil { | ||
| 44 | t.Fatalf("LoadOrCreate: %v", err) | ||
| 45 | } | ||
| 46 | |||
| 47 | stored := read(t, path) | ||
| 48 | if !seal.IsSealed(stored) { | ||
| 49 | t.Fatalf("key file is not sealed: %q", stored) | ||
| 50 | } | ||
| 51 | if strings.Contains(stored, "PRIVATE KEY") { | ||
| 52 | t.Fatal("key file contains a PEM") | ||
| 53 | } | ||
| 54 | |||
| 55 | // And it is the key that was returned: reopening yields the same public half. | ||
| 56 | reopened, err := LoadOrCreate(path, testKEK) | ||
| 57 | if err != nil { | ||
| 58 | t.Fatalf("reload: %v", err) | ||
| 59 | } | ||
| 60 | if AuthorizedKeyLine(reopened.PublicKey()) != AuthorizedKeyLine(signer.PublicKey()) { | ||
| 61 | t.Fatal("the sealed key reopened as a different key") | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | // TestPlaintextKeyIsSealedInPlace is the upgrade path: a plane whose host CA | ||
| 66 | // predates sealing seals it on the boot that first has a key, keeps the same | ||
| 67 | // identity, and does not do it again. | ||
| 68 | func TestPlaintextKeyIsSealedInPlace(t *testing.T) { | ||
| 69 | dir := t.TempDir() | ||
| 70 | path, pub := plaintextKey(t, dir) | ||
| 71 | |||
| 72 | signer, err := LoadOrCreate(path, testKEK) | ||
| 73 | if err != nil { | ||
| 74 | t.Fatalf("LoadOrCreate: %v", err) | ||
| 75 | } | ||
| 76 | // The identity is unchanged — this is the whole point. A different key here | ||
| 77 | // would invalidate every @cert-authority pin in the fleet. | ||
| 78 | if got := AuthorizedKeyLine(signer.PublicKey()); got != pub { | ||
| 79 | t.Fatalf("identity changed on seal:\n before %s\n after %s", pub, got) | ||
| 80 | } | ||
| 81 | |||
| 82 | sealed := read(t, path) | ||
| 83 | if !seal.IsSealed(sealed) { | ||
| 84 | t.Fatalf("key file was not sealed in place: %q", sealed) | ||
| 85 | } | ||
| 86 | if fi, err := os.Stat(path); err != nil { | ||
| 87 | t.Fatalf("stat: %v", err) | ||
| 88 | } else if perm := fi.Mode().Perm(); perm != 0o600 { | ||
| 89 | t.Fatalf("sealed key perms = %o, want 0600", perm) | ||
| 90 | } | ||
| 91 | |||
| 92 | // Idempotent: the second boot opens what the first wrote and rewrites nothing. | ||
| 93 | again, err := LoadOrCreate(path, testKEK) | ||
| 94 | if err != nil { | ||
| 95 | t.Fatalf("second boot: %v", err) | ||
| 96 | } | ||
| 97 | if AuthorizedKeyLine(again.PublicKey()) != pub { | ||
| 98 | t.Fatal("identity changed on the second boot") | ||
| 99 | } | ||
| 100 | if read(t, path) != sealed { | ||
| 101 | t.Fatal("the second boot rewrote an already-sealed key") | ||
| 102 | } | ||
| 103 | |||
| 104 | // Nothing is left behind in the data directory. | ||
| 105 | entries, err := os.ReadDir(dir) | ||
| 106 | if err != nil { | ||
| 107 | t.Fatalf("readdir: %v", err) | ||
| 108 | } | ||
| 109 | if len(entries) != 1 { | ||
| 110 | names := make([]string, 0, len(entries)) | ||
| 111 | for _, e := range entries { | ||
| 112 | names = append(names, e.Name()) | ||
| 113 | } | ||
| 114 | t.Fatalf("sealing left temp files behind: %v", names) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | // TestWrongKEKRefusesAndKeepsTheKey is the one that matters most: a plane that | ||
| 119 | // comes back with the wrong key must stop, not mint a new identity. A | ||
| 120 | // regenerated host CA would present every client with an impostor. | ||
| 121 | func TestWrongKEKRefusesAndKeepsTheKey(t *testing.T) { | ||
| 122 | path := filepath.Join(t.TempDir(), "ca") | ||
| 123 | if _, err := LoadOrCreate(path, testKEK); err != nil { | ||
| 124 | t.Fatalf("seed: %v", err) | ||
| 125 | } | ||
| 126 | sealed := read(t, path) | ||
| 127 | |||
| 128 | for _, tc := range []struct { | ||
| 129 | name string | ||
| 130 | kek []byte | ||
| 131 | }{ | ||
| 132 | {"wrong key", otherKEK}, | ||
| 133 | {"no key at all", nil}, | ||
| 134 | {"short key", testKEK[:16]}, | ||
| 135 | } { | ||
| 136 | t.Run(tc.name, func(t *testing.T) { | ||
| 137 | signer, err := LoadOrCreate(path, tc.kek) | ||
| 138 | if err == nil { | ||
| 139 | t.Fatal("a sealed key opened with the wrong KEK") | ||
| 140 | } | ||
| 141 | if signer != nil { | ||
| 142 | t.Fatal("a signer was returned despite the failure") | ||
| 143 | } | ||
| 144 | if !strings.Contains(err.Error(), path) { | ||
| 145 | t.Errorf("the error must name the file, got %v", err) | ||
| 146 | } | ||
| 147 | if !strings.Contains(err.Error(), "key_encryption_key") { | ||
| 148 | t.Errorf("the error must name the config key, got %v", err) | ||
| 149 | } | ||
| 150 | if strings.Contains(err.Error(), "PRIVATE KEY") { | ||
| 151 | t.Errorf("the error echoes key material: %v", err) | ||
| 152 | } | ||
| 153 | if read(t, path) != sealed { | ||
| 154 | t.Fatal("the key file was rewritten — a host CA must never be regenerated over an unreadable one") | ||
| 155 | } | ||
| 156 | }) | ||
| 157 | } | ||
| 158 | |||
| 159 | // And the right key still opens it afterwards: nothing was consumed. | ||
| 160 | if _, err := LoadOrCreate(path, testKEK); err != nil { | ||
| 161 | t.Fatalf("the key must survive a failed open: %v", err) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | // TestUnparseableKeyIsReportedNotSealed: a corrupt file is a corrupt file. It is | ||
| 166 | // reported as one, and not rewritten as sealed nonsense that would hide what | ||
| 167 | // happened. | ||
| 168 | func TestUnparseableKeyIsReportedNotSealed(t *testing.T) { | ||
| 169 | path := filepath.Join(t.TempDir(), "ca") | ||
| 170 | if err := os.WriteFile(path, []byte("not a key at all\n"), 0o600); err != nil { | ||
| 171 | t.Fatalf("write: %v", err) | ||
| 172 | } | ||
| 173 | if _, err := LoadOrCreate(path, testKEK); err == nil { | ||
| 174 | t.Fatal("garbage was accepted as a key") | ||
| 175 | } | ||
| 176 | if got := read(t, path); got != "not a key at all\n" { | ||
| 177 | t.Fatalf("the file was rewritten: %q", got) | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | // TestNewSealsBothKeys: the host CA and the gate host key both rest sealed, and | ||
| 182 | // remain distinct keys. | ||
| 183 | func TestNewSealsBothKeys(t *testing.T) { | ||
| 184 | dir := t.TempDir() | ||
| 185 | caPath, hostPath := filepath.Join(dir, "ca"), filepath.Join(dir, "host") | ||
| 186 | |||
| 187 | ca, err := New(caPath, hostPath, testKEK) | ||
| 188 | if err != nil { | ||
| 189 | t.Fatalf("New: %v", err) | ||
| 190 | } | ||
| 191 | for _, p := range []string{caPath, hostPath} { | ||
| 192 | if stored := read(t, p); !seal.IsSealed(stored) { | ||
| 193 | t.Errorf("%s is not sealed: %q", p, stored) | ||
| 194 | } | ||
| 195 | } | ||
| 196 | if string(ssh.MarshalAuthorizedKey(ca.HostCA().PublicKey())) == | ||
| 197 | string(ssh.MarshalAuthorizedKey(ca.HostKey().PublicKey())) { | ||
| 198 | t.Fatal("HostCA and HostKey share the same public key") | ||
| 199 | } | ||
| 200 | |||
| 201 | // A wrong KEK stops the whole gate rather than half-loading it. | ||
| 202 | if _, err := New(caPath, hostPath, otherKEK); err == nil { | ||
| 203 | t.Fatal("New accepted the wrong KEK") | ||
| 204 | } | ||
| 205 | } | ||
internal/server/sshca/sshca.go
| Old | New | ||
|---|---|---|---|
| @@ -4,10 +4,13 @@ | |||
| 4 | // boot into a configured path and reused thereafter, so users never see | 4 | // boot into a configured path and reused thereafter, so users never see |
| 5 | // host-key-changed warnings. | 5 | // host-key-changed warnings. |
| 6 | // | 6 | // |
| 7 | // The private key material is handled like AdminToken / the server TLS key: | 7 | // Both keys rest SEALED: the file on disk is ciphertext under the server's |
| 8 | // written 0600, server-user-owned, and NEVER logged or exposed in API | 8 | // key_encryption_key (internal/server/seal), which lives in the config and not |
| 9 | // responses. Only the CA *public* key is exported (for VM trust injection and | 9 | // beside the data, so a copied volume or a snapshot of one carries no signing |
| 10 | // known_hosts pinning). | 10 | // power. The material is otherwise handled like the server TLS key — written |
| 11 | // 0600, server-user-owned, and NEVER logged or exposed in API responses. Only | ||
| 12 | // the CA *public* key is exported (for VM trust injection and known_hosts | ||
| 13 | // pinning). | ||
| 11 | package sshca | 14 | package sshca |
| 12 | 15 | ||
| 13 | import ( | 16 | import ( |
| @@ -16,10 +19,13 @@ import ( | |||
| 16 | "encoding/binary" | 19 | "encoding/binary" |
| 17 | "encoding/pem" | 20 | "encoding/pem" |
| 18 | "fmt" | 21 | "fmt" |
| 22 | "log/slog" | ||
| 19 | "os" | 23 | "os" |
| 24 | "path/filepath" | ||
| 20 | "strings" | 25 | "strings" |
| 21 | "time" | 26 | "time" |
| 22 | 27 | ||
| 28 | "github.com/a73x/eitri/internal/server/seal" | ||
| 23 | "golang.org/x/crypto/ssh" | 29 | "golang.org/x/crypto/ssh" |
| 24 | ) | 30 | ) |
| 25 | 31 | ||
| @@ -38,13 +44,14 @@ type CA struct { | |||
| 38 | } | 44 | } |
| 39 | 45 | ||
| 40 | // New loads-or-creates the user CA (caPath) and the gate host key (hostKeyPath). | 46 | // New loads-or-creates the user CA (caPath) and the gate host key (hostKeyPath). |
| 41 | // Both files are created 0600 if absent and reused if present. | 47 | // Both files are created 0600 if absent and reused if present, and both rest |
| 42 | func New(caPath, hostKeyPath string) (*CA, error) { | 48 | // sealed under kek — the server's key_encryption_key. |
| 43 | userCA, err := LoadOrCreate(caPath) | 49 | func New(caPath, hostKeyPath string, kek []byte) (*CA, error) { |
| 50 | userCA, err := LoadOrCreate(caPath, kek) | ||
| 44 | if err != nil { | 51 | if err != nil { |
| 45 | return nil, fmt.Errorf("ssh user CA: %w", err) | 52 | return nil, fmt.Errorf("ssh user CA: %w", err) |
| 46 | } | 53 | } |
| 47 | hostKey, err := LoadOrCreate(hostKeyPath) | 54 | hostKey, err := LoadOrCreate(hostKeyPath, kek) |
| 48 | if err != nil { | 55 | if err != nil { |
| 49 | return nil, fmt.Errorf("ssh host key: %w", err) | 56 | return nil, fmt.Errorf("ssh host key: %w", err) |
| 50 | } | 57 | } |
| @@ -75,37 +82,41 @@ func AuthorizedKeyLine(pub ssh.PublicKey) string { | |||
| 75 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))) | 82 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub))) |
| 76 | } | 83 | } |
| 77 | 84 | ||
| 78 | // LoadOrCreate returns a stable ssh.Signer for the key at path. If the file is | 85 | // LoadOrCreate returns a stable ssh.Signer for the key at path, sealed at rest |
| 79 | // absent it generates an ed25519 key, writes it 0600 (OpenSSH PEM), and returns | 86 | // under kek (the server's key_encryption_key). An absent file is generated and |
| 80 | // its signer; if present it parses and returns the existing key. The public key | 87 | // written sealed, 0600; a present one is opened and parsed. The public key is |
| 81 | // is stable across reloads. | 88 | // stable across reloads. |
| 89 | // | ||
| 90 | // A file holding a bare PEM is one written before it was sealed. It is read, | ||
| 91 | // then sealed in place — see reseal — so a plane seals itself on the boot that | ||
| 92 | // first has a KEK, and does so once. | ||
| 82 | // | 93 | // |
| 83 | // Never logs or returns key material in errors. | 94 | // Never logs or returns key material in errors. |
| 84 | func LoadOrCreate(path string) (ssh.Signer, error) { | 95 | func LoadOrCreate(path string, kek []byte) (ssh.Signer, error) { |
| 85 | pemBytes, err := os.ReadFile(path) | 96 | stored, err := os.ReadFile(path) |
| 86 | if err == nil { | 97 | if err == nil { |
| 87 | signer, perr := ssh.ParsePrivateKey(pemBytes) | 98 | return load(path, string(stored), kek) |
| 88 | if perr != nil { | ||
| 89 | return nil, fmt.Errorf("parse ssh key %q: %w", path, perr) | ||
| 90 | } | ||
| 91 | return signer, nil | ||
| 92 | } | 99 | } |
| 93 | if !os.IsNotExist(err) { | 100 | if !os.IsNotExist(err) { |
| 94 | return nil, fmt.Errorf("read ssh key %q: %w", path, err) | 101 | return nil, fmt.Errorf("read ssh key %q: %w", path, err) |
| 95 | } | 102 | } |
| 96 | 103 | ||
| 97 | // Absent — generate a fresh ed25519 key and persist it 0600. | 104 | // Absent — generate a fresh ed25519 key and persist it sealed, 0600. |
| 98 | pemBytes, signer, err := GenerateHostKey() | 105 | pemBytes, signer, err := GenerateHostKey() |
| 99 | if err != nil { | 106 | if err != nil { |
| 100 | return nil, err | 107 | return nil, err |
| 101 | } | 108 | } |
| 109 | blob, err := seal.Seal(kek, string(pemBytes)) | ||
| 110 | if err != nil { | ||
| 111 | return nil, fmt.Errorf("seal ssh key %q: %w", path, err) | ||
| 112 | } | ||
| 102 | // Write 0600 exclusively so a concurrent creator can't race us into a | 113 | // Write 0600 exclusively so a concurrent creator can't race us into a |
| 103 | // clobbered key; O_EXCL also guards against following a symlink. | 114 | // clobbered key; O_EXCL also guards against following a symlink. |
| 104 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) | 115 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) |
| 105 | if err != nil { | 116 | if err != nil { |
| 106 | return nil, fmt.Errorf("create ssh key %q: %w", path, err) | 117 | return nil, fmt.Errorf("create ssh key %q: %w", path, err) |
| 107 | } | 118 | } |
| 108 | if _, werr := f.Write(pemBytes); werr != nil { | 119 | if _, werr := f.WriteString(blob); werr != nil { |
| 109 | f.Close() | 120 | f.Close() |
| 110 | return nil, fmt.Errorf("write ssh key %q: %w", path, werr) | 121 | return nil, fmt.Errorf("write ssh key %q: %w", path, werr) |
| 111 | } | 122 | } |
| @@ -115,10 +126,94 @@ func LoadOrCreate(path string) (ssh.Signer, error) { | |||
| 115 | return signer, nil | 126 | return signer, nil |
| 116 | } | 127 | } |
| 117 | 128 | ||
| 129 | // load turns the contents of an existing key file into a signer, sealing it | ||
| 130 | // first if it is not sealed yet. | ||
| 131 | // | ||
| 132 | // A sealed file that will not open is fatal, and deliberately so: the caller | ||
| 133 | // must never fall through to generating a replacement. This key is eitri's | ||
| 134 | // identity — every user's `@cert-authority` pin and every VM's host certificate | ||
| 135 | // name it — so quietly minting a new one would present the whole fleet with an | ||
| 136 | // impostor and look, to every client, exactly like an attack. | ||
| 137 | func load(path, stored string, kek []byte) (ssh.Signer, error) { | ||
| 138 | if seal.IsSealed(stored) { | ||
| 139 | pemBytes, err := seal.Open(kek, stored) | ||
| 140 | if err != nil { | ||
| 141 | return nil, fmt.Errorf("ssh key %q is sealed and this server cannot open it "+ | ||
| 142 | "(key_encryption_key must be the one it was sealed with; the key is NOT regenerated): %w", path, err) | ||
| 143 | } | ||
| 144 | return parse(path, pemBytes) | ||
| 145 | } | ||
| 146 | // A plaintext key predates sealing. Parse it before rewriting anything, so | ||
| 147 | // an unreadable file is reported as itself rather than sealed as garbage. | ||
| 148 | signer, err := parse(path, stored) | ||
| 149 | if err != nil { | ||
| 150 | return nil, err | ||
| 151 | } | ||
| 152 | if err := reseal(path, stored, kek); err != nil { | ||
| 153 | return nil, err | ||
| 154 | } | ||
| 155 | return signer, nil | ||
| 156 | } | ||
| 157 | |||
| 158 | // reseal replaces a plaintext key file with its sealed form, atomically: write | ||
| 159 | // a temp file beside it, fsync so the bytes are on the medium before anything | ||
| 160 | // points at them, then rename over the original. The order matters more than it | ||
| 161 | // looks — a crash anywhere before the rename leaves the plaintext key intact | ||
| 162 | // and the next boot simply tries again, whereas removing the original first | ||
| 163 | // would turn a badly timed crash into a lost fleet identity. | ||
| 164 | // | ||
| 165 | // A failure here stops the server rather than carrying on with an unsealed key: | ||
| 166 | // the operator asked for key material to be encrypted at rest, and continuing | ||
| 167 | // while it is not would be the one outcome nobody would notice. | ||
| 168 | func reseal(path, plaintext string, kek []byte) error { | ||
| 169 | blob, err := seal.Seal(kek, plaintext) | ||
| 170 | if err != nil { | ||
| 171 | return fmt.Errorf("seal ssh key %q: %w", path, err) | ||
| 172 | } | ||
| 173 | dir := filepath.Dir(path) | ||
| 174 | tmp, err := os.CreateTemp(dir, filepath.Base(path)+".sealing-*") | ||
| 175 | if err != nil { | ||
| 176 | return fmt.Errorf("seal ssh key %q: create temp: %w", path, err) | ||
| 177 | } | ||
| 178 | tmpName := tmp.Name() | ||
| 179 | defer os.Remove(tmpName) // no-op once the rename has consumed it | ||
| 180 | if err := tmp.Chmod(0o600); err != nil { | ||
| 181 | tmp.Close() | ||
| 182 | return fmt.Errorf("seal ssh key %q: chmod temp: %w", path, err) | ||
| 183 | } | ||
| 184 | if _, err := tmp.WriteString(blob); err != nil { | ||
| 185 | tmp.Close() | ||
| 186 | return fmt.Errorf("seal ssh key %q: write temp: %w", path, err) | ||
| 187 | } | ||
| 188 | if err := tmp.Sync(); err != nil { | ||
| 189 | tmp.Close() | ||
| 190 | return fmt.Errorf("seal ssh key %q: sync temp: %w", path, err) | ||
| 191 | } | ||
| 192 | if err := tmp.Close(); err != nil { | ||
| 193 | return fmt.Errorf("seal ssh key %q: close temp: %w", path, err) | ||
| 194 | } | ||
| 195 | if err := os.Rename(tmpName, path); err != nil { | ||
| 196 | return fmt.Errorf("seal ssh key %q: rename: %w", path, err) | ||
| 197 | } | ||
| 198 | slog.Info("ssh key sealed at rest", "path", path) | ||
| 199 | return nil | ||
| 200 | } | ||
| 201 | |||
| 202 | // parse turns private-key PEM into a signer. The underlying error can quote the | ||
| 203 | // bytes it failed on, so only the path is reported. | ||
| 204 | func parse(path, pemBytes string) (ssh.Signer, error) { | ||
| 205 | signer, err := ssh.ParsePrivateKey([]byte(pemBytes)) | ||
| 206 | if err != nil { | ||
| 207 | return nil, fmt.Errorf("parse ssh key %q: unusable key material", path) | ||
| 208 | } | ||
| 209 | return signer, nil | ||
| 210 | } | ||
| 211 | |||
| 118 | // GenerateHostKey generates a fresh ed25519 key and returns it both as an | 212 | // GenerateHostKey generates a fresh ed25519 key and returns it both as an |
| 119 | // OpenSSH-format private-key PEM (for persisting / shipping to a guest as | 213 | // OpenSSH-format private-key PEM (for persisting / shipping to a guest as |
| 120 | // /etc/ssh/ssh_host_ed25519_key) and as a ready-to-use signer. The PEM is | 214 | // /etc/ssh/ssh_host_ed25519_key) and as a ready-to-use signer. The PEM is |
| 121 | // unencrypted (0600 at rest, like the CA and gate host keys) and never logged. | 215 | // unencrypted: it is handed to a guest as its own host key, which is where it |
| 216 | // comes to rest. The copy eitri keeps of its OWN keys is sealed (LoadOrCreate). | ||
| 122 | func GenerateHostKey() (pemBytes []byte, signer ssh.Signer, err error) { | 217 | func GenerateHostKey() (pemBytes []byte, signer ssh.Signer, err error) { |
| 123 | _, priv, err := ed25519.GenerateKey(rand.Reader) | 218 | _, priv, err := ed25519.GenerateKey(rand.Reader) |
| 124 | if err != nil { | 219 | if err != nil { |
internal/server/sshca/sshca_test.go
| Old | New | ||
|---|---|---|---|
| @@ -8,14 +8,23 @@ import ( | |||
| 8 | "testing" | 8 | "testing" |
| 9 | "time" | 9 | "time" |
| 10 | 10 | ||
| 11 | "github.com/a73x/eitri/internal/server/seal" | ||
| 11 | "golang.org/x/crypto/ssh" | 12 | "golang.org/x/crypto/ssh" |
| 12 | ) | 13 | ) |
| 13 | 14 | ||
| 15 | // testKEK stands in for the config's key_encryption_key; otherKEK is a | ||
| 16 | // different one of the same size, for the plane that comes back with the wrong | ||
| 17 | // key. | ||
| 18 | var ( | ||
| 19 | testKEK = bytes.Repeat([]byte{0x2b}, seal.KEKSize) | ||
| 20 | otherKEK = bytes.Repeat([]byte{0x7f}, seal.KEKSize) | ||
| 21 | ) | ||
| 22 | |||
| 14 | func TestLoadOrCreate_CreatesWith0600(t *testing.T) { | 23 | func TestLoadOrCreate_CreatesWith0600(t *testing.T) { |
| 15 | dir := t.TempDir() | 24 | dir := t.TempDir() |
| 16 | path := filepath.Join(dir, "ca") | 25 | path := filepath.Join(dir, "ca") |
| 17 | 26 | ||
| 18 | signer, err := LoadOrCreate(path) | 27 | signer, err := LoadOrCreate(path, testKEK) |
| 19 | if err != nil { | 28 | if err != nil { |
| 20 | t.Fatalf("LoadOrCreate: %v", err) | 29 | t.Fatalf("LoadOrCreate: %v", err) |
| 21 | } | 30 | } |
| @@ -36,11 +45,11 @@ func TestLoadOrCreate_ReloadStable(t *testing.T) { | |||
| 36 | dir := t.TempDir() | 45 | dir := t.TempDir() |
| 37 | path := filepath.Join(dir, "ca") | 46 | path := filepath.Join(dir, "ca") |
| 38 | 47 | ||
| 39 | first, err := LoadOrCreate(path) | 48 | first, err := LoadOrCreate(path, testKEK) |
| 40 | if err != nil { | 49 | if err != nil { |
| 41 | t.Fatalf("LoadOrCreate (create): %v", err) | 50 | t.Fatalf("LoadOrCreate (create): %v", err) |
| 42 | } | 51 | } |
| 43 | second, err := LoadOrCreate(path) | 52 | second, err := LoadOrCreate(path, testKEK) |
| 44 | if err != nil { | 53 | if err != nil { |
| 45 | t.Fatalf("LoadOrCreate (reload): %v", err) | 54 | t.Fatalf("LoadOrCreate (reload): %v", err) |
| 46 | } | 55 | } |
| @@ -57,7 +66,7 @@ func TestNew_AccessorsAndAuthorizedKey(t *testing.T) { | |||
| 57 | caPath := filepath.Join(dir, "ca") | 66 | caPath := filepath.Join(dir, "ca") |
| 58 | hostPath := filepath.Join(dir, "host") | 67 | hostPath := filepath.Join(dir, "host") |
| 59 | 68 | ||
| 60 | ca, err := New(caPath, hostPath) | 69 | ca, err := New(caPath, hostPath, testKEK) |
| 61 | if err != nil { | 70 | if err != nil { |
| 62 | t.Fatalf("New: %v", err) | 71 | t.Fatalf("New: %v", err) |
| 63 | } | 72 | } |
| @@ -126,7 +135,7 @@ func TestAuthorizedKeyLineIsCanonical(t *testing.T) { | |||
| 126 | } | 135 | } |
| 127 | 136 | ||
| 128 | func TestSignHostCert_SignedByCAAndScopedToPrincipal(t *testing.T) { | 137 | func TestSignHostCert_SignedByCAAndScopedToPrincipal(t *testing.T) { |
| 129 | ca, err := LoadOrCreate(filepath.Join(t.TempDir(), "ca")) | 138 | ca, err := LoadOrCreate(filepath.Join(t.TempDir(), "ca"), testKEK) |
| 130 | if err != nil { | 139 | if err != nil { |
| 131 | t.Fatalf("LoadOrCreate CA: %v", err) | 140 | t.Fatalf("LoadOrCreate CA: %v", err) |
| 132 | } | 141 | } |