Skip to content

Commit 66a27fd

Browse files
VAULT-37632 allow restoring SSH CA from loaded snapshot (hashicorp#8581) (hashicorp#9034)
* allow restoring ssh config/ca * add some unit tests * address PR review * imports and test upgrades * linter complaints * add PR comment and linter fixes * address review Co-authored-by: Bruno Oliveira de Souza <[email protected]>
1 parent e40eca1 commit 66a27fd

File tree

7 files changed

+244
-20
lines changed

7 files changed

+244
-20
lines changed

builtin/logical/ssh/backend.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ func Backend(conf *logical.BackendConfig) (*backend, error) {
5656
caPrivateKeyStoragePath,
5757
keysStoragePrefix,
5858
},
59+
60+
AllowSnapshotRead: []string{
61+
"config/ca",
62+
},
5963
},
6064

6165
Paths: []*framework.Path{

builtin/logical/ssh/path_config_ca.go

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ func pathConfigCA(b *backend) *framework.Path {
107107
OperationSuffix: "ca-configuration",
108108
},
109109
},
110+
logical.RecoverOperation: &framework.PathOperation{
111+
Callback: b.pathConfigCARecover,
112+
},
110113
},
111114

112115
HelpSynopsis: `Set the SSH private key used for signing certificates.`,
@@ -120,7 +123,9 @@ Read operations will return the public key, if already stored/generated.`,
120123
}
121124

122125
func (b *backend) pathConfigCARead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
123-
publicKey, err := getCAPublicKey(ctx, req.Storage)
126+
// prevent migration from deprecated paths on snapshot read as writes to a loaded snapshot storage are forbidden
127+
allowMigration := !req.IsSnapshotReadOrList()
128+
publicKey, err := getCAPublicKey(ctx, req.Storage, allowMigration)
124129
if err != nil {
125130
return nil, fmt.Errorf("failed to read CA public key: %w", err)
126131
}
@@ -142,16 +147,22 @@ func (b *backend) pathConfigCADelete(ctx context.Context, req *logical.Request,
142147
if err := req.Storage.Delete(ctx, caPrivateKeyStoragePath); err != nil {
143148
return nil, err
144149
}
150+
if err := req.Storage.Delete(ctx, caPrivateKeyStoragePathDeprecated); err != nil {
151+
return nil, err
152+
}
145153
if err := req.Storage.Delete(ctx, caPublicKeyStoragePath); err != nil {
146154
return nil, err
147155
}
156+
if err := req.Storage.Delete(ctx, caPublicKeyStoragePathDeprecated); err != nil {
157+
return nil, err
158+
}
148159
if err := req.Storage.Delete(ctx, caManagedKeyStoragePath); err != nil {
149160
return nil, err
150161
}
151162
return nil, nil
152163
}
153164

154-
func readStoredKey(ctx context.Context, storage logical.Storage, keyType string) (*keyStorageEntry, error) {
165+
func readStoredKeyEntry(ctx context.Context, storage logical.Storage, keyType string, allowMigration bool) (*logical.StorageEntry, error) {
155166
var path, deprecatedPath string
156167
switch keyType {
157168
case caPrivateKey:
@@ -176,25 +187,39 @@ func readStoredKey(ctx context.Context, storage logical.Storage, keyType string)
176187
if err != nil {
177188
return nil, err
178189
}
190+
179191
if entry != nil {
192+
// modify entry variable, both for possible migration and also to comply with the expected JSON entry for the caller
180193
entry, err = logical.StorageEntryJSON(path, keyStorageEntry{
181194
Key: string(entry.Value),
182195
})
183196
if err != nil {
184197
return nil, err
185198
}
186-
if err := storage.Put(ctx, entry); err != nil {
187-
return nil, err
188-
}
189-
if err = storage.Delete(ctx, deprecatedPath); err != nil {
190-
return nil, err
199+
// migrations are disable on recover, as we can't write to the loaded snapshot storage
200+
if allowMigration {
201+
if err := storage.Put(ctx, entry); err != nil {
202+
return nil, err
203+
}
204+
if err = storage.Delete(ctx, deprecatedPath); err != nil {
205+
return nil, err
206+
}
191207
}
192208
}
193209
}
210+
return entry, nil
211+
}
212+
213+
// readStoredKey reads a key from storage, returning nil if not found.
214+
// ignore-nil-nil-function-check
215+
func readStoredKey(ctx context.Context, storage logical.Storage, keyType string, allowMigration bool) (*keyStorageEntry, error) {
216+
entry, err := readStoredKeyEntry(ctx, storage, keyType, allowMigration)
217+
if err != nil {
218+
return nil, err
219+
}
194220
if entry == nil {
195221
return nil, nil
196222
}
197-
198223
var keyEntry keyStorageEntry
199224
if err := entry.DecodeJSON(&keyEntry); err != nil {
200225
return nil, err
@@ -450,10 +475,10 @@ func (b *backend) createManagedKey(ctx context.Context, s logical.Storage, manag
450475
return nil
451476
}
452477

453-
func getCAPublicKey(ctx context.Context, storage logical.Storage) (string, error) {
478+
func getCAPublicKey(ctx context.Context, storage logical.Storage, allowMigration bool) (string, error) {
454479
var publicKey string
455480

456-
storedKeyEntry, err := readStoredKey(ctx, storage, caPublicKey)
481+
storedKeyEntry, err := readStoredKey(ctx, storage, caPublicKey, allowMigration)
457482
if err != nil {
458483
return "", err
459484
}
@@ -495,12 +520,13 @@ func readManagedKey(ctx context.Context, storage logical.Storage) (*managedKeySt
495520
}
496521

497522
func caKeysConfigured(ctx context.Context, s logical.Storage) (bool, error) {
498-
publicKeyEntry, err := readStoredKey(ctx, s, caPublicKey)
523+
const allowMigration = false // no need to allow migration when just checking for existence, we can do that later
524+
publicKeyEntry, err := readStoredKey(ctx, s, caPublicKey, allowMigration)
499525
if err != nil {
500526
return false, fmt.Errorf("failed to read CA public key: %w", err)
501527
}
502528

503-
privateKeyEntry, err := readStoredKey(ctx, s, caPrivateKey)
529+
privateKeyEntry, err := readStoredKey(ctx, s, caPrivateKey, allowMigration)
504530
if err != nil {
505531
return false, fmt.Errorf("failed to read CA private key: %w", err)
506532
}
@@ -520,3 +546,64 @@ func caKeysConfigured(ctx context.Context, s logical.Storage) (bool, error) {
520546

521547
return false, nil
522548
}
549+
550+
// pathConfigCARecover recovers the CA from the target snapshot back to the live storage.
551+
// ignore-nil-nil-function-check
552+
func (b *backend) pathConfigCARecover(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
553+
// check live storage for existing keys. Disallow recovery if CA is already configured for consistency with create operation
554+
found, err := caKeysConfigured(ctx, req.Storage)
555+
if err != nil {
556+
return nil, err
557+
}
558+
if found {
559+
return logical.ErrorResponse("keys are already configured; delete them before recovering the CA"), nil
560+
}
561+
562+
// fetch directly from the snapshot storage instead of following the usual restore procedure of getting the values
563+
// from the req.Data, since those came from a previous CARead operation on the loaded snapshot, which only contains
564+
// the public key.
565+
snapshotStorage, err := logical.NewSnapshotStorageView(req)
566+
if err != nil {
567+
return nil, err
568+
}
569+
const allowMigration = false // prevent migration from deprecated paths as we can't allow writes on the snapshot storage
570+
publicKeyEntry, err := readStoredKeyEntry(ctx, snapshotStorage, caPublicKey, allowMigration)
571+
if err != nil {
572+
return nil, fmt.Errorf("failed to read CA public key for restore: %w", err)
573+
}
574+
privateKeyEntry, err := readStoredKeyEntry(ctx, snapshotStorage, caPrivateKey, allowMigration)
575+
if err != nil {
576+
return nil, fmt.Errorf("failed to read CA private key for restore: %w", err)
577+
}
578+
managedKey, err := readManagedKey(ctx, snapshotStorage)
579+
if err != nil {
580+
return nil, fmt.Errorf("failed to read CA managed key for restore: %w", err)
581+
}
582+
583+
if publicKeyEntry == nil && privateKeyEntry == nil && managedKey == nil {
584+
return logical.ErrorResponse("no CA keys found in snapshot storage to restore"), nil
585+
}
586+
587+
// it's possible that we've read the keys from a deprecated path in the snapshot, but it should be automatically
588+
// upgraded to the new path anyway, so we don't care about restoring it back to the deprecated path
589+
if publicKeyEntry != nil {
590+
err = req.Storage.Put(ctx, publicKeyEntry)
591+
if err != nil {
592+
return nil, fmt.Errorf("failed to restore public key entry in storage: %w", err)
593+
}
594+
}
595+
if privateKeyEntry != nil {
596+
err = req.Storage.Put(ctx, privateKeyEntry)
597+
if err != nil {
598+
return nil, fmt.Errorf("failed to restore private key entry in storage: %w", err)
599+
}
600+
}
601+
if managedKey != nil {
602+
err = b.createManagedKey(ctx, req.Storage, managedKey.KeyName.String(), managedKey.KeyId.String())
603+
if err != nil {
604+
return nil, fmt.Errorf("failed to restore managed key entry in storage: %w", err)
605+
}
606+
}
607+
608+
return nil, nil
609+
}

builtin/logical/ssh/path_config_ca_test.go

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"strings"
1111
"testing"
1212

13+
"github.com/hashicorp/vault/sdk/helper/testhelpers/snapshots"
1314
"github.com/hashicorp/vault/sdk/logical"
15+
"github.com/stretchr/testify/require"
1416
)
1517

1618
func TestSSH_ConfigCAStorageUpgrade(t *testing.T) {
@@ -39,7 +41,7 @@ func TestSSH_ConfigCAStorageUpgrade(t *testing.T) {
3941
}
4042

4143
// Reading it should return the key as well as upgrade the storage path
42-
privateKeyEntry, err := readStoredKey(context.Background(), config.StorageView, caPrivateKey)
44+
privateKeyEntry, err := readStoredKey(context.Background(), config.StorageView, caPrivateKey, true)
4345
if err != nil {
4446
t.Fatal(err)
4547
}
@@ -73,7 +75,7 @@ func TestSSH_ConfigCAStorageUpgrade(t *testing.T) {
7375
}
7476

7577
// Reading it should return the key as well as upgrade the storage path
76-
publicKeyEntry, err := readStoredKey(context.Background(), config.StorageView, caPublicKey)
78+
publicKeyEntry, err := readStoredKey(context.Background(), config.StorageView, caPublicKey, true)
7779
if err != nil {
7880
t.Fatal(err)
7981
}
@@ -180,6 +182,28 @@ func TestSSH_ConfigCAUpdateDelete(t *testing.T) {
180182
if err != nil || (resp != nil && resp.IsError()) {
181183
t.Fatalf("bad: err: %v, resp:%v", err, resp)
182184
}
185+
186+
// verify deletion of keys on deprecated path
187+
err = config.StorageView.Put(context.Background(), &logical.StorageEntry{
188+
Key: caPublicKeyStoragePathDeprecated,
189+
Value: []byte(testCAPublicKey),
190+
})
191+
require.NoError(t, err)
192+
err = config.StorageView.Put(context.Background(), &logical.StorageEntry{
193+
Key: caPrivateKeyStoragePathDeprecated,
194+
Value: []byte(testCAPrivateKey),
195+
})
196+
require.NoError(t, err)
197+
caReq.Operation = logical.DeleteOperation
198+
resp, err = b.HandleRequest(context.Background(), caReq)
199+
if err != nil || (resp != nil && resp.IsError()) {
200+
t.Fatalf("bad: err: %v, resp:%v", err, resp)
201+
}
202+
// ensure it was deleted
203+
caReq.Operation = logical.ReadOperation
204+
resp, err = b.HandleRequest(context.Background(), caReq)
205+
require.NoError(t, err)
206+
require.Error(t, resp.Error())
183207
}
184208

185209
func createDeleteHelper(t *testing.T, b logical.Backend, config *logical.BackendConfig, index int, keyType string, keyBits int) {
@@ -340,7 +364,7 @@ func TestReadStoredKey(t *testing.T) {
340364
t.Fatalf("error writing public key: %s", err)
341365
}
342366

343-
publicKeyEntry, err := readStoredKey(context.Background(), storage, caPublicKey)
367+
publicKeyEntry, err := readStoredKey(context.Background(), storage, caPublicKey, true)
344368
if err != nil {
345369
t.Fatalf("error reading public key: %s", err)
346370
}
@@ -349,7 +373,7 @@ func TestReadStoredKey(t *testing.T) {
349373
t.Fatalf("returned key does not match: expected %s, got %s", tt.publicKey, publicKeyEntry.Key)
350374
}
351375

352-
privateKeyEntry, err := readStoredKey(context.Background(), storage, caPrivateKey)
376+
privateKeyEntry, err := readStoredKey(context.Background(), storage, caPrivateKey, true)
353377
if err != nil {
354378
t.Fatalf("error reading private key: %s", err)
355379
}
@@ -387,7 +411,7 @@ func TestGetCAPublicKey(t *testing.T) {
387411
t.Fatalf("error writing key: %s", err)
388412
}
389413

390-
key, err := getCAPublicKey(ctx, storage)
414+
key, err := getCAPublicKey(ctx, storage, true)
391415
if err != nil {
392416
t.Fatalf("error retrieving public key: %s", err)
393417
}
@@ -586,3 +610,106 @@ func readKey(ctx context.Context, s logical.Storage, path string) error {
586610

587611
return nil
588612
}
613+
614+
// TestCARecover verifies secret recovery of the SSH CA
615+
func TestCARecover(t *testing.T) {
616+
var err error
617+
config := logical.TestBackendConfig()
618+
config.StorageView = &logical.InmemStorage{}
619+
620+
b, err := Factory(context.Background(), config)
621+
if err != nil {
622+
t.Fatalf("Cannot create backend: %s", err)
623+
}
624+
tc := snapshots.NewSnapshotTestCase(t, b)
625+
626+
// generate CA keys on the snapshot storage
627+
_, err = b.HandleRequest(context.Background(), &logical.Request{
628+
Operation: logical.UpdateOperation,
629+
Path: "config/ca",
630+
Storage: tc.SnapshotStorage(),
631+
Data: map[string]interface{}{
632+
"public_key": testCAPublicKey,
633+
"private_key": testCAPrivateKey,
634+
},
635+
})
636+
require.NoError(t, err)
637+
638+
// write different CA to the regular storage
639+
_, err = b.HandleRequest(context.Background(), &logical.Request{
640+
Operation: logical.UpdateOperation,
641+
Path: "config/ca",
642+
Storage: tc.RegularStorage(),
643+
Data: map[string]interface{}{
644+
"generate_signing_key": true,
645+
},
646+
})
647+
require.NoError(t, err)
648+
t.Run("read no side effects", func(t *testing.T) {
649+
tc.RunRead(t, "config/ca")
650+
})
651+
652+
t.Run("recover succeeds", func(t *testing.T) {
653+
tc.DoRecover(t, "config/ca")
654+
data, err := b.HandleRequest(context.Background(), &logical.Request{
655+
Operation: logical.ReadOperation,
656+
Path: "config/ca",
657+
Storage: tc.SnapshotStorage(),
658+
Data: map[string]interface{}{
659+
"public_key": "should be the actual public key but the SSH CA recovery doesn't really care as it reads directly from snapshot storage",
660+
},
661+
})
662+
require.NoError(t, err)
663+
require.NotNil(t, data)
664+
require.Equal(t, testCAPublicKey, data.Data["public_key"])
665+
})
666+
}
667+
668+
// TestCARecoverMigration is the same as TestCARecover, but recovering from a snapshot with the data in the deprecated
669+
// storage paths, which ensures that the migration logic is skipped during recovery.
670+
func TestCARecoverMigration(t *testing.T) {
671+
var err error
672+
config := logical.TestBackendConfig()
673+
config.StorageView = &logical.InmemStorage{}
674+
675+
b, err := Factory(context.Background(), config)
676+
if err != nil {
677+
t.Fatalf("Cannot create backend: %s", err)
678+
}
679+
tc := snapshots.NewSnapshotTestCase(t, b)
680+
err = tc.SnapshotStorage().Put(context.Background(), &logical.StorageEntry{
681+
Key: caPublicKeyStoragePathDeprecated,
682+
Value: []byte(testCAPublicKey),
683+
})
684+
require.NoError(t, err)
685+
err = tc.SnapshotStorage().Put(context.Background(), &logical.StorageEntry{
686+
Key: caPrivateKeyStoragePathDeprecated,
687+
Value: []byte(testCAPrivateKey),
688+
})
689+
require.NoError(t, err)
690+
691+
require.NoError(t, err)
692+
t.Run("read no side effects", func(t *testing.T) {
693+
tc.RunRead(t, "config/ca")
694+
})
695+
696+
t.Run("recover succeeds", func(t *testing.T) {
697+
tc.DoRecover(t, "config/ca")
698+
// ensure that even though the migration on read is disabled, by reading from the old path in the snapshot and
699+
// creating a entry in the regular storage, that the entry ends up in the new path
700+
entry, err := tc.RegularStorage().Get(context.Background(), caPublicKeyStoragePathDeprecated)
701+
require.NoError(t, err)
702+
require.Nil(t, entry)
703+
data, err := b.HandleRequest(context.Background(), &logical.Request{
704+
Operation: logical.ReadOperation,
705+
Path: "config/ca",
706+
Storage: tc.SnapshotStorage(),
707+
Data: map[string]interface{}{
708+
"public_key": "should be the actual public key but the SSH CA recovery doesn't really care as it reads directly from snapshot storage",
709+
},
710+
})
711+
require.NoError(t, err)
712+
require.NotNil(t, data)
713+
require.Equal(t, testCAPublicKey, data.Data["public_key"])
714+
})
715+
}

builtin/logical/ssh/path_fetch.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ func pathFetchPublicKey(b *backend) *framework.Path {
2929
}
3030

3131
func (b *backend) pathFetchPublicKey(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
32-
publicKey, err := getCAPublicKey(ctx, req.Storage)
32+
const allowMigration = true // only paths that support snapshot reads are
33+
publicKey, err := getCAPublicKey(ctx, req.Storage, allowMigration)
3334
if err != nil {
3435
return nil, err
3536
}

0 commit comments

Comments
 (0)