Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ghcr.io/libops/go:1.26.5@sha256:ea764e85e42a243217c621891123b3fda9374674c29d59785414fc6b15815b3d AS builder
FROM ghcr.io/libops/go:1.26.6@sha256:132e5632829827a10da523e64a2adaed4c47362e60ac26f081181d7c7a279bb8 AS builder

SHELL ["/bin/ash", "-o", "pipefail", "-ex", "-c"]

Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ loop because forced termination or task timeout can still interrupt secure
bootstrap.

For auto-unseal, the default is five recovery shares with a threshold of three.
Assign them to independent named custodians and test a quorum-based generate-root
procedure. A recovery bucket plus a KMS key controlled by one identity is not
independent custody.
By default Vault returns those shares to the initializer, which removes the
initial root token, encrypts the recovery bundle with Google KMS, and stores
only ciphertext in GCS. Operators that require independent human custody may
add one PGP public key per recovery share.

## Usage

Expand Down Expand Up @@ -137,11 +138,12 @@ The `vault-init` service supports the following environment variables for config
- `VAULT_RECOVERY_THRESHOLD` (3) - Number of recovery shares needed to authorize recovery operations.
Only applies to Vault 1.0 native auto-unseal.

- `VAULT_RECOVERY_PGP_KEYS` - Required with auto-unseal. A JSON array whose
- `VAULT_RECOVERY_PGP_KEYS` - Optional JSON array whose
length equals `VAULT_RECOVERY_SHARES`; each entry is a distinct
base64-encoded binary PGP public key owned by an independent custodian. Vault
encrypts each returned recovery share to the corresponding key. Private PGP
keys must never be provided to this service.
encrypts each returned recovery share to the corresponding key. When omitted,
the initializer protects the returned recovery bundle with Google KMS before
storing it in GCS. Private PGP keys must never be provided to this service.

- `VAULT_SKIP_VERIFY` (false) - Disable TLS validation when connecting. Setting
to true is highly discouraged. TLS 1.2 or newer is required by default.
Expand Down
2 changes: 1 addition & 1 deletion ci/publication_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func TestReleasePublishesTheTaggedImage(t *testing.T) {
func TestDockerfileBuildsNativeNonRootScratchImages(t *testing.T) {
dockerfile := readFile(t, "Dockerfile")
for _, required := range []string{
"FROM ghcr.io/libops/go:1.26.5@sha256:",
"FROM ghcr.io/libops/go:1.26.6@sha256:",
"FROM scratch",
"USER 65532:65532",
"ENTRYPOINT [\"/bin/vault-init\"]",
Expand Down
12 changes: 7 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,11 @@ func main() {
vaultStoredShares = intFromEnv("VAULT_STORED_SHARES", 1)
vaultRecoveryShares = intFromEnv("VAULT_RECOVERY_SHARES", 5)
vaultRecoveryThreshold = intFromEnv("VAULT_RECOVERY_THRESHOLD", 3)
vaultRecoveryPGPKeys, err = recoveryPGPKeysFromEnv("VAULT_RECOVERY_PGP_KEYS", vaultRecoveryShares, vaultRecoveryThreshold)
if err != nil {
log.Fatal(err)
if strings.TrimSpace(os.Getenv("VAULT_RECOVERY_PGP_KEYS")) != "" {
vaultRecoveryPGPKeys, err = recoveryPGPKeysFromEnv("VAULT_RECOVERY_PGP_KEYS", vaultRecoveryShares, vaultRecoveryThreshold)
if err != nil {
log.Fatal(err)
}
}
}

Expand Down Expand Up @@ -970,8 +972,8 @@ func verifyBootstrapCompletion(ctx context.Context, read encryptedSecretReader)
if record.SchemaVersion != 1 || record.AuditPath != "cloudrun" || !record.RootTokenRevoked ||
record.RecoveryShares < 3 || record.RecoveryThreshold < 2 ||
record.RecoveryThreshold > record.RecoveryShares ||
len(record.CustodianKeySHA256) != record.RecoveryShares {
return fmt.Errorf("secure bootstrap completion does not prove the required audit device, root-token revocation, and independent recovery quorum")
(len(record.CustodianKeySHA256) != 0 && len(record.CustodianKeySHA256) != record.RecoveryShares) {
return fmt.Errorf("secure bootstrap completion does not prove the required audit device, root-token revocation, and recovery threshold")
}
seen := make(map[string]struct{}, len(record.CustodianKeySHA256))
for _, fingerprint := range record.CustodianKeySHA256 {
Expand Down
32 changes: 32 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,35 @@ func TestPersistBootstrapCompletionRecordsAuditAndRevocation(t *testing.T) {
}
}

func TestPersistBootstrapCompletionWithoutCustodianPGPKeys(t *testing.T) {
originalShares := vaultRecoveryShares
originalThreshold := vaultRecoveryThreshold
originalKeys := vaultRecoveryPGPKeys
t.Cleanup(func() {
vaultRecoveryShares = originalShares
vaultRecoveryThreshold = originalThreshold
vaultRecoveryPGPKeys = originalKeys
})
vaultRecoveryShares = 5
vaultRecoveryThreshold = 3
vaultRecoveryPGPKeys = nil

var record []byte
if err := persistBootstrapCompletion(func(_ context.Context, _ string, data []byte) error {
record = bytes.Clone(data)
return nil
}, func(time.Duration) { t.Fatal("unexpected retry") }); err != nil {
t.Fatal(err)
}
var completion bootstrapCompletion
if err := json.Unmarshal(record, &completion); err != nil {
t.Fatal(err)
}
if completion.RecoveryShares != 5 || completion.RecoveryThreshold != 3 || len(completion.CustodianKeySHA256) != 0 {
t.Fatalf("completion = %#v", completion)
}
}

func TestProcessTLSConfigReadsCertificatesFromCAPath(t *testing.T) {
server := httptest.NewTLSServer(nil)
server.Close()
Expand Down Expand Up @@ -558,12 +587,15 @@ func TestVerifyBootstrapCompletion(t *testing.T) {
invalidFingerprintRecord := validRecord
invalidFingerprintRecord.CustodianKeySHA256 = append([]string(nil), validRecord.CustodianKeySHA256...)
invalidFingerprintRecord.CustodianKeySHA256[4] = "not-a-sha256"
kmsProtectedRecord := validRecord
kmsProtectedRecord.CustodianKeySHA256 = nil
for _, test := range []struct {
name string
record string
wantError bool
}{
{name: "valid", record: valid},
{name: "valid KMS protected bundle", record: string(mustJSON(t, kmsProtectedRecord))},
{name: "audit missing", record: `{"schema_version":1,"root_token_revoked":true}`, wantError: true},
{name: "root live", record: `{"schema_version":1,"audit_path":"cloudrun"}`, wantError: true},
{name: "duplicate custodian", record: string(mustJSON(t, duplicateRecord)), wantError: true},
Expand Down
Loading