From f82651e184c99192104164375b1d0e67201d1e0e Mon Sep 17 00:00:00 2001 From: libops-agent <115990865+libops-agent@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:26:35 +0000 Subject: [PATCH] [minor] Restore automatic KMS recovery custody --- Dockerfile | 2 +- README.md | 14 ++++++++------ ci/publication_contract_test.go | 2 +- main.go | 12 +++++++----- main_test.go | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 63ca43d..827f50e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 2822c20..25822c6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/ci/publication_contract_test.go b/ci/publication_contract_test.go index d156f3f..4776c4e 100644 --- a/ci/publication_contract_test.go +++ b/ci/publication_contract_test.go @@ -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\"]", diff --git a/main.go b/main.go index e5e1821..e42ca13 100644 --- a/main.go +++ b/main.go @@ -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) + } } } @@ -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 { diff --git a/main_test.go b/main_test.go index 3a7c176..2434ed1 100644 --- a/main_test.go +++ b/main_test.go @@ -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() @@ -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},