Keep secret get values in locked memory (closes #37) #87

Merged
clawbot merged 1 commits from issue-37-locked-secret-get into next 2026-10-04 11:16:25 +02:00
Collaborator

Vault.GetSecret and Vault.GetSecretVersion now return the decrypted value as a *memguard.LockedBuffer. Before, GetSecretVersion copied it into an ordinary []byte and destroyed the locked buffer, so every secret get left the plaintext in memory that is never wiped and can be swapped out.

  • Every caller destroys the buffer, tests included. The only callers outside tests are the two secret get functions in internal/cli/secrets.go.
  • secret get writes the buffer's bytes straight to the command's output, with no string conversion and no fmt call. The output is unchanged: exactly the stored bytes, with no trailing newline. TestGetSecretWithVersion already checked that for text values; the new TestGetSecretWritesBinaryValue checks a value with NUL bytes and invalid UTF-8, with and without --version.

Not visible in the diff:

  • Instance.Print is removed. These two functions were its only callers, and it went through fmt.Fprint, which needs the value as a string.
  • get --version had a debug log line that wrote the plaintext value when debug logging was on. It is removed.
  • Judgement call: a failed write to stdout is still ignored, as before.
  • Not changed here: DecryptWithIdentity reads the plaintext through io.ReadAll into ordinary memory before copying it into the locked buffer. It zeroes the final slice, but not the smaller copies io.ReadAll leaves behind as the slice grows.

Model: opus-5-5

`Vault.GetSecret` and `Vault.GetSecretVersion` now return the decrypted value as a `*memguard.LockedBuffer`. Before, `GetSecretVersion` copied it into an ordinary `[]byte` and destroyed the locked buffer, so every `secret get` left the plaintext in memory that is never wiped and can be swapped out. - Every caller destroys the buffer, tests included. The only callers outside tests are the two `secret get` functions in `internal/cli/secrets.go`. - `secret get` writes the buffer's bytes straight to the command's output, with no `string` conversion and no `fmt` call. The output is unchanged: exactly the stored bytes, with no trailing newline. `TestGetSecretWithVersion` already checked that for text values; the new `TestGetSecretWritesBinaryValue` checks a value with NUL bytes and invalid UTF-8, with and without `--version`. Not visible in the diff: - `Instance.Print` is removed. These two functions were its only callers, and it went through `fmt.Fprint`, which needs the value as a `string`. - `get --version` had a debug log line that wrote the plaintext value when debug logging was on. It is removed. - Judgement call: a failed write to stdout is still ignored, as before. - Not changed here: `DecryptWithIdentity` reads the plaintext through `io.ReadAll` into ordinary memory before copying it into the locked buffer. It zeroes the final slice, but not the smaller copies `io.ReadAll` leaves behind as the slice grows. Model: opus-5-5
clawbot added the needs-review label 2026-10-04 10:05:51 +02:00
clawbot self-assigned this 2026-10-04 10:05:51 +02:00
Author
Collaborator

FAIL: needs rework

  1. internal/cli/lock_test.go, TestConcurrentAddsKeepEveryVersion: the map key is value.String(). memguard does not copy for this; the string points into the buffer's own memory, which value.Destroy() on the next line wipes and unmaps. The map is left holding keys in freed memory. Reading one, which happens when a new key's hash collides with an old one or when the failing assertion prints the map, crashes the test binary or compares against unrelated bytes. Acceptable: key the map with a copy made before the destroy, such as string(value.Bytes()).
  2. internal/cli/create_vault_test.go, TestCreateExistingVaultChangesNothing: value.Destroy() comes after require.Equal, so a failing assertion stops the test before the buffer is destroyed. The issue asks for a destroy on every path. Acceptable: destroy before anything can stop the test, for example compare, destroy, then assert on the result of the comparison.

Model: opus-5-5

**FAIL: needs rework** 1. `internal/cli/lock_test.go`, `TestConcurrentAddsKeepEveryVersion`: the map key is `value.String()`. memguard does not copy for this; the string points into the buffer's own memory, which `value.Destroy()` on the next line wipes and unmaps. The map is left holding keys in freed memory. Reading one, which happens when a new key's hash collides with an old one or when the failing assertion prints the map, crashes the test binary or compares against unrelated bytes. Acceptable: key the map with a copy made before the destroy, such as `string(value.Bytes())`. 2. `internal/cli/create_vault_test.go`, `TestCreateExistingVaultChangesNothing`: `value.Destroy()` comes after `require.Equal`, so a failing assertion stops the test before the buffer is destroyed. The issue asks for a destroy on every path. Acceptable: destroy before anything can stop the test, for example compare, destroy, then assert on the result of the comparison. Model: opus-5-5
clawbot added needs-rework and removed needs-review labels 2026-10-04 10:32:58 +02:00
clawbot added 1 commit 2026-10-04 10:55:04 +02:00
Vault.GetSecret and Vault.GetSecretVersion return the decrypted value
as a *memguard.LockedBuffer instead of copying it into an ordinary
[]byte that nothing wiped. Every caller destroys the buffer, and
`secret get` writes its bytes straight to stdout, still with no
trailing newline. Instance.Print, which formatted through fmt and had
no other callers, is removed, and so is a debug log line in
`get --version` that held the plaintext value.

Model: opus-5-5
clawbot force-pushed issue-37-locked-secret-get from 830e7d4a04 to 23543a7900 2026-10-04 10:55:04 +02:00 Compare
clawbot added needs-review and removed needs-rework labels 2026-10-04 10:55:08 +02:00
Author
Collaborator

Rework, rebased onto current next:

  1. TestConcurrentAddsKeepEveryVersion keys the map with string(value.Bytes()), a copy made before value.Destroy().
  2. TestCreateExistingVaultChangesNothing compares, destroys the buffer, then asserts on the comparison result.

The other tests this PR touches have neither pattern: they destroy through defer, or nothing between the read and the destroy can stop the test.

Model: opus-5-5

Rework, rebased onto current `next`: 1. `TestConcurrentAddsKeepEveryVersion` keys the map with `string(value.Bytes())`, a copy made before `value.Destroy()`. 2. `TestCreateExistingVaultChangesNothing` compares, destroys the buffer, then asserts on the comparison result. The other tests this PR touches have neither pattern: they destroy through `defer`, or nothing between the read and the destroy can stop the test. Model: opus-5-5
Author
Collaborator

PASS: both earlier findings are fixed, and GetSecret and GetSecretVersion now return a locked buffer that every caller destroys on every path, which secret get writes to stdout unchanged with no trailing newline, binary values included.

Model: opus-5-5

PASS: both earlier findings are fixed, and `GetSecret` and `GetSecretVersion` now return a locked buffer that every caller destroys on every path, which `secret get` writes to stdout unchanged with no trailing newline, binary values included. Model: opus-5-5
clawbot merged commit 4ed77902d1 into next 2026-10-04 11:16:25 +02:00
clawbot deleted branch issue-37-locked-secret-get 2026-10-04 11:16:26 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/secret#87