Skip to content

Commit eba7db0

Browse files
FIPS complaint agent file vault (#7360) (#7498)
Add elastic file vault seed implementation to allow variable length salt sizes. FIPS distributions will strictly use the new .seedV2 format with the salt size set to 16 for FIPS compliance. When not in FIPS only the (V1) .seed file is used. (cherry picked from commit 1d5a294) Co-authored-by: Michel Laterman <[email protected]>
1 parent 2655066 commit eba7db0

File tree

12 files changed

+625
-115
lines changed

12 files changed

+625
-115
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Kind can be one of:
2+
# - breaking-change: a change to previously-documented behavior
3+
# - deprecation: functionality that is being removed in a later release
4+
# - bug-fix: fixes a problem in a previous version
5+
# - enhancement: extends functionality but does not break or fix existing behavior
6+
# - feature: new functionality
7+
# - known-issue: problems that we are aware of in a given version
8+
# - security: impacts on the security of a product or a user’s deployment.
9+
# - upgrade: important information for someone upgrading from a prior version
10+
# - other: does not fit into any of the other categories
11+
kind: enhancement
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: FIPS Compliant agent file vault
15+
16+
# Long description; in case the summary is not enough to describe the change
17+
# this field accommodate a description without length limits.
18+
# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
19+
description: |
20+
Change elastic file vault implementation to allow variable length salt sizes
21+
only in FIPS enabled agents. Increase default salt size to 16 for FIPS
22+
compliance. Non-FIPS agents are unchanged.
23+
24+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
25+
component: elastic-agent
26+
27+
# PR URL; optional; the PR number that added the changeset.
28+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
29+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
30+
# Please provide it if you are adding a fragment for a different PR.
31+
pr: https://github.com/elastic/elastic-agent/pull/7360
32+
33+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
34+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
35+
#issue:

internal/pkg/agent/vault/seed.go

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
package vault
66

77
import (
8-
"errors"
8+
"encoding/binary"
99
"fmt"
1010
"io/fs"
1111
"os"
@@ -16,62 +16,68 @@ import (
1616
)
1717

1818
const (
19-
seedFile = ".seed"
19+
// seedFile is len(aesgcm.AES256) and contains only the random seed
20+
// A default salt size of 8 is used with this seed file
21+
seedFile = ".seed"
22+
seedFileSize = int(aesgcm.AES256)
23+
// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
24+
seedFileV2 = ".seedV2"
25+
seedFileV2Size = seedFileSize + 4
26+
)
27+
28+
const (
29+
saltSizeV1 = 8
30+
defaultSaltSizeV2 = 16
2031
)
2132

2233
var (
2334
mxSeed sync.Mutex
2435
)
2536

26-
func getSeed(path string) ([]byte, error) {
37+
// getSeedV1 will read the V1 .seed file
38+
// Will return fs.ErrNotExists if the bytecount does not match
39+
func getSeedV1(path string) ([]byte, error) {
2740
fp := filepath.Join(path, seedFile)
2841

29-
mxSeed.Lock()
30-
defer mxSeed.Unlock()
31-
3242
b, err := os.ReadFile(fp)
3343
if err != nil {
3444
return nil, fmt.Errorf("could not read seed file: %w", err)
3545
}
3646

37-
// return fs.ErrNotExists if invalid length of bytes returned
38-
if len(b) != int(aesgcm.AES256) {
39-
return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
47+
// return fs.ErrNotExist if invalid length of bytes returned
48+
if len(b) != seedFileSize {
49+
return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileSize, len(b), fs.ErrNotExist)
4050
}
4151
return b, nil
4252
}
4353

44-
func createSeedIfNotExists(path string) ([]byte, error) {
45-
fp := filepath.Join(path, seedFile)
46-
47-
mxSeed.Lock()
48-
defer mxSeed.Unlock()
54+
// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
55+
// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
56+
// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
57+
func getSeedV2(path string) ([]byte, int, error) {
58+
fp := filepath.Join(path, seedFileV2)
4959

5060
b, err := os.ReadFile(fp)
5161
if err != nil {
52-
if !errors.Is(err, os.ErrNotExist) {
53-
return nil, err
54-
}
62+
return nil, 0, fmt.Errorf("could not read seed file: %w", err)
5563
}
5664

57-
if len(b) != 0 {
58-
return b, nil
65+
// return fs.ErrNotExist if invalid length of bytes returned
66+
if len(b) != seedFileV2Size {
67+
return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
5968
}
60-
61-
seed, err := aesgcm.NewKey(aesgcm.AES256)
62-
if err != nil {
63-
return nil, err
69+
pass := b[0:seedFileSize]
70+
saltSize := binary.LittleEndian.Uint32(b[seedFileSize:])
71+
if saltSize == 0 {
72+
return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
6473
}
65-
66-
err = os.WriteFile(fp, seed, 0600)
67-
if err != nil {
68-
return nil, err
74+
if err := checkSalt(int(saltSize)); err != nil {
75+
return nil, 0, err
6976
}
70-
71-
return seed, nil
77+
return pass, int(saltSize), nil
7278
}
7379

74-
func getOrCreateSeed(path string, readonly bool) ([]byte, error) {
80+
func getOrCreateSeed(path string, readonly bool) ([]byte, int, error) {
7581
if readonly {
7682
return getSeed(path)
7783
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build requirefips
6+
7+
package vault
8+
9+
import (
10+
"encoding/binary"
11+
"errors"
12+
"fmt"
13+
"os"
14+
"path/filepath"
15+
16+
"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
17+
)
18+
19+
// getSeed returns the seed and salt size from the V2 seed file.
20+
// If the byte count does not match, or a 0 length salt is detected fs.ErrNotExist will be returned.
21+
func getSeed(path string) ([]byte, int, error) {
22+
mxSeed.Lock()
23+
defer mxSeed.Unlock()
24+
25+
// FIPS only supports V2
26+
b, saltSize, err := getSeedV2(path)
27+
if err != nil {
28+
return nil, 0, err
29+
}
30+
return b, saltSize, nil
31+
}
32+
33+
// checkSalt ensures the salt size is at least 16 bytes.
34+
func checkSalt(size int) error {
35+
if size < defaultSaltSizeV2 {
36+
return fmt.Errorf("expected salt to be at least %d: %w", defaultSaltSizeV2, errors.ErrUnsupported)
37+
}
38+
return nil
39+
}
40+
41+
// createSeedIfNotExists returns the seed and salt size from the V2 seed file.
42+
// If the seed file does not exist it will create and write a new V2 seed file with a salt size of 16.
43+
func createSeedIfNotExists(path string) ([]byte, int, error) {
44+
mxSeed.Lock()
45+
defer mxSeed.Unlock()
46+
47+
pass, saltSize, err := getSeedV2(path)
48+
if err != nil {
49+
if !errors.Is(err, os.ErrNotExist) {
50+
return nil, 0, err
51+
}
52+
}
53+
if len(pass) != 0 {
54+
return pass, saltSize, nil
55+
}
56+
57+
seed, err := aesgcm.NewKey(aesgcm.AES256)
58+
if err != nil {
59+
return nil, 0, err
60+
}
61+
l := make([]byte, 4)
62+
binary.LittleEndian.PutUint32(l, uint32(defaultSaltSizeV2))
63+
64+
err = os.WriteFile(filepath.Join(path, seedFileV2), append(seed, l...), 0600)
65+
if err != nil {
66+
return nil, 0, err
67+
}
68+
69+
return seed, defaultSaltSizeV2, nil
70+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build requirefips
6+
7+
package vault
8+
9+
import (
10+
"encoding/binary"
11+
"errors"
12+
"os"
13+
"path/filepath"
14+
"testing"
15+
16+
"github.com/google/go-cmp/cmp"
17+
18+
"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
19+
)
20+
21+
func TestGetSeedFailsV1File(t *testing.T) {
22+
dir := t.TempDir()
23+
fp := filepath.Join(dir, seedFile)
24+
25+
if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
26+
t.Fatal(err)
27+
}
28+
seed, err := aesgcm.NewKey(aesgcm.AES256)
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
err = os.WriteFile(fp, seed, 0600)
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
38+
_, _, err = getSeed(dir)
39+
if !errors.Is(err, os.ErrNotExist) {
40+
t.Fatalf("FIPS mode must not read v1 seeds. expected error %v to be os.ErrNotExist", err)
41+
}
42+
}
43+
44+
func TestGetSeedV2File(t *testing.T) {
45+
dir := t.TempDir()
46+
fp := filepath.Join(dir, seedFileV2)
47+
48+
if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
49+
t.Fatal(err)
50+
}
51+
seed, err := aesgcm.NewKey(aesgcm.AES256)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
l := make([]byte, 4)
56+
binary.LittleEndian.PutUint32(l, uint32(defaultSaltSizeV2))
57+
58+
err = os.WriteFile(fp, append(seed, l...), 0600)
59+
if err != nil {
60+
t.Fatal(err)
61+
}
62+
63+
b, saltSize, err := getSeed(dir)
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
if saltSize != defaultSaltSizeV2 {
68+
t.Errorf("expected salt size to be %d, got: %d", defaultSaltSizeV2, saltSize)
69+
}
70+
diff := cmp.Diff(seed, b)
71+
if diff != "" {
72+
t.Error(diff)
73+
}
74+
}
75+
76+
func TestCreateSeedIfNotExists(t *testing.T) {
77+
dir := t.TempDir()
78+
79+
fp := filepath.Join(dir, seedFile)
80+
fpV2 := filepath.Join(dir, seedFileV2)
81+
82+
if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
83+
t.Fatal(err)
84+
}
85+
if _, err := os.Stat(fpV2); !errors.Is(err, os.ErrNotExist) {
86+
t.Fatal(err)
87+
}
88+
89+
b, saltSize, err := createSeedIfNotExists(dir)
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
94+
// V2 file should exist
95+
if _, err := os.Stat(fpV2); err != nil {
96+
t.Fatal(err)
97+
}
98+
// V1 file should not exist
99+
if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
100+
t.Fatal(err)
101+
}
102+
103+
diff := cmp.Diff(seedFileSize, len(b))
104+
if diff != "" {
105+
t.Error(diff)
106+
}
107+
if saltSize != defaultSaltSizeV2 {
108+
t.Errorf("expected salt size: %d got: %d", defaultSaltSizeV2, saltSize)
109+
}
110+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build !requirefips
6+
7+
package vault
8+
9+
import (
10+
"errors"
11+
"os"
12+
"path/filepath"
13+
14+
"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
15+
)
16+
17+
// getSeed returns the seed from the v1 .seed file
18+
// or fs.ErrNotExist if the bytecount does not match
19+
func getSeed(path string) ([]byte, int, error) {
20+
mxSeed.Lock()
21+
defer mxSeed.Unlock()
22+
23+
// Non fips only supports V1 seed
24+
b, err := getSeedV1(path)
25+
if err != nil {
26+
return nil, 0, err
27+
}
28+
return b, saltSizeV1, nil
29+
}
30+
31+
// checkSalt is a nop as V2 seeds are disabled for non-FIPS agents
32+
func checkSalt(_ int) error {
33+
return nil
34+
}
35+
36+
// createSeedIfNotExists returns the seed from the v1 .seed file
37+
// If the seed file does not exist it will create and write a new v1 seed file.
38+
func createSeedIfNotExists(path string) ([]byte, int, error) {
39+
mxSeed.Lock()
40+
defer mxSeed.Unlock()
41+
42+
pass, err := getSeedV1(path)
43+
if err != nil {
44+
if !errors.Is(err, os.ErrNotExist) {
45+
return nil, 0, err
46+
}
47+
}
48+
if len(pass) != 0 {
49+
return pass, saltSizeV1, nil
50+
}
51+
52+
seed, err := aesgcm.NewKey(aesgcm.AES256)
53+
if err != nil {
54+
return nil, 0, err
55+
}
56+
err = os.WriteFile(filepath.Join(path, seedFile), seed, 0600)
57+
if err != nil {
58+
return nil, 0, err
59+
}
60+
61+
return seed, saltSizeV1, nil
62+
}

0 commit comments

Comments
 (0)