a73x

d865b83b

feat(server): server key material is encrypted at rest

a73x   2026-08-08 08:36

Commit message
feat(server): server key material is encrypted at rest

What rests on disk is no longer what signs. One key-encryption key — the
server's key_encryption_key, which lives in its config and nowhere near the
data — seals the key material the control plane keeps on disk: the host CA and
the gate host key in the data directory. A copied volume or a nightly backup
yields ciphertext and no signing power, and a restore needs the config as well
as the data.

key_encryption_key is required: 64 hex characters, minted with `openssl rand
-hex 32`, validated at load so a plane that cannot open its own host CA refuses
to start rather than failing at some later request. internal/server/seal is the
whole mechanism — AES-256-GCM, a fresh nonce per value, stored as "v1:" +
base64(nonce || ciphertext), versioned so a second scheme can sit beside this
one and anything unrecognized is refused rather than guessed at. Open fails
closed on every way a value can be wrong, and its errors name none of the
material.

sshca writes a generated key sealed and seals a plaintext one in place: temp
file beside it, fsync, rename over the original, so a crash before the rename
leaves the key intact and the next boot tries again. A sealed key file that will
not open stops the server. It is never regenerated over — a fresh host CA would
invalidate every client's `@cert-authority` pin and every VM's host certificate
at once, and would look, to every one of them, exactly like an attack.

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 }