From 1db89bfec2d5b5b2f377d224f7e50c81471f21e7 Mon Sep 17 00:00:00 2001 From: libops-agent <115990865+libops-agent@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:01:21 +0000 Subject: [PATCH 1/2] [minor] Remove retained Vault root credentials --- README.md | 71 ++++++--- main.go | 442 ++++++++++++++++++++++++++++++++++++++------------- main_test.go | 355 +++++++++++++++++++++++++++++------------ 3 files changed, 623 insertions(+), 245 deletions(-) diff --git a/README.md b/README.md index 3fbf94f..2822c20 100644 --- a/README.md +++ b/README.md @@ -2,48 +2,56 @@ The `vault-init` service automates the process of [initializing](https://www.vaultproject.io/docs/commands/operator/init.html) and [unsealing](https://www.vaultproject.io/docs/concepts/seal.html#unsealing) HashiCorp Vault instances running on [Google Cloud Platform](https://cloud.google.com). -After `vault-init` initializes a Vault server it stores master keys and root tokens, encrypted using [Google Cloud KMS](https://cloud.google.com/kms), to a user defined [Google Cloud Storage](https://cloud.google.com/storage) bucket. +After `vault-init` initializes a Vault server it stores only root-token-free +recovery material, encrypted using [Google Cloud KMS](https://cloud.google.com/kms), +in a user-defined [Google Cloud Storage](https://cloud.google.com/storage) +bucket. It enables a non-raw JSON audit device on Vault stdout, revokes and +verifies the initial root token, and then writes a non-secret bootstrap +completion record. The initial root token is never written to GCS. Vault returns its initial recovery material only once. Before sending the initialization request, this service verifies that neither destination object already exists, that KMS can encrypt and decrypt a round-trip probe, and that the workload can commit and read back a unique non-secret, create-only GCS marker. The retained `vault-init-preflight/` marker is intentional because the -runtime has no object-delete permission. A graceful shutdown received before +initializer has no object-delete permission. A graceful shutdown received before the initialization request is sent aborts safely and leaves a one-shot job retryable. Once the request is committed, the service ignores graceful shutdown, retains a successful response in memory, and retries KMS and GCS with bounded exponential backoff. GCS writes use a create-only precondition. A run is complete only after each write is confirmed committed: either the writer `Close` succeeds or a byte-identical create-only object is read back after an -ambiguous close result. The -complete encrypted initialization response is stored before the convenience -root-token object, so a partial two-object write still preserves all recovery -material. If the successful response is malformed or fails partway through a -read, the bytes received are encrypted and stored before the process reports -failure, and the convenience object is not created. Operators must avoid +ambiguous close result. The validated recovery response is stripped of its +root token before encryption. An unreadable or malformed response is not +persisted because it may contain a live root credential. Operators must avoid forcibly terminating the initializer between Vault accepting `/v1/sys/init` and the "Initialization complete" log entry. -Every run that finds Vault already initialized verifies that both encrypted -objects exist and are non-empty before exiting successfully. This makes a -Cloud Run retry idempotent when the first task stored the response but lost its -final client response. If only `root-token.enc` is missing, the retry decrypts -the complete response and recreates that convenience object with the same -create-only protection. If `unseal-keys.json.enc` is missing, the one-time Vault -response cannot be reconstructed: the process exits nonzero and repeated job -attempts will continue to fail rather than silently claiming success. The +Every run that finds Vault already initialized verifies the encrypted recovery +bundle, proves that it contains no root token, validates the bootstrap marker, +and fails if the legacy `root-token.enc` object exists. A crash after the +recovery bundle becomes durable but before audit/root revocation completes +requires a recovery-key-quorum operator repair; an automated retry cannot and +must not recover a durable root token. If `unseal-keys.json.enc` or +`bootstrap-complete.json` is missing, the process exits nonzero rather than +silently claiming success. The default Cloud Run Job policy [retries a failed task three times](https://cloud.google.com/run/docs/configuring/max-retries). Those retries can bridge transient GCS errors, but they cannot repair a missing recovery bundle. Configure a task timeout long enough for the post-initialization retry -loop because forced termination or task timeout can still destroy the only -in-memory copy. +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. ## Usage -The `vault-init` service is designed to be run alongside a Vault server and -communicate over local host. +The `vault-init` service can run beside Vault for development or as a one-shot +job through the authenticated Vault Proxy URL. Production uses the latter so +the initializer, public proxy, and Vault runtime keep separate identities. You can download the code and compile the binary with Go. Release images are published as one signed multi-platform manifest to both GHCR and the public @@ -107,11 +115,11 @@ The `vault-init` service supports the following environment variables for config the privileged Google access token and initialization redirects would also make commit state ambiguous. -- `GCS_BUCKET_NAME` - The Google Cloud Storage Bucket where the Vault master key - and root token is stored. +- `GCS_BUCKET_NAME` - The Google Cloud Storage bucket where root-token-free + recovery material and the non-secret completion record are stored. - `KMS_KEY_ID` - The Google Cloud KMS key ID used to encrypt and decrypt the - vault master key and root token. + Vault recovery bundle. - `VAULT_SECRET_SHARES` (5) - The number of human shares to create. @@ -123,12 +131,18 @@ The `vault-init` service supports the following environment variables for config - `VAULT_STORED_SHARES` (1) - Number of shares to store on KMS. Only applies to Vault 1.0 native auto-unseal. -- `VAULT_RECOVERY_SHARES` (1) - Number of recovery shares to generate. Only +- `VAULT_RECOVERY_SHARES` (5) - Number of recovery shares to generate. Only applies to Vault 1.0 native auto-unseal. -- `VAULT_RECOVERY_THRESHOLD` (1) - Number of recovery shares needed to trigger an auto-unseal. +- `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 + 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. + - `VAULT_SKIP_VERIFY` (false) - Disable TLS validation when connecting. Setting to true is highly discouraged. TLS 1.2 or newer is required by default. @@ -178,6 +192,13 @@ overwrite access. Use a dedicated bucket for each Vault deployment and protect it with retention, versioning, restricted administration, and an independently tested recovery procedure. +Existing deployments that contain `root-token.enc` or a root token inside +`unseal-keys.json.enc` must be migrated under dual control: decrypt in a +restricted recovery session, revoke the retained token, write a root-token-free +bundle, remove the legacy object, enable and verify the `cloudrun/` stdout audit +device, and write the completion marker. Do not copy decrypted material through +Terraform, CI, tickets, chat, or shell arguments. + For more information on service accounts, please see the [Google Cloud Service Accounts documentation][service-accounts]. diff --git a/main.go b/main.go index 604b51b..3ca857f 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" "crypto/tls" "crypto/x509" "encoding/base64" @@ -43,6 +44,7 @@ var ( vaultStoredShares int vaultRecoveryShares int vaultRecoveryThreshold int + vaultRecoveryPGPKeys []string kmsService *cloudkms.Service kmsKeyId string @@ -57,14 +59,15 @@ var ( ) const ( - unsealKeysObjectName = "unseal-keys.json.enc" - rootTokenObjectName = "root-token.enc" // #nosec G101 -- fixed GCS object name, not a credential value - kmsPreflightPlaintext = "vault-init initialization preflight" - metadataTokenURL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" // #nosec G101 -- metadata endpoint, not a credential value - vaultProxyEmailScope = "https://www.googleapis.com/auth/userinfo.email" - secretWriteTimeout = 2 * time.Minute - maxSecretRetryDelay = time.Minute - maxEncryptedBundle = 256 << 10 + unsealKeysObjectName = "unseal-keys.json.enc" + rootTokenObjectName = "root-token.enc" // #nosec G101 -- legacy fixed object name, not a credential value + bootstrapCompleteObjectName = "bootstrap-complete.json" + kmsPreflightPlaintext = "vault-init initialization preflight" + metadataTokenURL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" // #nosec G101 -- metadata endpoint, not a credential value + vaultProxyEmailScope = "https://www.googleapis.com/auth/userinfo.email" + secretWriteTimeout = 2 * time.Minute + maxSecretRetryDelay = time.Minute + maxEncryptedBundle = 256 << 10 ) type secretEncrypter func(context.Context, []byte) ([]byte, error) @@ -77,11 +80,12 @@ type storageRoundTripTester func(context.Context) error // InitRequest holds a Vault init request. type InitRequest struct { - SecretShares int `json:"secret_shares"` - SecretThreshold int `json:"secret_threshold"` - StoredShares int `json:"stored_shares"` - RecoveryShares int `json:"recovery_shares"` - RecoveryThreshold int `json:"recovery_threshold"` + SecretShares int `json:"secret_shares"` + SecretThreshold int `json:"secret_threshold"` + StoredShares int `json:"stored_shares"` + RecoveryShares int `json:"recovery_shares"` + RecoveryThreshold int `json:"recovery_threshold"` + RecoveryPGPKeys []string `json:"recovery_pgp_keys,omitempty"` } // InitResponse holds a Vault init response. @@ -93,6 +97,20 @@ type InitResponse struct { RootToken string `json:"root_token"` } +type bootstrapCompletion struct { + SchemaVersion int `json:"schema_version"` + AuditPath string `json:"audit_path"` + RootTokenRevoked bool `json:"root_token_revoked"` + RecoveryShares int `json:"recovery_shares"` + RecoveryThreshold int `json:"recovery_threshold"` + CustodianKeySHA256 []string `json:"custodian_key_sha256"` +} + +type auditDevice struct { + Type string `json:"type"` + Options map[string]string `json:"options"` +} + // UnsealRequest holds a Vault unseal request. type UnsealRequest struct { Key string `json:"key"` @@ -134,8 +152,12 @@ func main() { if vaultAutoUnseal { vaultStoredShares = intFromEnv("VAULT_STORED_SHARES", 1) - vaultRecoveryShares = intFromEnv("VAULT_RECOVERY_SHARES", 1) - vaultRecoveryThreshold = intFromEnv("VAULT_RECOVERY_THRESHOLD", 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) + } } vaultCaCert := stringFromEnv("VAULT_CACERT", "") @@ -363,6 +385,7 @@ func initialize(shutdown <-chan os.Signal) error { StoredShares: vaultStoredShares, RecoveryShares: vaultRecoveryShares, RecoveryThreshold: vaultRecoveryThreshold, + RecoveryPGPKeys: vaultRecoveryPGPKeys, } // allow optional secret shares/threshold to support GCP KMS on newer version of Vault @@ -416,51 +439,46 @@ func initialize(shutdown <-chan os.Signal) error { initRequestResponseBody, err := io.ReadAll(response.Body) if err != nil { - if len(initRequestResponseBody) > 0 { - persistenceErr := retryInitializationPersistence( - initRequestResponseBody, - "", - encryptSecretWithKMS, - storeEncryptedSecretOnce, - time.Sleep, - ) - return fmt.Errorf("read initialization response: %w (partial encrypted response preserved: %v)", err, persistenceErr) - } - return fmt.Errorf("read initialization response: %w", err) + return fmt.Errorf("read initialization response: %w; refusing to persist an unvalidated response that may contain a live root token", err) } var initResponse InitResponse if err := json.Unmarshal(initRequestResponseBody, &initResponse); err != nil { - // Preserve every byte returned by the one-time endpoint even when its - // shape is unexpected. Passing an empty root token makes the persistence - // routine stop after the complete encrypted response is durable. - persistenceErr := retryInitializationPersistence( - initRequestResponseBody, - "", - encryptSecretWithKMS, - storeEncryptedSecretOnce, - time.Sleep, - ) - return fmt.Errorf("decode initialization response: %w (%v)", err, persistenceErr) + return fmt.Errorf("decode initialization response: %w; refusing to persist an unvalidated response that may contain a live root token", err) + } + if initResponse.RootToken == "" { + return fmt.Errorf("initialization response did not contain a root token") + } + if len(initResponse.RecoveryKeys) == 0 && len(initResponse.RecoveryKeysBase64) == 0 && + len(initResponse.Keys) == 0 && len(initResponse.KeysBase64) == 0 { + return fmt.Errorf("initialization response did not contain recovery or unseal keys") } // Vault returns its recovery material exactly once. Once the server accepts - // the init request, do not honor shutdown or return to the health loop until - // that material is durably encrypted and stored. Retrying in this process is - // the only safe response to a transient KMS or GCS failure. - log.Println("Encrypting and durably storing unseal keys and the root token...") - if err := retryInitializationPersistence( - initRequestResponseBody, - initResponse.RootToken, + // the init request, first persist a root-token-free recovery bundle. Enable + // the audit device next so revocation itself is audited, revoke and verify the + // initial root token, and only then publish the non-secret completion marker. + // A crash after the recovery bundle is durable but before completion fails + // closed for a quorum-based operator repair; it never leaves a root token in + // GCS for an automated retry. + log.Println("Encrypting and durably storing root-token-free recovery material...") + if err := retryRecoveryPersistence( + initResponse, encryptSecretWithKMS, storeEncryptedSecretOnce, time.Sleep, ); err != nil { return err } + if err := enableAuditAndRevokeInitialRoot(initResponse.RootToken); err != nil { + return fmt.Errorf("secure bootstrap incomplete after recovery material became durable: %w; use the recovery-key quorum to inspect audit state and generate a short-lived repair token", err) + } + if err := persistBootstrapCompletion(storeEncryptedSecretOnce, time.Sleep); err != nil { + return err + } - log.Println("Initialization complete.") + log.Println("Initialization complete; audit is enabled and the initial root token is revoked.") return nil } @@ -551,21 +569,25 @@ func verifyStorageRoundTrip( return nil } -func retryInitializationPersistence( - initResponse []byte, - rootToken string, +func retryRecoveryPersistence( + initResponse InitResponse, encrypt secretEncrypter, store encryptedSecretStore, wait func(time.Duration), ) error { retryDelay := time.Second - // Protect and store the complete response before doing any work on the - // redundant root-token object. This minimizes the interval in which a forced - // task termination could destroy Vault's one-time recovery response. + // Never serialize the initial root token into the recovery object. Recovery + // keys are the durable break-glass mechanism and can generate a short-lived + // root token with the configured quorum when an operator explicitly needs it. + initResponse.RootToken = "" + recoveryResponse, err := json.Marshal(initResponse) + if err != nil { + return fmt.Errorf("marshal root-token-free recovery response: %w", err) + } protectedResponse := retrySecretEncryption( - initResponse, - "initialization response", + recoveryResponse, + "recovery response", encrypt, wait, &retryDelay, @@ -573,37 +595,171 @@ func retryInitializationPersistence( retrySecretStore( unsealKeysObjectName, protectedResponse, - "initialization response", + "recovery response", store, wait, &retryDelay, ) - log.Printf("Unseal keys written to gs://%s/%s", gcsBucketName, unsealKeysObjectName) + log.Printf("Root-token-free recovery material written to gs://%s/%s", gcsBucketName, unsealKeysObjectName) + return nil +} - if rootToken == "" { - return fmt.Errorf("initialization response did not contain a root token; the complete encrypted response is durable at gs://%s/%s", gcsBucketName, unsealKeysObjectName) +func persistBootstrapCompletion(store encryptedSecretStore, wait func(time.Duration)) error { + custodianDigests, err := recoveryPGPKeyDigests(vaultRecoveryPGPKeys) + if err != nil { + return fmt.Errorf("derive recovery custodian fingerprints: %w", err) + } + record, err := json.Marshal(bootstrapCompletion{ + SchemaVersion: 1, + AuditPath: "cloudrun", + RootTokenRevoked: true, + RecoveryShares: vaultRecoveryShares, + RecoveryThreshold: vaultRecoveryThreshold, + CustodianKeySHA256: custodianDigests, + }) + if err != nil { + return fmt.Errorf("marshal bootstrap completion: %w", err) } - - protectedRootToken := retrySecretEncryption( - []byte(rootToken), - "root token", - encrypt, - wait, - &retryDelay, - ) + retryDelay := time.Second retrySecretStore( - rootTokenObjectName, - protectedRootToken, - "root token", + bootstrapCompleteObjectName, + record, + "non-secret bootstrap completion", store, wait, &retryDelay, ) + log.Printf("Secure bootstrap completion written to gs://%s/%s", gcsBucketName, bootstrapCompleteObjectName) + return nil +} + +func enableAuditAndRevokeInitialRoot(rootToken string) error { + if rootToken == "" { + return fmt.Errorf("initial root token is empty") + } + if err := ensureStdoutAuditDevice(rootToken); err != nil { + return err + } + if err := revokeInitialRootToken(rootToken); err != nil { + return err + } + return verifyInitialRootTokenRevoked(rootToken) +} + +func ensureStdoutAuditDevice(rootToken string) error { + devices, err := readAuditDevices(rootToken) + if err != nil { + return fmt.Errorf("read audit devices: %w", err) + } + if device, ok := devices["cloudrun/"]; ok { + return validateStdoutAuditDevice(device) + } + + payload, err := json.Marshal(map[string]any{ + "type": "file", + "description": "Cloud Run stdout audit stream", + "options": map[string]string{ + "file_path": "stdout", + "format": "json", + "hmac_accessor": "true", + "log_raw": "false", + "elide_list_responses": "true", + }, + }) + if err != nil { + return fmt.Errorf("marshal audit configuration: %w", err) + } + status, _, err := doRootVaultRequest(http.MethodPost, "/v1/sys/audit/cloudrun", payload, rootToken) + if err != nil { + return fmt.Errorf("enable stdout audit device: %w", err) + } + if status != http.StatusNoContent { + return fmt.Errorf("enable stdout audit device: unexpected status %d", status) + } + devices, err = readAuditDevices(rootToken) + if err != nil { + return fmt.Errorf("verify stdout audit device: %w", err) + } + device, ok := devices["cloudrun/"] + if !ok { + return fmt.Errorf("verify stdout audit device: cloudrun/ is absent") + } + return validateStdoutAuditDevice(device) +} + +func readAuditDevices(rootToken string) (map[string]auditDevice, error) { + status, body, err := doRootVaultRequest(http.MethodGet, "/v1/sys/audit", nil, rootToken) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d", status) + } + var devices map[string]auditDevice + if err := json.Unmarshal(body, &devices); err != nil { + return nil, fmt.Errorf("decode audit devices: %w", err) + } + return devices, nil +} + +func validateStdoutAuditDevice(device auditDevice) error { + if device.Type != "file" || device.Options["file_path"] != "stdout" || + device.Options["log_raw"] == "true" { + return fmt.Errorf("cloudrun/ audit device must be file output to stdout with raw secret logging disabled") + } + return nil +} + +func revokeInitialRootToken(rootToken string) error { + status, _, err := doRootVaultRequest(http.MethodPost, "/v1/auth/token/revoke-self", nil, rootToken) + if err != nil { + return fmt.Errorf("revoke initial root token: %w", err) + } + if status != http.StatusNoContent { + return fmt.Errorf("revoke initial root token: unexpected status %d", status) + } + return nil +} - log.Printf("Root token written to gs://%s/%s", gcsBucketName, rootTokenObjectName) +func verifyInitialRootTokenRevoked(rootToken string) error { + status, _, err := doRootVaultRequest(http.MethodGet, "/v1/auth/token/lookup-self", nil, rootToken) + if err != nil { + return fmt.Errorf("verify initial root token revocation: %w", err) + } + if status != http.StatusForbidden { + return fmt.Errorf("verify initial root token revocation: got status %d, want 403", status) + } return nil } +func doRootVaultRequest(method, requestPath string, payload []byte, rootToken string) (int, []byte, error) { + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + request, err := newVaultRequest(method, vaultAddr+requestPath, body) + if err != nil { + return 0, nil, err + } + request.Header.Set("X-Vault-Token", rootToken) + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := httpClient.Do(request) + if err != nil { + return 0, nil, err + } + defer response.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(response.Body, maxEncryptedBundle+1)) + if err != nil { + return 0, nil, err + } + if len(responseBody) > maxEncryptedBundle { + return 0, nil, fmt.Errorf("Vault response exceeds %d bytes", maxEncryptedBundle) + } + return response.StatusCode, responseBody, nil +} + func retrySecretEncryption( plaintext []byte, description string, @@ -754,7 +910,7 @@ func statEncryptedSecret(ctx context.Context, name string) (int64, error) { } func ensureInitializationTargetsEmpty(ctx context.Context, stat encryptedSecretStat) error { - for _, name := range []string{unsealKeysObjectName, rootTokenObjectName} { + for _, name := range []string{unsealKeysObjectName, bootstrapCompleteObjectName, rootTokenObjectName} { _, err := stat(ctx, name) if errors.Is(err, storage.ErrObjectNotExist) { continue @@ -770,61 +926,63 @@ func ensureInitializationTargetsEmpty(ctx context.Context, stat encryptedSecretS func verifyDurableInitialization() error { ctx, cancel := context.WithTimeout(context.Background(), secretWriteTimeout) defer cancel() - - initialErr := verifyStoredRecoveryMaterial(ctx, statEncryptedSecret) - if initialErr == nil { - return nil - } - - bundleSize, bundleErr := statEncryptedSecret(ctx, unsealKeysObjectName) - _, rootErr := statEncryptedSecret(ctx, rootTokenObjectName) - if bundleErr != nil || bundleSize <= 0 || !errors.Is(rootErr, storage.ErrObjectNotExist) { - return initialErr + if err := verifyStoredRecoveryMaterial(ctx, statEncryptedSecret); err != nil { + return err } - - if err := restoreRootTokenObject( - ctx, - readEncryptedSecret, - decryptSecretWithKMS, - encryptSecretWithKMS, - storeEncryptedSecretOnce, - ); err != nil { - return fmt.Errorf("%w; restore missing root-token object from durable response: %v", initialErr, err) + if err := verifyRecoveryBundleContents(ctx, readEncryptedSecret, decryptSecretWithKMS); err != nil { + return err } - log.Printf("Restored root token to gs://%s/%s from the durable initialization response", gcsBucketName, rootTokenObjectName) - return verifyStoredRecoveryMaterial(ctx, statEncryptedSecret) + return verifyBootstrapCompletion(ctx, readEncryptedSecret) } -func restoreRootTokenObject( - ctx context.Context, - read encryptedSecretReader, - decrypt secretDecrypter, - encrypt secretEncrypter, - store encryptedSecretStore, -) error { +func verifyRecoveryBundleContents(ctx context.Context, read encryptedSecretReader, decrypt secretDecrypter) error { protectedResponse, err := read(ctx, unsealKeysObjectName) if err != nil { - return fmt.Errorf("read encrypted initialization response: %w", err) + return fmt.Errorf("read encrypted recovery response: %w", err) } - initResponseJSON, err := decrypt(ctx, protectedResponse) + recoveryJSON, err := decrypt(ctx, protectedResponse) if err != nil { - return fmt.Errorf("decrypt initialization response: %w", err) + return fmt.Errorf("decrypt recovery response: %w", err) } - - var initResponse InitResponse - if err := json.Unmarshal(initResponseJSON, &initResponse); err != nil { - return fmt.Errorf("decode initialization response: %w", err) + var recovery InitResponse + if err := json.Unmarshal(recoveryJSON, &recovery); err != nil { + return fmt.Errorf("decode recovery response: %w", err) } - if initResponse.RootToken == "" { - return fmt.Errorf("initialization response did not contain a root token") + if recovery.RootToken != "" { + return fmt.Errorf("encrypted recovery response retains an initial root token; migrate it to the root-token-free schema under dual control") + } + if len(recovery.RecoveryKeys) == 0 && len(recovery.RecoveryKeysBase64) == 0 && + len(recovery.Keys) == 0 && len(recovery.KeysBase64) == 0 { + return fmt.Errorf("encrypted recovery response contains no recovery or unseal keys") } + return nil +} - protectedRootToken, err := encrypt(ctx, []byte(initResponse.RootToken)) +func verifyBootstrapCompletion(ctx context.Context, read encryptedSecretReader) error { + recordJSON, err := read(ctx, bootstrapCompleteObjectName) if err != nil { - return fmt.Errorf("encrypt root token: %w", err) - } - if err := store(ctx, rootTokenObjectName, protectedRootToken); err != nil { - return fmt.Errorf("store encrypted root token: %w", err) + return fmt.Errorf("read secure bootstrap completion: %w", err) + } + var record bootstrapCompletion + if err := json.Unmarshal(recordJSON, &record); err != nil { + return fmt.Errorf("decode secure bootstrap completion: %w", err) + } + 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") + } + seen := make(map[string]struct{}, len(record.CustodianKeySHA256)) + for _, fingerprint := range record.CustodianKeySHA256 { + decoded, decodeErr := hex.DecodeString(fingerprint) + if decodeErr != nil || len(decoded) != sha256.Size || fingerprint != strings.ToLower(fingerprint) { + return fmt.Errorf("secure bootstrap completion contains an invalid custodian key fingerprint") + } + if _, duplicate := seen[fingerprint]; duplicate { + return fmt.Errorf("secure bootstrap completion does not prove independent recovery custodians") + } + seen[fingerprint] = struct{}{} } return nil } @@ -852,7 +1010,7 @@ func readEncryptedSecret(ctx context.Context, name string) ([]byte, error) { } func verifyStoredRecoveryMaterial(ctx context.Context, stat encryptedSecretStat) error { - for _, name := range []string{unsealKeysObjectName, rootTokenObjectName} { + for _, name := range []string{unsealKeysObjectName, bootstrapCompleteObjectName} { size, err := stat(ctx, name) if errors.Is(err, storage.ErrObjectNotExist) { return fmt.Errorf("gs://%s/%s is missing", gcsBucketName, name) @@ -864,6 +1022,11 @@ func verifyStoredRecoveryMaterial(ctx context.Context, stat encryptedSecretStat) return fmt.Errorf("gs://%s/%s is empty", gcsBucketName, name) } } + if size, err := stat(ctx, rootTokenObjectName); err == nil { + return fmt.Errorf("legacy gs://%s/%s still exists (%d bytes); revoke the retained token, replace the recovery bundle with a root-token-free copy, and remove the legacy object under dual control", gcsBucketName, rootTokenObjectName, size) + } else if !errors.Is(err, storage.ErrObjectNotExist) { + return fmt.Errorf("inspect legacy gs://%s/%s: %w", gcsBucketName, rootTokenObjectName, err) + } return nil } @@ -1000,7 +1163,7 @@ func newVaultRequest(method, url string, body io.Reader) (*http.Request, error) } request.Header.Set("Accept", "application/json") - if method == http.MethodPut { + if body != nil && (method == http.MethodPut || method == http.MethodPost) { request.Header.Set("Content-Type", "application/json") } @@ -1112,6 +1275,57 @@ func newMetadataHTTPClient() *http.Client { } } +func recoveryPGPKeysFromEnv(env string, shares, threshold int) ([]string, error) { + if shares < 3 || threshold < 2 || threshold > shares { + return nil, fmt.Errorf("recovery custody requires at least three shares, a threshold of at least two, and threshold no greater than shares") + } + raw := os.Getenv(env) + if raw == "" { + return nil, fmt.Errorf("%s must contain a JSON array of %d independent custodian PGP public keys", env, shares) + } + var keys []string + decoder := json.NewDecoder(strings.NewReader(raw)) + if err := decoder.Decode(&keys); err != nil { + return nil, fmt.Errorf("parse %s as a JSON string array: %w", env, err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s must contain one JSON value", env) + } + if len(keys) != shares { + return nil, fmt.Errorf("%s must contain exactly %d keys, got %d", env, shares, len(keys)) + } + seen := make(map[[sha256.Size]byte]struct{}, len(keys)) + for i, key := range keys { + decoded, err := base64.StdEncoding.Strict().DecodeString(key) + if err != nil { + return nil, fmt.Errorf("%s key %d is not strict base64: %w", env, i+1, err) + } + if len(decoded) < 32 || len(decoded) > 64<<10 { + return nil, fmt.Errorf("%s key %d has an invalid decoded size", env, i+1) + } + digest := sha256.Sum256(decoded) + if _, duplicate := seen[digest]; duplicate { + return nil, fmt.Errorf("%s contains a duplicate custodian key at position %d", env, i+1) + } + seen[digest] = struct{}{} + } + return keys, nil +} + +func recoveryPGPKeyDigests(keys []string) ([]string, error) { + digests := make([]string, 0, len(keys)) + for i, key := range keys { + decoded, err := base64.StdEncoding.Strict().DecodeString(key) + if err != nil { + return nil, fmt.Errorf("custodian key %d is not strict base64: %w", i+1, err) + } + digest := sha256.Sum256(decoded) + digests = append(digests, hex.EncodeToString(digest[:])) + } + return digests, nil +} + func boolFromEnv(env string, def bool) bool { val := os.Getenv(env) if val == "" { diff --git a/main_test.go b/main_test.go index c3fbb05..3a7c176 100644 --- a/main_test.go +++ b/main_test.go @@ -5,6 +5,8 @@ import ( "context" "crypto/tls" "crypto/x509" + "encoding/base64" + "encoding/json" "encoding/pem" "errors" "io" @@ -26,6 +28,15 @@ func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) return f(request) } +func testRecoveryPGPKeys() []string { + keys := make([]string, 5) + for i := range keys { + decoded := bytes.Repeat([]byte{byte(i + 1)}, 64) + keys[i] = base64.StdEncoding.EncodeToString(decoded) + } + return keys +} + type scriptedWriteCloser struct { written int writeErr error @@ -49,8 +60,8 @@ func (w *scriptedWriteCloser) Close() error { return w.closeErr } -func TestRetryInitializationPersistenceRetainsOneTimeResponse(t *testing.T) { - initResponse := []byte(`{"keys_base64":["key"],"root_token":"root"}`) +func TestRetryRecoveryPersistenceNeverStoresRootToken(t *testing.T) { + initResponse := InitResponse{KeysBase64: []string{"key"}, RootToken: "root"} encryptCalls := 0 encrypt := func(_ context.Context, plaintext []byte) ([]byte, error) { encryptCalls++ @@ -64,25 +75,17 @@ func TestRetryInitializationPersistenceRetainsOneTimeResponse(t *testing.T) { name string data []byte } - rootWriteAttempts := 0 store := func(_ context.Context, name string, ciphertext []byte) error { writes = append(writes, struct { name string data []byte }{name: name, data: bytes.Clone(ciphertext)}) - if name == rootTokenObjectName { - rootWriteAttempts++ - if rootWriteAttempts == 1 { - return errors.New("transient GCS failure") - } - } return nil } var waits []time.Duration - err := retryInitializationPersistence( + err := retryRecoveryPersistence( initResponse, - "root", encrypt, store, func(delay time.Duration) { waits = append(waits, delay) }, @@ -91,38 +94,36 @@ func TestRetryInitializationPersistenceRetainsOneTimeResponse(t *testing.T) { t.Fatal(err) } - if encryptCalls != 3 { - t.Fatalf("encrypt calls = %d, want 3", encryptCalls) + if encryptCalls != 2 { + t.Fatalf("encrypt calls = %d, want 2", encryptCalls) } - if !reflect.DeepEqual(waits, []time.Duration{time.Second, 2 * time.Second}) { + if !reflect.DeepEqual(waits, []time.Duration{time.Second}) { t.Fatalf("retry delays = %v", waits) } - wantWrites := []struct { - name string - data []byte - }{ - {name: unsealKeysObjectName, data: append([]byte("encrypted:"), initResponse...)}, - {name: rootTokenObjectName, data: []byte("encrypted:root")}, - {name: rootTokenObjectName, data: []byte("encrypted:root")}, + if len(writes) != 1 || writes[0].name != unsealKeysObjectName { + t.Fatalf("writes = %#v, want only recovery bundle", writes) + } + plaintext := bytes.TrimPrefix(writes[0].data, []byte("encrypted:")) + var stored InitResponse + if err := json.Unmarshal(plaintext, &stored); err != nil { + t.Fatal(err) } - if !reflect.DeepEqual(writes, wantWrites) { - t.Fatalf("writes = %#v, want %#v", writes, wantWrites) + if stored.RootToken != "" || !reflect.DeepEqual(stored.KeysBase64, []string{"key"}) { + t.Fatalf("stored recovery = %#v, want keys without root token", stored) } } -func TestRetryInitializationPersistenceMakesRecoveryBundleDurableBeforeRootTokenWork(t *testing.T) { - initResponse := []byte(`{"keys_base64":["key"],"root_token":"root"}`) +func TestRetryRecoveryPersistenceHasNoRootTokenWork(t *testing.T) { + initResponse := InitResponse{KeysBase64: []string{"key"}, RootToken: "root"} var events []string - err := retryInitializationPersistence( + err := retryRecoveryPersistence( initResponse, - "root", func(_ context.Context, plaintext []byte) ([]byte, error) { - if bytes.Equal(plaintext, initResponse) { - events = append(events, "encrypt bundle") - return []byte("bundle"), nil + if bytes.Contains(plaintext, []byte(`"root_token":"root"`)) { + t.Fatal("root token reached encryption") } - events = append(events, "encrypt root") - return []byte("token"), nil + events = append(events, "encrypt bundle") + return []byte("bundle"), nil }, func(_ context.Context, name string, _ []byte) error { events = append(events, "store "+name) @@ -136,34 +137,44 @@ func TestRetryInitializationPersistenceMakesRecoveryBundleDurableBeforeRootToken want := []string{ "encrypt bundle", "store " + unsealKeysObjectName, - "encrypt root", - "store " + rootTokenObjectName, } if !reflect.DeepEqual(events, want) { t.Fatalf("events = %v, want %v", events, want) } } -func TestRetryInitializationPersistencePreservesUnexpectedResponse(t *testing.T) { - response := []byte(`{"unexpected":"response"}`) - var writes []string - err := retryInitializationPersistence( - response, - "", - func(_ context.Context, plaintext []byte) ([]byte, error) { - return append([]byte("encrypted:"), plaintext...), nil - }, - func(_ context.Context, name string, _ []byte) error { - writes = append(writes, name) - return nil - }, - func(time.Duration) { t.Fatal("unexpected retry") }, - ) - if err == nil || !strings.Contains(err.Error(), "complete encrypted response is durable") { - t.Fatalf("error = %v, want durable-bundle warning", err) +func TestPersistBootstrapCompletionRecordsAuditAndRevocation(t *testing.T) { + originalShares := vaultRecoveryShares + originalThreshold := vaultRecoveryThreshold + originalKeys := vaultRecoveryPGPKeys + t.Cleanup(func() { + vaultRecoveryShares = originalShares + vaultRecoveryThreshold = originalThreshold + vaultRecoveryPGPKeys = originalKeys + }) + vaultRecoveryShares = 5 + vaultRecoveryThreshold = 3 + vaultRecoveryPGPKeys = testRecoveryPGPKeys() + + var name string + var record []byte + if err := persistBootstrapCompletion(func(_ context.Context, objectName string, data []byte) error { + name = objectName + record = bytes.Clone(data) + return nil + }, func(time.Duration) { t.Fatal("unexpected retry") }); err != nil { + t.Fatal(err) } - if !reflect.DeepEqual(writes, []string{unsealKeysObjectName}) { - t.Fatalf("writes = %v, want only recovery bundle", writes) + if name != bootstrapCompleteObjectName { + t.Fatalf("object = %q", name) + } + var completion bootstrapCompletion + if err := json.Unmarshal(record, &completion); err != nil { + t.Fatal(err) + } + if completion.SchemaVersion != 1 || completion.AuditPath != "cloudrun" || !completion.RootTokenRevoked || + completion.RecoveryShares != 5 || completion.RecoveryThreshold != 3 || len(completion.CustodianKeySHA256) != 5 { + t.Fatalf("completion = %#v", completion) } } @@ -427,35 +438,44 @@ func TestVerifyStoredRecoveryMaterial(t *testing.T) { { name: "complete", sizes: map[string]int64{ - unsealKeysObjectName: 512, - rootTokenObjectName: 128, + unsealKeysObjectName: 512, + bootstrapCompleteObjectName: 128, }, }, { name: "missing mandatory recovery bundle", - sizes: map[string]int64{rootTokenObjectName: 128}, + sizes: map[string]int64{bootstrapCompleteObjectName: 128}, wantError: unsealKeysObjectName + " is missing", }, { - name: "missing convenience root token", + name: "missing bootstrap completion", sizes: map[string]int64{unsealKeysObjectName: 512}, - wantError: rootTokenObjectName + " is missing", + wantError: bootstrapCompleteObjectName + " is missing", }, { name: "empty mandatory recovery bundle", sizes: map[string]int64{ - unsealKeysObjectName: 0, - rootTokenObjectName: 128, + unsealKeysObjectName: 0, + bootstrapCompleteObjectName: 128, }, wantError: unsealKeysObjectName + " is empty", }, { - name: "empty convenience root token", + name: "empty bootstrap completion", + sizes: map[string]int64{ + unsealKeysObjectName: 512, + bootstrapCompleteObjectName: 0, + }, + wantError: bootstrapCompleteObjectName + " is empty", + }, + { + name: "legacy root token retained", sizes: map[string]int64{ - unsealKeysObjectName: 512, - rootTokenObjectName: 0, + unsealKeysObjectName: 512, + bootstrapCompleteObjectName: 128, + rootTokenObjectName: 64, }, - wantError: rootTokenObjectName + " is empty", + wantError: rootTokenObjectName + " still exists", }, { name: "lookup failure", @@ -490,57 +510,180 @@ func TestVerifyStoredRecoveryMaterial(t *testing.T) { } } -func TestRestoreRootTokenObjectFromDurableResponse(t *testing.T) { - response := []byte(`{"keys_base64":["key"],"root_token":"root"}`) - var storedName string - var storedData []byte - err := restoreRootTokenObject( - context.Background(), - func(_ context.Context, name string) ([]byte, error) { - if name != unsealKeysObjectName { - t.Fatalf("read object = %q", name) +func TestVerifyRecoveryBundleContentsRejectsRetainedRootToken(t *testing.T) { + for _, test := range []struct { + name string + response string + wantError string + }{ + {name: "root free", response: `{"recovery_keys_base64":["key"]}`}, + {name: "retained root", response: `{"recovery_keys_base64":["key"],"root_token":"root"}`, wantError: "retains an initial root token"}, + {name: "no keys", response: `{}`, wantError: "contains no recovery or unseal keys"}, + } { + t.Run(test.name, func(t *testing.T) { + err := verifyRecoveryBundleContents( + context.Background(), + func(context.Context, string) ([]byte, error) { return []byte("encrypted"), nil }, + func(context.Context, []byte) ([]byte, error) { return []byte(test.response), nil }, + ) + if test.wantError == "" && err != nil { + t.Fatal(err) } - return append([]byte("encrypted:"), response...), nil - }, - func(_ context.Context, ciphertext []byte) ([]byte, error) { - return bytes.TrimPrefix(ciphertext, []byte("encrypted:")), nil - }, - func(_ context.Context, plaintext []byte) ([]byte, error) { - return append([]byte("encrypted:"), plaintext...), nil - }, - func(_ context.Context, name string, data []byte) error { - storedName = name - storedData = bytes.Clone(data) - return nil + if test.wantError != "" && (err == nil || !strings.Contains(err.Error(), test.wantError)) { + t.Fatalf("error = %v, want %q", err, test.wantError) + } + }) + } +} + +func TestVerifyBootstrapCompletion(t *testing.T) { + validRecord := bootstrapCompletion{ + SchemaVersion: 1, + AuditPath: "cloudrun", + RootTokenRevoked: true, + RecoveryShares: 5, + RecoveryThreshold: 3, + CustodianKeySHA256: []string{ + strings.Repeat("a", 64), + strings.Repeat("b", 64), + strings.Repeat("c", 64), + strings.Repeat("d", 64), + strings.Repeat("e", 64), }, - ) + } + valid := string(mustJSON(t, validRecord)) + duplicateRecord := validRecord + duplicateRecord.CustodianKeySHA256 = append([]string(nil), validRecord.CustodianKeySHA256...) + duplicateRecord.CustodianKeySHA256[4] = duplicateRecord.CustodianKeySHA256[3] + invalidFingerprintRecord := validRecord + invalidFingerprintRecord.CustodianKeySHA256 = append([]string(nil), validRecord.CustodianKeySHA256...) + invalidFingerprintRecord.CustodianKeySHA256[4] = "not-a-sha256" + for _, test := range []struct { + name string + record string + wantError bool + }{ + {name: "valid", record: valid}, + {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}, + {name: "invalid fingerprint", record: string(mustJSON(t, invalidFingerprintRecord)), wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + err := verifyBootstrapCompletion(context.Background(), func(context.Context, string) ([]byte, error) { + return []byte(test.record), nil + }) + if (err != nil) != test.wantError { + t.Fatalf("error = %v, wantError = %t", err, test.wantError) + } + }) + } +} + +func TestRecoveryPGPKeysRequireIndependentQuorum(t *testing.T) { + validKeys := testRecoveryPGPKeys() + t.Setenv("TEST_RECOVERY_PGP_KEYS", string(mustJSON(t, validKeys))) + got, err := recoveryPGPKeysFromEnv("TEST_RECOVERY_PGP_KEYS", 5, 3) if err != nil { t.Fatal(err) } - if storedName != rootTokenObjectName { - t.Fatalf("stored object = %q", storedName) + if !reflect.DeepEqual(got, validKeys) { + t.Fatalf("keys changed") } - if string(storedData) != "encrypted:root" { - t.Fatalf("stored data = %q", storedData) + + tests := []struct { + name string + keys []string + shares int + threshold int + }{ + {name: "one of one", keys: validKeys[:1], shares: 1, threshold: 1}, + {name: "missing custodian", keys: validKeys[:4], shares: 5, threshold: 3}, + {name: "duplicate", keys: []string{validKeys[0], validKeys[1], validKeys[2], validKeys[3], validKeys[3]}, shares: 5, threshold: 3}, + {name: "invalid base64", keys: []string{validKeys[0], validKeys[1], validKeys[2], validKeys[3], "not-base64"}, shares: 5, threshold: 3}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("TEST_RECOVERY_PGP_KEYS", string(mustJSON(t, test.keys))) + if _, err := recoveryPGPKeysFromEnv("TEST_RECOVERY_PGP_KEYS", test.shares, test.threshold); err == nil { + t.Fatal("unsafe recovery custody accepted") + } + }) } } -func TestRestoreRootTokenObjectRejectsUnusableResponse(t *testing.T) { - err := restoreRootTokenObject( - context.Background(), - func(context.Context, string) ([]byte, error) { return []byte("encrypted"), nil }, - func(context.Context, []byte) ([]byte, error) { return []byte(`{"keys_base64":["key"]}`), nil }, - func(context.Context, []byte) ([]byte, error) { - t.Fatal("root token encryption ran for unusable response") - return nil, nil - }, - func(context.Context, string, []byte) error { - t.Fatal("root token write ran for unusable response") - return nil - }, - ) - if err == nil || !strings.Contains(err.Error(), "did not contain a root token") { - t.Fatalf("error = %v, want missing root token", err) +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func TestSecureBootstrapEnablesAuditBeforeRevokingRoot(t *testing.T) { + originalVaultAddr := vaultAddr + originalHTTPClient := httpClient + originalMetadataClient := metadataClient + t.Cleanup(func() { + vaultAddr = originalVaultAddr + httpClient = originalHTTPClient + metadataClient = originalMetadataClient + }) + + metadataClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"access_token":"proxy-admin"}`)), + Header: make(http.Header), + }, nil + })} + + auditEnabled := false + rootRevoked := false + var events []string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("X-Admin-Token") != "proxy-admin" || request.Header.Get("X-Vault-Token") != "initial-root" { + t.Errorf("privileged headers were not separated") + } + switch request.Method + " " + request.URL.Path { + case "GET /v1/sys/audit": + if !auditEnabled { + _, _ = response.Write([]byte(`{}`)) + return + } + _, _ = response.Write([]byte(`{"cloudrun/":{"type":"file","options":{"file_path":"stdout","log_raw":"false"}}}`)) + case "POST /v1/sys/audit/cloudrun": + events = append(events, "audit") + auditEnabled = true + response.WriteHeader(http.StatusNoContent) + case "POST /v1/auth/token/revoke-self": + if !auditEnabled { + t.Error("root token was revoked before audit was enabled") + } + events = append(events, "revoke") + rootRevoked = true + response.WriteHeader(http.StatusNoContent) + case "GET /v1/auth/token/lookup-self": + events = append(events, "verify") + if rootRevoked { + response.WriteHeader(http.StatusForbidden) + return + } + response.WriteHeader(http.StatusOK) + default: + t.Fatalf("unexpected Vault request %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + vaultAddr = server.URL + httpClient = server.Client() + + if err := enableAuditAndRevokeInitialRoot("initial-root"); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(events, []string{"audit", "revoke", "verify"}) { + t.Fatalf("events = %v", events) } } From 8acae39f5c43c6ec5369be4bfb9996b5a98f70fe Mon Sep 17 00:00:00 2001 From: libops-agent <115990865+libops-agent@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:15:29 +0000 Subject: [PATCH 2/2] [patch] Update vulnerable Vault init dependencies --- go.mod | 4 ++-- go.sum | 8 ++++---- main.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 5e13cf5..da53580 100644 --- a/go.mod +++ b/go.mod @@ -46,11 +46,11 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect - google.golang.org/grpc v1.82.0 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index dc737f4..b7e3e6c 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -119,8 +119,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index 3ca857f..e5e1821 100644 --- a/main.go +++ b/main.go @@ -755,7 +755,7 @@ func doRootVaultRequest(method, requestPath string, payload []byte, rootToken st return 0, nil, err } if len(responseBody) > maxEncryptedBundle { - return 0, nil, fmt.Errorf("Vault response exceeds %d bytes", maxEncryptedBundle) + return 0, nil, fmt.Errorf("vault response exceeds %d bytes", maxEncryptedBundle) } return response.StatusCode, responseBody, nil }