Repository: elastic/elastic-agent
Pull request #7360: FIPS complaint agent file vault
Author: login: michel-laterman
Labels: name: enhancement
description: New feature or request
name: Team:Elastic-Agent-Control-Plane
description: Label for the Agent Control Plane team
name: backport-8.x
description: Automated backport to the 8.x branch with mergify
name: backport-9.0
description: Automated backport to the 9.0 branch
Created: 2025-03-12T19:59:03Z | Merged: 2025-03-20T19:19:08Z

## What does this PR do?

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.

Seed file version is as follows:
- `.seed` files remain unchanged from the current released implementation. Used in non-FIPS distribution.
- `.seedV2` files are new; they contain the seed followed by the salt size (as a little endian uint32 value). This allows specify the salt size in use by the agent vault. Used by the FIPS distribution.

## Why is it important?

Current file vault does not meet FIPS compliance.

## Checklist

- [x] I have read and understood the [pull request guidelines](https://github.com/elastic/elastic-agent/blob/main/CONTRIBUTING.md#pull-request-guidelines) of this project.
- [x] My code follows the style guidelines of this project
- [x] I have commented my code, particularly in hard-to-understand areas
- ~~I have made corresponding changes to the documentation~~
- ~~I have made corresponding change to the default configuration files~~
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] I have added an entry in `./changelog/fragments` using the [changelog tool](https://github.com/elastic/elastic-agent#changelog)
- ~~I have added an integration test or an E2E test~~

## Disruptive User Impact

No replacement path from a non-FIPS to a FIPS-agent (or vice-versa).


Diff:
diff --git a/changelog/fragments/1741808763-FIPS-Compliant-agent-file-vault.yaml b/changelog/fragments/1741808763-FIPS-Compliant-agent-file-vault.yaml
new file mode 100644
index 00000000000..2dc7fb74c09
--- /dev/null
+++ b/changelog/fragments/1741808763-FIPS-Compliant-agent-file-vault.yaml
@@ -0,0 +1,35 @@
+# Kind can be one of:
+# - breaking-change: a change to previously-documented behavior
+# - deprecation: functionality that is being removed in a later release
+# - bug-fix: fixes a problem in a previous version
+# - enhancement: extends functionality but does not break or fix existing behavior
+# - feature: new functionality
+# - known-issue: problems that we are aware of in a given version
+# - security: impacts on the security of a product or a user’s deployment.
+# - upgrade: important information for someone upgrading from a prior version
+# - other: does not fit into any of the other categories
+kind: enhancement
+
+# Change summary; a 80ish characters long description of the change.
+summary: FIPS Compliant agent file vault
+
+# Long description; in case the summary is not enough to describe the change
+# this field accommodate a description without length limits.
+# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
+description: |
+  Change elastic file vault implementation to allow variable length salt sizes
+  only in FIPS enabled agents.  Increase default salt size to 16 for FIPS
+  compliance. Non-FIPS agents are unchanged.
+
+# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
+component: elastic-agent
+
+# PR URL; optional; the PR number that added the changeset.
+# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
+# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
+# Please provide it if you are adding a fragment for a different PR.
+pr: https://github.com/elastic/elastic-agent/pull/7360
+
+# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
+# If not present is automatically filled by the tooling with the issue linked to the PR number.
+#issue:
diff --git a/internal/pkg/agent/vault/seed.go b/internal/pkg/agent/vault/seed.go
index a89d603a38a..d23a42fcc14 100644
--- a/internal/pkg/agent/vault/seed.go
+++ b/internal/pkg/agent/vault/seed.go
@@ -5,7 +5,7 @@
 package vault
 
 import (
-	"errors"
+	"encoding/binary"
 	"fmt"
 	"io/fs"
 	"os"
@@ -16,62 +16,68 @@ import (
 )
 
 const (
-	seedFile = ".seed"
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
+	seedFile     = ".seed"
+	seedFileSize = int(aesgcm.AES256)
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = seedFileSize + 4
+)
+
+const (
+	saltSizeV1        = 8
+	defaultSaltSizeV2 = 16
 )
 
 var (
 	mxSeed sync.Mutex
 )
 
-func getSeed(path string) ([]byte, error) {
+// getSeedV1 will read the V1 .seed file
+// Will return fs.ErrNotExists if the bytecount does not match
+func getSeedV1(path string) ([]byte, error) {
 	fp := filepath.Join(path, seedFile)
 
-	mxSeed.Lock()
-	defer mxSeed.Unlock()
-
 	b, err := os.ReadFile(fp)
 	if err != nil {
 		return nil, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	// return fs.ErrNotExists if invalid length of bytes returned
-	if len(b) != int(aesgcm.AES256) {
-		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileSize {
+		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileSize, len(b), fs.ErrNotExist)
 	}
 	return b, nil
 }
 
-func createSeedIfNotExists(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
-	mxSeed.Lock()
-	defer mxSeed.Unlock()
+// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
+// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
+// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
+func getSeedV2(path string) ([]byte, int, error) {
+	fp := filepath.Join(path, seedFileV2)
 
 	b, err := os.ReadFile(fp)
 	if err != nil {
-		if !errors.Is(err, os.ErrNotExist) {
-			return nil, err
-		}
+		return nil, 0, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	if len(b) != 0 {
-		return b, nil
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileV2Size {
+		return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
 	}
-
-	seed, err := aesgcm.NewKey(aesgcm.AES256)
-	if err != nil {
-		return nil, err
+	pass := b[0:seedFileSize]
+	saltSize := binary.LittleEndian.Uint32(b[seedFileSize:])
+	if saltSize == 0 {
+		return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
 	}
-
-	err = os.WriteFile(fp, seed, 0600)
-	if err != nil {
-		return nil, err
+	if err := checkSalt(int(saltSize)); err != nil {
+		return nil, 0, err
 	}
-
-	return seed, nil
+	return pass, int(saltSize), nil
 }
 
-func getOrCreateSeed(path string, readonly bool) ([]byte, error) {
+func getOrCreateSeed(path string, readonly bool) ([]byte, int, error) {
 	if readonly {
 		return getSeed(path)
 	}
diff --git a/internal/pkg/agent/vault/seed_fips.go b/internal/pkg/agent/vault/seed_fips.go
new file mode 100644
index 00000000000..fa3004e6061
--- /dev/null
+++ b/internal/pkg/agent/vault/seed_fips.go
@@ -0,0 +1,70 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License 2.0;
+// you may not use this file except in compliance with the Elastic License 2.0.
+
+//go:build requirefips
+
+package vault
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
+)
+
+// getSeed returns the seed and salt size from the V2 seed file.
+// If the byte count does not match, or a 0 length salt is detected fs.ErrNotExist will be returned.
+func getSeed(path string) ([]byte, int, error) {
+	mxSeed.Lock()
+	defer mxSeed.Unlock()
+
+	// FIPS only supports V2
+	b, saltSize, err := getSeedV2(path)
+	if err != nil {
+		return nil, 0, err
+	}
+	return b, saltSize, nil
+}
+
+// checkSalt ensures the salt size is at least 16 bytes.
+func checkSalt(size int) error {
+	if size < defaultSaltSizeV2 {
+		return fmt.Errorf("expected salt to be at least %d: %w", defaultSaltSizeV2, errors.ErrUnsupported)
+	}
+	return nil
+}
+
+// createSeedIfNotExists returns the seed and salt size from the V2 seed file.
+// If the seed file does not exist it will create and write a new V2 seed file with a salt size of 16.
+func createSeedIfNotExists(path string) ([]byte, int, error) {
+	mxSeed.Lock()
+	defer mxSeed.Unlock()
+
+	pass, saltSize, err := getSeedV2(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSize, nil
+	}
+
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		return nil, 0, err
+	}
+	l := make([]byte, 4)
+	binary.LittleEndian.PutUint32(l, uint32(defaultSaltSizeV2))
+
+	err = os.WriteFile(filepath.Join(path, seedFileV2), append(seed, l...), 0600)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return seed, defaultSaltSizeV2, nil
+}
diff --git a/internal/pkg/agent/vault/seed_fips_test.go b/internal/pkg/agent/vault/seed_fips_test.go
new file mode 100644
index 00000000000..ab83319ef04
--- /dev/null
+++ b/internal/pkg/agent/vault/seed_fips_test.go
@@ -0,0 +1,110 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License 2.0;
+// you may not use this file except in compliance with the Elastic License 2.0.
+
+//go:build requirefips
+
+package vault
+
+import (
+	"encoding/binary"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
+)
+
+func TestGetSeedFailsV1File(t *testing.T) {
+	dir := t.TempDir()
+	fp := filepath.Join(dir, seedFile)
+
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(fp, seed, 0600)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, _, err = getSeed(dir)
+	if !errors.Is(err, os.ErrNotExist) {
+		t.Fatalf("FIPS mode must not read v1 seeds. expected error %v to be os.ErrNotExist", err)
+	}
+}
+
+func TestGetSeedV2File(t *testing.T) {
+	dir := t.TempDir()
+	fp := filepath.Join(dir, seedFileV2)
+
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		t.Fatal(err)
+	}
+	l := make([]byte, 4)
+	binary.LittleEndian.PutUint32(l, uint32(defaultSaltSizeV2))
+
+	err = os.WriteFile(fp, append(seed, l...), 0600)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	b, saltSize, err := getSeed(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if saltSize != defaultSaltSizeV2 {
+		t.Errorf("expected salt size to be %d, got: %d", defaultSaltSizeV2, saltSize)
+	}
+	diff := cmp.Diff(seed, b)
+	if diff != "" {
+		t.Error(diff)
+	}
+}
+
+func TestCreateSeedIfNotExists(t *testing.T) {
+	dir := t.TempDir()
+
+	fp := filepath.Join(dir, seedFile)
+	fpV2 := filepath.Join(dir, seedFileV2)
+
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	if _, err := os.Stat(fpV2); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+
+	b, saltSize, err := createSeedIfNotExists(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// V2 file should exist
+	if _, err := os.Stat(fpV2); err != nil {
+		t.Fatal(err)
+	}
+	// V1 file should not exist
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+
+	diff := cmp.Diff(seedFileSize, len(b))
+	if diff != "" {
+		t.Error(diff)
+	}
+	if saltSize != defaultSaltSizeV2 {
+		t.Errorf("expected salt size: %d got: %d", defaultSaltSizeV2, saltSize)
+	}
+}
diff --git a/internal/pkg/agent/vault/seed_nofips.go b/internal/pkg/agent/vault/seed_nofips.go
new file mode 100644
index 00000000000..3dc8790fde4
--- /dev/null
+++ b/internal/pkg/agent/vault/seed_nofips.go
@@ -0,0 +1,62 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License 2.0;
+// you may not use this file except in compliance with the Elastic License 2.0.
+
+//go:build !requirefips
+
+package vault
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+
+	"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
+)
+
+// getSeed returns the seed from the v1 .seed file
+// or fs.ErrNotExist if the bytecount does not match
+func getSeed(path string) ([]byte, int, error) {
+	mxSeed.Lock()
+	defer mxSeed.Unlock()
+
+	// Non fips only supports V1 seed
+	b, err := getSeedV1(path)
+	if err != nil {
+		return nil, 0, err
+	}
+	return b, saltSizeV1, nil
+}
+
+// checkSalt is a nop as V2 seeds are disabled for non-FIPS agents
+func checkSalt(_ int) error {
+	return nil
+}
+
+// createSeedIfNotExists returns the seed from the v1 .seed file
+// If the seed file does not exist it will create and write a new v1 seed file.
+func createSeedIfNotExists(path string) ([]byte, int, error) {
+	mxSeed.Lock()
+	defer mxSeed.Unlock()
+
+	pass, err := getSeedV1(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSizeV1, nil
+	}
+
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		return nil, 0, err
+	}
+	err = os.WriteFile(filepath.Join(path, seedFile), seed, 0600)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return seed, saltSizeV1, nil
+}
diff --git a/internal/pkg/agent/vault/seed_nofips_test.go b/internal/pkg/agent/vault/seed_nofips_test.go
new file mode 100644
index 00000000000..9336c7690e2
--- /dev/null
+++ b/internal/pkg/agent/vault/seed_nofips_test.go
@@ -0,0 +1,109 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License 2.0;
+// you may not use this file except in compliance with the Elastic License 2.0.
+
+//go:build !requirefips
+
+package vault
+
+import (
+	"encoding/binary"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
+)
+
+func TestGetSeedReturnsV1File(t *testing.T) {
+	dir := t.TempDir()
+	fp := filepath.Join(dir, seedFile)
+
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = os.WriteFile(fp, seed, 0600)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	b, saltSize, err := getSeed(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if diff := cmp.Diff(seed, b); diff != "" {
+		t.Error(diff)
+	}
+	if saltSize != saltSizeV1 {
+		t.Errorf("expected salt size: %d got: %d", saltSizeV1, saltSize)
+	}
+}
+
+func TestGetSeedFailsV2File(t *testing.T) {
+	dir := t.TempDir()
+	fp := filepath.Join(dir, seedFileV2)
+
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		t.Fatal(err)
+	}
+	l := make([]byte, 4)
+	binary.LittleEndian.PutUint32(l, uint32(defaultSaltSizeV2))
+
+	err = os.WriteFile(fp, append(seed, l...), 0600)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	_, _, err = getSeed(dir)
+	if !errors.Is(err, os.ErrNotExist) {
+		t.Fatalf("non-FIPS mode must not read v2 seeds. expected error %v to be os.ErrNotExist", err)
+	}
+}
+
+func TestCreateSeedIfNotExists(t *testing.T) {
+	dir := t.TempDir()
+
+	fp := filepath.Join(dir, seedFile)
+	fpV2 := filepath.Join(dir, seedFileV2)
+
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	if _, err := os.Stat(fpV2); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+
+	b, saltSize, err := createSeedIfNotExists(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// V2 file should not exist
+	if _, err := os.Stat(fpV2); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+	// V1 file should exist
+	if _, err := os.Stat(fp); err != nil {
+		t.Fatal(err)
+	}
+
+	diff := cmp.Diff(seedFileSize, len(b))
+	if diff != "" {
+		t.Error(diff)
+	}
+	if saltSize != saltSizeV1 {
+		t.Errorf("expected salt size: %d got: %d", saltSizeV1, saltSize)
+	}
+}
diff --git a/internal/pkg/agent/vault/seed_test.go b/internal/pkg/agent/vault/seed_test.go
index deaefa256d9..4ddb659dfa1 100644
--- a/internal/pkg/agent/vault/seed_test.go
+++ b/internal/pkg/agent/vault/seed_test.go
@@ -6,6 +6,7 @@ package vault
 
 import (
 	"context"
+	"encoding/binary"
 	"encoding/hex"
 	"errors"
 	"os"
@@ -19,7 +20,7 @@ import (
 	"github.com/elastic/elastic-agent/internal/pkg/agent/vault/aesgcm"
 )
 
-func TestGetSeed(t *testing.T) {
+func TestGetSeedV1(t *testing.T) {
 	dir := t.TempDir()
 
 	fp := filepath.Join(dir, seedFile)
@@ -30,62 +31,92 @@ func TestGetSeed(t *testing.T) {
 	}
 
 	// seed is not yet created
-	if _, err := getSeed(dir); !errors.Is(err, os.ErrNotExist) {
+	if _, err := getSeedV1(dir); !errors.Is(err, os.ErrNotExist) {
 		t.Fatal(err)
 	}
-
 	// should be not found
 	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
 		t.Fatal(err)
 	}
 
-	b, err := createSeedIfNotExists(dir)
+	// create seed manually
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
 	if err != nil {
 		t.Fatal(err)
 	}
-
-	// file should exist
-	if _, err := os.Stat(fp); err != nil {
+	err = os.WriteFile(fp, seed, 0600)
+	if err != nil {
 		t.Fatal(err)
 	}
 
-	diff := cmp.Diff(int(aesgcm.AES256), len(b))
-	if diff != "" {
-		t.Error(diff)
+	if _, err := os.Stat(fp); err != nil {
+		t.Fatal(err)
 	}
 
-	// try get seed
-	gotSeed, err := getSeed(dir)
+	b, err := getSeedV1(dir)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	diff = cmp.Diff(b, gotSeed)
+	if seedFileSize != len(b) {
+		t.Errorf("expected seed file size to be %d, got: %d", seedFileSize, len(b))
+	}
+
+	diff := cmp.Diff(seed, b)
 	if diff != "" {
 		t.Error(diff)
 	}
 }
 
-func TestCreateSeedIfNotExists(t *testing.T) {
+func TestGetSeedV2(t *testing.T) {
 	dir := t.TempDir()
 
-	fp := filepath.Join(dir, seedFile)
+	fp := filepath.Join(dir, seedFileV2)
 
+	// check the test prerequisites
 	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
 		t.Fatal(err)
 	}
 
-	b, err := createSeedIfNotExists(dir)
+	// seed is not yet created
+	if _, _, err := getSeedV2(dir); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+
+	// should be not found
+	if _, err := os.Stat(fp); !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+
+	// create seed manually
+	seed, err := aesgcm.NewKey(aesgcm.AES256)
+	if err != nil {
+		t.Fatal(err)
+	}
+	l := make([]byte, 4)
+	binary.LittleEndian.PutUint32(l, uint32(defaultSaltSizeV2))
+	err = os.WriteFile(fp, append(seed, l...), 0600)
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	// file should exist
 	if _, err := os.Stat(fp); err != nil {
 		t.Fatal(err)
 	}
 
-	diff := cmp.Diff(int(aesgcm.AES256), len(b))
+	b, saltSize, err := getSeedV2(dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if seedFileSize != len(b) {
+		t.Errorf("expected seed length to be %d, got: %d", seedFileSize, len(b))
+	}
+	if saltSize != defaultSaltSizeV2 {
+		t.Errorf("expected salt size: %d got: %d", defaultSaltSizeV2, saltSize)
+	}
+
+	diff := cmp.Diff(seed, b)
 	if diff != "" {
 		t.Error(diff)
 	}
@@ -105,7 +136,7 @@ func TestCreateSeedIfNotExistsRace(t *testing.T) {
 	for i := 0; i < count; i++ {
 		g.Go(func(idx int) func() error {
 			return func() error {
-				seed, err := createSeedIfNotExists(dir)
+				seed, _, err := createSeedIfNotExists(dir)
 				mx.Lock()
 				res[idx] = seed
 				mx.Unlock()
diff --git a/internal/pkg/agent/vault/vault_file.go b/internal/pkg/agent/vault/vault_file.go
index 6522c080710..e66d8713c91 100644
--- a/internal/pkg/agent/vault/vault_file.go
+++ b/internal/pkg/agent/vault/vault_file.go
@@ -19,8 +19,9 @@ import (
 )
 
 type FileVault struct {
-	path string
-	seed []byte
+	path     string
+	seed     []byte
+	saltSize int
 
 	lockRetryDelay time.Duration
 	lock           *flock.Flock
@@ -74,7 +75,7 @@ func NewFileVault(ctx context.Context, options Options) (v *FileVault, err error
 		err = r.unlockAndJoinErrors(err)
 	}()
 
-	r.seed, err = getOrCreateSeed(path, options.readonly)
+	r.seed, r.saltSize, err = getOrCreateSeed(path, options.readonly)
 	if err != nil {
 		return nil, fmt.Errorf("could not get or create seed for the vault at %s: %w", path, err)
 	}
diff --git a/internal/pkg/agent/vault/vault_file_fips_test.go b/internal/pkg/agent/vault/vault_file_fips_test.go
new file mode 100644
index 00000000000..a5fe3d04051
--- /dev/null
+++ b/internal/pkg/agent/vault/vault_file_fips_test.go
@@ -0,0 +1,72 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License 2.0;
+// you may not use this file except in compliance with the Elastic License 2.0.
+
+//go:build requirefips
+
+package vault
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestFileVaultRekey(t *testing.T) {
+	const key = "foo"
+
+	ctx, cn := context.WithCancel(context.Background())
+	defer cn()
+
+	vaultPath := getTestFileVaultPath(t)
+	options, err := ApplyOptions(WithVaultPath(vaultPath))
+	if err != nil {
+		t.Fatal(err)
+	}
+	v, err := NewFileVault(ctx, options)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer v.Close()
+
+	err = v.Set(ctx, key, []byte("bar"))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Read seed file value
+	seedPath := filepath.Join(vaultPath, seedFileV2)
+	seedFileBytes, err := os.ReadFile(seedPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	diff := cmp.Diff(seedFileV2Size, len(seedFileBytes))
+	if diff != "" {
+		t.Fatal(diff)
+	}
+
+	// Remove the .seedV2 file.
+	// This will cause the vault seed to be reinitialized for the new vault instance
+	err = os.Remove(seedPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// The vault with the new seed
+	v2, err := NewFileVault(ctx, options)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer v2.Close()
+
+	// The key should be not found
+	_, err = v2.Get(ctx, key)
+	if !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+}
diff --git a/internal/pkg/agent/vault/vault_file_nofips_test.go b/internal/pkg/agent/vault/vault_file_nofips_test.go
new file mode 100644
index 00000000000..5aa58ad1d52
--- /dev/null
+++ b/internal/pkg/agent/vault/vault_file_nofips_test.go
@@ -0,0 +1,72 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License 2.0;
+// you may not use this file except in compliance with the Elastic License 2.0.
+
+//go:build !requirefips
+
+package vault
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestFileVaultRekey(t *testing.T) {
+	const key = "foo"
+
+	ctx, cn := context.WithCancel(context.Background())
+	defer cn()
+
+	vaultPath := getTestFileVaultPath(t)
+	options, err := ApplyOptions(WithVaultPath(vaultPath))
+	if err != nil {
+		t.Fatal(err)
+	}
+	v, err := NewFileVault(ctx, options)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer v.Close()
+
+	err = v.Set(ctx, key, []byte("bar"))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Read seed file value
+	seedPath := filepath.Join(vaultPath, seedFile)
+	seedFileBytes, err := os.ReadFile(seedPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	diff := cmp.Diff(seedFileSize, len(seedFileBytes))
+	if diff != "" {
+		t.Fatal(diff)
+	}
+
+	// Remove the .seed file.
+	// This will cause the vault seed to be reinitialized for the new vault instance
+	err = os.Remove(seedPath)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// The vault with the new seed
+	v2, err := NewFileVault(ctx, options)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer v2.Close()
+
+	// The key should be not found
+	_, err = v2.Get(ctx, key)
+	if !errors.Is(err, os.ErrNotExist) {
+		t.Fatal(err)
+	}
+}
diff --git a/internal/pkg/agent/vault/vault_file_notwindows.go b/internal/pkg/agent/vault/vault_file_notwindows.go
index 343820498d1..10d12ad6a80 100644
--- a/internal/pkg/agent/vault/vault_file_notwindows.go
+++ b/internal/pkg/agent/vault/vault_file_notwindows.go
@@ -21,13 +21,12 @@ import (
 )
 
 const (
-	saltSize      = 8
 	keyLen    int = 32
 	iterCount int = 12022
 )
 
 func (v *FileVault) encrypt(data []byte) ([]byte, error) {
-	key, salt, err := deriveKey(v.seed, nil)
+	key, salt, err := deriveKey(v.seed, v.saltSize, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -39,18 +38,18 @@ func (v *FileVault) encrypt(data []byte) ([]byte, error) {
 }
 
 func (v *FileVault) decrypt(data []byte) ([]byte, error) {
-	if len(data) < saltSize {
+	if len(data) < v.saltSize {
 		return nil, syscall.EINVAL
 	}
-	salt, data := data[:saltSize], data[saltSize:]
-	key, _, err := deriveKey(v.seed, salt)
+	salt, data := data[:v.saltSize], data[v.saltSize:]
+	key, _, err := deriveKey(v.seed, v.saltSize, salt)
 	if err != nil {
 		return nil, err
 	}
 	return aesgcm.Decrypt(key, data)
 }
 
-func deriveKey(pw []byte, salt []byte) ([]byte, []byte, error) {
+func deriveKey(pw []byte, saltSize int, salt []byte) ([]byte, []byte, error) {
 	if salt == nil {
 		salt = make([]byte, saltSize)
 		if _, err := rand.Read(salt); err != nil {
diff --git a/internal/pkg/agent/vault/vault_file_test.go b/internal/pkg/agent/vault/vault_file_test.go
index b96438bc5dd..bbfd1fda206 100644
--- a/internal/pkg/agent/vault/vault_file_test.go
+++ b/internal/pkg/agent/vault/vault_file_test.go
@@ -7,9 +7,7 @@ package vault
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
-	"os"
 	"path/filepath"
 	"time"
 
@@ -26,61 +24,6 @@ func getTestFileVaultPath(t *testing.T) string {
 	return filepath.Join(dir, "vault")
 }
 
-func TestFileVaultRekey(t *testing.T) {
-	const key = "foo"
-
-	ctx, cn := context.WithCancel(context.Background())
-	defer cn()
-
-	vaultPath := getTestFileVaultPath(t)
-	options, err := ApplyOptions(WithVaultPath(vaultPath))
-	if err != nil {
-		t.Fatal(err)
-	}
-	v, err := NewFileVault(ctx, options)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer v.Close()
-
-	err = v.Set(ctx, key, []byte("bar"))
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// Read seed file value
-	seedPath := filepath.Join(vaultPath, ".seed")
-	seedBytes, err := os.ReadFile(seedPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	diff := cmp.Diff(int(aesgcm.AES256), len(seedBytes))
-	if diff != "" {
-		t.Fatal(diff)
-	}
-
-	// Remove the .seed file.
-	// This will cause the vault seed to be reinitialized for the new vault instance
-	err = os.Remove(seedPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// The vault with the new seed
-	v2, err := NewFileVault(ctx, options)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer v2.Close()
-
-	// The key should be not found
-	_, err = v2.Get(ctx, key)
-	if !errors.Is(err, os.ErrNotExist) {
-		t.Fatal(err)
-	}
-}
-
 func TestFileVault(t *testing.T) {
 	vaultPath := getTestFileVaultPath(t)
 


Reviews:
user: login: michel-laterman
body: This is currently a draft as I need to do testing.
I may need to have a different default salt size for fips/nonfips distributions to start.

My thought is that this approach will give us the ability to migrate from saltSize 8 to 16 which will be useful if customers want to migrate agents (they will need to go from agent version `N-1 -> N -> N-fips`).

Migration is not part of this PR.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T20:04:48Z
user: login: blakerouse
body: I really want to just understand what happens here so 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T02:04:24Z
user: login: pkoutsovasilis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:37:19Z
user: login: pkoutsovasilis
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:46:38Z
user: login: simitt
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:53:38Z
user: login: michel-laterman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T18:20:14Z
user: login: blakerouse
body: With product saying that we don't need an upgrade path then I am good with this PR. I think this decision will be re-visited in the future. I could see an organization with 100+ agents having to re-deploy to get FIPS as being an issue.

I would think we could support upgrade to non-FIPS that adjusts the size of the salt to FIPS compliant, then upgrade to a FIPS compliant binary that is now using the salt. It is interesting topic if a FIPS binary upgrade using non-FIPS compliant path to support an upgrade is not non-FIPS if then it uses FIPS from that upgrade. To me this is an interesting discussion.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T00:27:06Z
user: login: pkoutsovasilis
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T11:46:32Z
user: login: simitt
body: >Add elastic file vault seed implementation to allow variable length salt sizes.
>.seedV2 files are new; they contain the seed followed by the salt size (as a little endian uint32 value). This allows specify the salt size in use by the agent vault. Used by the FIPS distribution.

I don't see how the vault salt size can be set, it always uses the default of size 16 for fips. It's also not clear to me why it would need to be able to deal with variable salt size? 

Looking at the current code, are all these changes really necessary and relevant? 
- now that non-fips is unchanged, what is the reasoning behind introducing a `.seedV2` file for fips rather than also using the `.seed` file? 
- The only place where I see a salt size before the changes is in `vault_file_notwindows.go`. It can easily be that I am overlooking something, so please push back if that is the case, but wouldn't the simplest required change be to only switch the `saltSize=16` in this file for the `fips` case and keep everything else untouched? Can you elaborate why this wouldn't be sufficient or desired? 
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T12:49:33Z
user: login: simitt
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:49:19Z
user: login: nimarezainia
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T22:15:00Z

Review comments:
diff_hunk: @@ -16,14 +17,23 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256+4) and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2 = ".seedV2"
path: internal/pkg/agent/vault/seed.go
user: login: michel-laterman
body: Currently, we have hard-coded values for iterations, and keyLen. Should we add these to the seed file to allow for changes?
created_at: 2025-03-12T20:01:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,62 +17,116 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = int(aesgcm.AES256) + 4
+)
+
+const (
+	saltSizeV1        = 8
+	defaultSaltSizeV2 = 16
 )
 
 var (
 	mxSeed sync.Mutex
 )
 
-func getSeed(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func getSeed(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
+	// Prefer V2 seeds
+	b, saltSize, errV2 := getSeedV2(path)
+	if errV2 == nil {
+		return b, saltSize, nil
+	}
+	// Fallback to V1 seed
+	b, errV1 := getSeedV1(path)
+	if errV1 == nil {
+		return b, saltSizeV1, nil
+	}
+	return nil, 0, errors.Join(errV2, errV1)
+}
+
+// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
+// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
+// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
+func getSeedV2(path string) ([]byte, int, error) {
+	fp := filepath.Join(path, seedFileV2)
+
 	b, err := os.ReadFile(fp)
 	if err != nil {
-		return nil, fmt.Errorf("could not read seed file: %w", err)
+		return nil, 0, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	// return fs.ErrNotExists if invalid length of bytes returned
-	if len(b) != int(aesgcm.AES256) {
-		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileV2Size {
+		return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
+	}
+	pass := b[0:int(aesgcm.AES256)]
+	saltSize := binary.LittleEndian.Uint32(b[int(aesgcm.AES256):])
+	if saltSize == 0 {
+		return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
 	}
-	return b, nil
+	if err := checkSalt(int(saltSize)); err != nil {
+		return nil, 0, err
+	}
+	return pass, int(saltSize), nil
 }
 
-func createSeedIfNotExists(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func createSeedIfNotExists(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
-	b, err := os.ReadFile(fp)
+	// Prefer reading V2 seeds
+	pass, saltSize, err := getSeedV2(path)
 	if err != nil {
 		if !errors.Is(err, os.ErrNotExist) {
-			return nil, err
+			return nil, 0, err
 		}
 	}
+	if len(pass) != 0 {
+		return pass, saltSize, nil
+	}
 
-	if len(b) != 0 {
-		return b, nil
+	// V1 seed fallback
+	// getSeedV1 will return ErrNotExist when in FIPS mode.
+	pass, err = getSeedV1(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSizeV1, nil
path: internal/pkg/agent/vault/seed.go
user: login: blakerouse
body: Based on my reading here this means that if v2 seed is not present and only v1 is present then it will only read the v1 seed and never create the v2 seed. I am reading that correctly, yes?

How does that work when upgrading from a non-fips to a fips agent? Does it re-create the vault with a new large seed and move all of the data over from non-fips to fips compliant?
created_at: 2025-03-18T02:04:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,62 +17,116 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = int(aesgcm.AES256) + 4
path: internal/pkg/agent/vault/seed.go
user: login: pkoutsovasilis
body: nit: define a const of `int(aesgcm.AES256)` and derive seedFileV2Size from it + 4. Then use the AES256 const when reading the file in `getSeedV2` func
created_at: 2025-03-18T13:37:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,62 +17,116 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = int(aesgcm.AES256) + 4
+)
+
+const (
+	saltSizeV1        = 8
+	defaultSaltSizeV2 = 16
 )
 
 var (
 	mxSeed sync.Mutex
 )
 
-func getSeed(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func getSeed(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
+	// Prefer V2 seeds
+	b, saltSize, errV2 := getSeedV2(path)
+	if errV2 == nil {
+		return b, saltSize, nil
+	}
+	// Fallback to V1 seed
+	b, errV1 := getSeedV1(path)
+	if errV1 == nil {
+		return b, saltSizeV1, nil
+	}
+	return nil, 0, errors.Join(errV2, errV1)
+}
+
+// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
+// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
+// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
+func getSeedV2(path string) ([]byte, int, error) {
+	fp := filepath.Join(path, seedFileV2)
+
 	b, err := os.ReadFile(fp)
 	if err != nil {
-		return nil, fmt.Errorf("could not read seed file: %w", err)
+		return nil, 0, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	// return fs.ErrNotExists if invalid length of bytes returned
-	if len(b) != int(aesgcm.AES256) {
-		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileV2Size {
+		return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
+	}
+	pass := b[0:int(aesgcm.AES256)]
+	saltSize := binary.LittleEndian.Uint32(b[int(aesgcm.AES256):])
+	if saltSize == 0 {
+		return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
 	}
-	return b, nil
+	if err := checkSalt(int(saltSize)); err != nil {
+		return nil, 0, err
+	}
+	return pass, int(saltSize), nil
 }
 
-func createSeedIfNotExists(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func createSeedIfNotExists(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
-	b, err := os.ReadFile(fp)
+	// Prefer reading V2 seeds
+	pass, saltSize, err := getSeedV2(path)
 	if err != nil {
 		if !errors.Is(err, os.ErrNotExist) {
-			return nil, err
+			return nil, 0, err
 		}
 	}
+	if len(pass) != 0 {
+		return pass, saltSize, nil
+	}
 
-	if len(b) != 0 {
-		return b, nil
+	// V1 seed fallback
+	// getSeedV1 will return ErrNotExist when in FIPS mode.
+	pass, err = getSeedV1(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSizeV1, nil
path: internal/pkg/agent/vault/seed.go
user: login: pkoutsovasilis
body: I will pose the question, should we support upgrades from a non-fips to a fips agent? 🙂 the PR description suggests show

> This should allow a migration path for existing vaults, and it's an alternative to https://github.com/elastic/elastic-agent/pull/7187 where salt size for vault changes based on FIPS/non-FIPS with no way to migrate.

but I don't see it as well. Unless we mean our state starts clean without any data from previous store. Am I missing something? 🤔 
created_at: 2025-03-18T13:46:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,62 +17,116 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = int(aesgcm.AES256) + 4
+)
+
+const (
+	saltSizeV1        = 8
+	defaultSaltSizeV2 = 16
 )
 
 var (
 	mxSeed sync.Mutex
 )
 
-func getSeed(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func getSeed(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
+	// Prefer V2 seeds
+	b, saltSize, errV2 := getSeedV2(path)
+	if errV2 == nil {
+		return b, saltSize, nil
+	}
+	// Fallback to V1 seed
+	b, errV1 := getSeedV1(path)
+	if errV1 == nil {
+		return b, saltSizeV1, nil
+	}
+	return nil, 0, errors.Join(errV2, errV1)
+}
+
+// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
+// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
+// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
+func getSeedV2(path string) ([]byte, int, error) {
+	fp := filepath.Join(path, seedFileV2)
+
 	b, err := os.ReadFile(fp)
 	if err != nil {
-		return nil, fmt.Errorf("could not read seed file: %w", err)
+		return nil, 0, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	// return fs.ErrNotExists if invalid length of bytes returned
-	if len(b) != int(aesgcm.AES256) {
-		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileV2Size {
+		return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
+	}
+	pass := b[0:int(aesgcm.AES256)]
+	saltSize := binary.LittleEndian.Uint32(b[int(aesgcm.AES256):])
+	if saltSize == 0 {
+		return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
 	}
-	return b, nil
+	if err := checkSalt(int(saltSize)); err != nil {
+		return nil, 0, err
+	}
+	return pass, int(saltSize), nil
 }
 
-func createSeedIfNotExists(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func createSeedIfNotExists(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
-	b, err := os.ReadFile(fp)
+	// Prefer reading V2 seeds
+	pass, saltSize, err := getSeedV2(path)
 	if err != nil {
 		if !errors.Is(err, os.ErrNotExist) {
-			return nil, err
+			return nil, 0, err
 		}
 	}
+	if len(pass) != 0 {
+		return pass, saltSize, nil
+	}
 
-	if len(b) != 0 {
-		return b, nil
+	// V1 seed fallback
+	// getSeedV1 will return ErrNotExist when in FIPS mode.
+	pass, err = getSeedV1(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSizeV1, nil
path: internal/pkg/agent/vault/seed.go
user: login: simitt
body: >I will pose the question, should we support upgrades from a non-fips to a fips agent? 

+1 on this question; I believe @nimarezainia already confirmed in a different conversation that this is not required - @nimarezainia keep me honest here. 
created_at: 2025-03-18T13:53:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,62 +17,116 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = int(aesgcm.AES256) + 4
+)
+
+const (
+	saltSizeV1        = 8
+	defaultSaltSizeV2 = 16
 )
 
 var (
 	mxSeed sync.Mutex
 )
 
-func getSeed(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func getSeed(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
+	// Prefer V2 seeds
+	b, saltSize, errV2 := getSeedV2(path)
+	if errV2 == nil {
+		return b, saltSize, nil
+	}
+	// Fallback to V1 seed
+	b, errV1 := getSeedV1(path)
+	if errV1 == nil {
+		return b, saltSizeV1, nil
+	}
+	return nil, 0, errors.Join(errV2, errV1)
+}
+
+// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
+// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
+// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
+func getSeedV2(path string) ([]byte, int, error) {
+	fp := filepath.Join(path, seedFileV2)
+
 	b, err := os.ReadFile(fp)
 	if err != nil {
-		return nil, fmt.Errorf("could not read seed file: %w", err)
+		return nil, 0, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	// return fs.ErrNotExists if invalid length of bytes returned
-	if len(b) != int(aesgcm.AES256) {
-		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileV2Size {
+		return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
+	}
+	pass := b[0:int(aesgcm.AES256)]
+	saltSize := binary.LittleEndian.Uint32(b[int(aesgcm.AES256):])
+	if saltSize == 0 {
+		return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
 	}
-	return b, nil
+	if err := checkSalt(int(saltSize)); err != nil {
+		return nil, 0, err
+	}
+	return pass, int(saltSize), nil
 }
 
-func createSeedIfNotExists(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func createSeedIfNotExists(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
-	b, err := os.ReadFile(fp)
+	// Prefer reading V2 seeds
+	pass, saltSize, err := getSeedV2(path)
 	if err != nil {
 		if !errors.Is(err, os.ErrNotExist) {
-			return nil, err
+			return nil, 0, err
 		}
 	}
+	if len(pass) != 0 {
+		return pass, saltSize, nil
+	}
 
-	if len(b) != 0 {
-		return b, nil
+	// V1 seed fallback
+	// getSeedV1 will return ErrNotExist when in FIPS mode.
+	pass, err = getSeedV1(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSizeV1, nil
path: internal/pkg/agent/vault/seed.go
user: login: michel-laterman
body: I'll remove all v2 support from non-fips agents
created_at: 2025-03-18T18:20:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -16,62 +17,116 @@ import (
 )
 
 const (
+	// seedFile is len(aesgcm.AES256) and contains only the random seed
+	// A default salt size of 8 is used with this seed file
 	seedFile = ".seed"
+	// seedFileV2 is len(aesgcm.AES256)+4 and contains the random seed followed by a non-zero salt size (little endian uint32)
+	seedFileV2     = ".seedV2"
+	seedFileV2Size = int(aesgcm.AES256) + 4
+)
+
+const (
+	saltSizeV1        = 8
+	defaultSaltSizeV2 = 16
 )
 
 var (
 	mxSeed sync.Mutex
 )
 
-func getSeed(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func getSeed(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
+	// Prefer V2 seeds
+	b, saltSize, errV2 := getSeedV2(path)
+	if errV2 == nil {
+		return b, saltSize, nil
+	}
+	// Fallback to V1 seed
+	b, errV1 := getSeedV1(path)
+	if errV1 == nil {
+		return b, saltSizeV1, nil
+	}
+	return nil, 0, errors.Join(errV2, errV1)
+}
+
+// getSeedV2 will read a seedV2 file and return the passphrase and saltSize
+// Will return fs.ErrNotExists if the byte count does not match, or saltSize is 0
+// when in FIPS mode will return fs.ErrUnsupported when saltSize is non-zero but less then 16
+func getSeedV2(path string) ([]byte, int, error) {
+	fp := filepath.Join(path, seedFileV2)
+
 	b, err := os.ReadFile(fp)
 	if err != nil {
-		return nil, fmt.Errorf("could not read seed file: %w", err)
+		return nil, 0, fmt.Errorf("could not read seed file: %w", err)
 	}
 
-	// return fs.ErrNotExists if invalid length of bytes returned
-	if len(b) != int(aesgcm.AES256) {
-		return nil, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", int(aesgcm.AES256), len(b), fs.ErrNotExist)
+	// return fs.ErrNotExist if invalid length of bytes returned
+	if len(b) != seedFileV2Size {
+		return nil, 0, fmt.Errorf("invalid seed length, expected: %v, got: %v: %w", seedFileV2Size, len(b), fs.ErrNotExist)
+	}
+	pass := b[0:int(aesgcm.AES256)]
+	saltSize := binary.LittleEndian.Uint32(b[int(aesgcm.AES256):])
+	if saltSize == 0 {
+		return nil, 0, fmt.Errorf("salt size 0 detected: %w", fs.ErrNotExist)
 	}
-	return b, nil
+	if err := checkSalt(int(saltSize)); err != nil {
+		return nil, 0, err
+	}
+	return pass, int(saltSize), nil
 }
 
-func createSeedIfNotExists(path string) ([]byte, error) {
-	fp := filepath.Join(path, seedFile)
-
+func createSeedIfNotExists(path string) ([]byte, int, error) {
 	mxSeed.Lock()
 	defer mxSeed.Unlock()
 
-	b, err := os.ReadFile(fp)
+	// Prefer reading V2 seeds
+	pass, saltSize, err := getSeedV2(path)
 	if err != nil {
 		if !errors.Is(err, os.ErrNotExist) {
-			return nil, err
+			return nil, 0, err
 		}
 	}
+	if len(pass) != 0 {
+		return pass, saltSize, nil
+	}
 
-	if len(b) != 0 {
-		return b, nil
+	// V1 seed fallback
+	// getSeedV1 will return ErrNotExist when in FIPS mode.
+	pass, err = getSeedV1(path)
+	if err != nil {
+		if !errors.Is(err, os.ErrNotExist) {
+			return nil, 0, err
+		}
+	}
+	if len(pass) != 0 {
+		return pass, saltSizeV1, nil
path: internal/pkg/agent/vault/seed.go
user: login: nimarezainia
body: yes agreed. I can't see a scenario where the user goes between the two flavours. That is not to say someone won;t come up with one (complaining they don;t have access to the machines to do a reinstall). I think instinctively users would understand that fip is a new binary and needs a new install.  For Fleet managed agents, upgrading to a different falvor is not even possible. For standalone users the 'upgrade' command doesn't allow a different binary to be applied. 

I would say it's safe to assume moving to a fips enabled image needs a new installation.
created_at: 2025-03-19T22:15:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: mergify[bot]
created_at: 2025-03-12T19:59:44Z
body: This pull request does not have a backport label. Could you fix it @michel-laterman? 🙏
To fixup this pull request, you need to add the backport labels for the needed
branches, such as:
* `backport-./d./d` is the label that automatically backports to the `8./d` branch. `/d` is the digit
* `backport-active-all` is the label that automatically backports to all active branches.
* `backport-active-8` is the label that automatically backports to all active minor branches for the 8 major.
* `backport-active-9` is the label that automatically backports to all active minor branches for the 9 major.
author_association: CONTRIBUTOR
user: login: blakerouse
created_at: 2025-03-12T20:58:30Z
body: > This is currently a draft as I need to do testing. I may need to have a different default salt size for fips/nonfips distributions to start.
> 
> My thought is that this approach will give us the ability to migrate from saltSize 8 to 16 which will be useful if customers want to migrate agents (they will need to go from agent version `N-1 -> N -> N-fips`).

Can you explain this more? Why not just `N-1 -> N-fips`? Could it be made that way?


author_association: CONTRIBUTOR
user: login: michel-laterman
created_at: 2025-03-12T21:05:47Z
body: > > This is currently a draft as I need to do testing. I may need to have a different default salt size for fips/nonfips distributions to start.
> > My thought is that this approach will give us the ability to migrate from saltSize 8 to 16 which will be useful if customers want to migrate agents (they will need to go from agent version `N-1 -> N -> N-fips`).
> 
> Can you explain this more? Why not just `N-1 -> N-fips`? Could it be made that way?

I'll admit that i'm not 100% sure on this; I'm assuming that a FIPS agent can not run the migration as it would need to use non-compliant crypto settings in order to do so
author_association: CONTRIBUTOR
user: login: michel-laterman
created_at: 2025-03-13T00:21:16Z
body: Right now the integration tests fail as one of the upgrade tests tries to go from an artifact that is built from this PR to the latest snapshot.
In terms of changes from this PR it goes from a vault with the V2 seed file + vault, to no seed file detected.
After we "upgrade" the vault contains:

```
drwxr-x--- 2 root root 4096 Mar 12 17:14 .
drwxrwx--- 5 root root 4096 Mar 12 17:14 ..
-rw------- 1 root root    0 Mar 12 17:11 .lock
-rw------- 1 root root   32 Mar 12 17:14 .seed
-rw------- 1 root root   36 Mar 12 17:11 .seedV2
-rw------- 1 root root  125 Mar 12 17:14 8c6b8035489e04adf7302525047eb100b54b791916b09a3cc5c73b8b59bb09ee # V1 vault
-rw------- 1 root root  133 Mar 12 17:11 8fee45c194974431fa8a0cfa3355ed15e6c0e742fdfb4992767198a77dd16bd3 # V2 vault
```

The upgraded agent fails to start with `could not load agent info: could not get agent info from store: fail to read configuration /opt/Elastic/Agent/fleet.enc for the agent: fail to decode bytes: cipher: message authentication failed`
author_association: CONTRIBUTOR
user: login: elasticmachine
created_at: 2025-03-13T18:15:59Z
body: Pinging @elastic/elastic-agent-control-plane (Team:Elastic-Agent-Control-Plane)
author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-03-18T02:04:58Z
body: This pull request is now in conflicts. Could you fix it? 🙏
To fixup this pull request, you can check out it locally. See documentation: https://help.github.com/articles/checking-out-pull-requests-locally/
```
git fetch upstream
git checkout -b fips-agent-vault upstream/fips-agent-vault
git merge upstream/main
git push upstream fips-agent-vault
```
author_association: CONTRIBUTOR
user: login: pkoutsovasilis
created_at: 2025-03-19T11:56:32Z
body: interestingly enough I do see a CI failure with this error

```
POST git-upload-pack (305 bytes)
--
  | From https://github.com/elastic/elastic-agent
  | * branch                  main       -> FETCH_HEAD
  | = [up to date]            main       -> origin/main
  | Warning: you are leaving 9 commits behind, not connected to
  | any of your branches:
  |  
  | c1beeb9557 Remove V2 seed functions from non-FIPS agents
  | 83a2014771 Provide v1 seedfile when v2 size matches
  | 18493241fa Use saltSize of 8 for non-fips
  | e2440300fe Fix unit tests
  | ... and 5 more.
  |  
  | If you want to keep them by creating a new branch, this may be a good time
  | to do so with:
  |  
  | git branch <new-branch-name> c1beeb9557
  |  
  | HEAD is now at 2336fb88bf [CI] Grant build and read permission for serverless beats pipeline (#7449)
  | Current branch: HEAD
  | Switched to a new branch 'pr_merge_7360'
  | New branch created: pr_merge_7360
  | Auto-merging internal/pkg/agent/vault/vault_file_notwindows.go
  | CONFLICT (content): Merge conflict in internal/pkg/agent/vault/vault_file_notwindows.go
  | Automatic merge failed; fix conflicts and then commit the result.
  | Merge failed: 1
``` 

but GH reports no conflicts 🤔 
author_association: CONTRIBUTOR
user: login: michel-laterman
created_at: 2025-03-19T15:19:10Z
body: Going for least lines of (code) change in this case really limits the agent in the future if we ever need to re-visit any of our agent state encryption.

Additionally, having an explicit `.seedV2` makes it really clear that it's not interoperable with the existing `.seed` files. This can also be a clear signal to us if we need to debug instances where the agent has been replaced (for FIPS) but for some reason the vault was left  intact.
author_association: CONTRIBUTOR
user: login: elastic-sonarqube[bot]
created_at: 2025-03-20T18:39:14Z
body: ## [![Quality Gate passed](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonar.elastic.dev/dashboard?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360) **Quality Gate passed**  
Issues  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonar.elastic.dev/project/issues?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&resolved=false) [0 New issues](https://sonar.elastic.dev/project/issues?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&resolved=false)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/fixed-16px.png '')](https://sonar.elastic.dev/project/issues?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&fixedInPullRequest=7360) [0 Fixed issues](https://sonar.elastic.dev/project/issues?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&fixedInPullRequest=7360)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/accepted-16px.png '')](https://sonar.elastic.dev/project/issues?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&issueStatuses=ACCEPTED) [0 Accepted issues](https://sonar.elastic.dev/project/issues?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&issueStatuses=ACCEPTED)

Measures  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonar.elastic.dev/security_hotspots?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360) [0 Security Hotspots](https://sonar.elastic.dev/security_hotspots?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonar.elastic.dev/component_measures?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&metric=new_coverage&view=list) [51.5% Coverage on New Code](https://sonar.elastic.dev/component_measures?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&metric=new_coverage&view=list)  
[![](https://raw.githubusercontent.com/SonarSource/sonarqube-static-resources/master/v104/common/passed-16px.png '')](https://sonar.elastic.dev/component_measures?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&metric=new_duplicated_lines_density&view=list) [0.0% Duplication on New Code](https://sonar.elastic.dev/component_measures?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube](https://sonar.elastic.dev/dashboard?id=elastic_elastic-agent_AYluowg0xMq8P7b4moiZ&pullRequest=7360)


author_association: NONE
user: login: elasticmachine
created_at: 2025-03-20T19:19:22Z
body: ## :green_heart: Build Succeeded
* [Buildkite Build](https://buildkite.com/elastic/elastic-agent/builds/19014)
* Commit: a8bac7359a51c772234d4cbaae6cce63753681a1

### History
* :broken_heart: [Build #18983](https://buildkite.com/elastic/elastic-agent/builds/18983) failed 287da670eede5ae006ca4bb50e54082eb7fb8662
* :broken_heart: [Build #18965](https://buildkite.com/elastic/elastic-agent/builds/18965) failed b15701b3683fdcba07fe774375ed2079ad9c4562
* :broken_heart: [Build #18931](https://buildkite.com/elastic/elastic-agent/builds/18931) failed c1beeb955781695efe59ab8b8c59e1c8745982d8
* :green_heart: [Build #18714](https://buildkite.com/elastic/elastic-agent/builds/18714) succeeded 83a2014771b06111d6cbd49de00a7a055be1fc0f
* :broken_heart: [Build #18657](https://buildkite.com/elastic/elastic-agent/builds/18657) failed 18493241fae0c86063a44042e6595bde137c2208
* :broken_heart: [Build #18647](https://buildkite.com/elastic/elastic-agent/builds/18647) failed e2440300fe77b233b190f183c3ab6561adc74af0

cc @michel-laterman

<!--buildkite-pr-comment-elastic-agent
{"builds":[{"buildStatus":{"state":"passed","success":true,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/elastic-agent/builds/19014","number":19014,"commit":"a8bac7359a51c772234d4cbaae6cce63753681a1"},{"buildStatus":{"state":"failed","success":false,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/elastic-agent/builds/18983","number":18983,"commit":"287da670eede5ae006ca4bb50e54082eb7fb8662"},{"buildStatus":{"state":"failed","success":false,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/elastic-agent/builds/18965","number":18965,"commit":"b15701b3683fdcba07fe774375ed2079ad9c4562"},{"buildStatus":{"state":"failed","success":false,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/elastic-agent/builds/18931","number":18931,"commit":"c1beeb955781695efe59ab8b8c59e1c8745982d8"},{"buildStatus":{"state":"passed","success":true,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/elastic-agent/builds/18714","number":18714,"commit":"83a2014771b06111d6cbd49de00a7a055be1fc0f"},{"buildStatus":{"state":"failed","success":false,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/elastic-agent/builds/18657","number":18657,"commit":"18493241fae0c86063a44042e6595bde137c2208"}],"number":19014}
buildkite-pr-comment-->
author_association: CONTRIBUTOR