fix(pass): copy bytes in PassValue.Unmarshal to survive backend zeroing#536
Conversation
Store backends defer clear() on the source buffer after calling Secret.Unmarshal (commit cd2a257). PassValue was aliasing the slice (m.value = data), so its value got zeroed under it. The daemon then returned base64-encoded zeros (e.g. "AAAA…" / "AAA") instead of the secret. macOS keychain tests masked this because MockCredential.Unmarshal copies via string() conversion. Fix: copy bytes in PassValue.Unmarshal. Also document the ownership contract on store.Secret.Unmarshal and add a regression test that zeros the source buffer after Unmarshal. Signed-off-by: Alano Terblanche <alano.terblanche@docker.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Assessment: 🔴 CRITICAL
Note: Verification agent returned an inconclusive response; findings from the drafter are surfaced for author evaluation.
Context: The Unmarshal fix (copying bytes via append([]byte(nil), data...)) is correct and well-motivated. However, the PR leaves a symmetric aliasing issue in PassValue.Marshal(), and the new interface contract documentation only covers the Unmarshal direction.
| @@ -59,7 +59,9 @@ var ( | |||
| type Secret interface { | |||
| // Marshal the secret into a slice of bytes | |||
| Marshal() ([]byte, error) | |||
There was a problem hiding this comment.
[HIGH] Incomplete interface contract: Marshal() return value is also zeroed by backends
All four store backends call defer clear(data) on the slice returned by Marshal():
// keychain_darwin.go:183, keychain_linux.go:322, keychain_windows.go:63, posixage/store.go:351
data, err := secret.Marshal()
defer clear(data) // ← zeroes m.value in PassValue via the aliasSince PassValue.Marshal() returns m.value directly (return m.value, nil) without copying, every Save()/Upsert() call silently zeroes the PassValue's internal state. Any subsequent Marshal() call on the same instance returns all-zero bytes.
The new comment added in this PR documents that Unmarshal implementations must copy retained bytes, but misses the symmetric contract for Marshal: backends zero the returned buffer, so Marshal() must return a fresh copy (or the contract must state that Marshal() output will be zeroed and callers must not reuse the instance).
Suggested fixes:
Option A — Copy in PassValue.Marshal():
func (m *PassValue) Marshal() ([]byte, error) {
return append([]byte(nil), m.value...), nil
}Option B — Document the contract on the Marshal interface comment (alongside the Unmarshal note already added):
// Marshal the secret into a slice of bytes.
// Callers (store backends) may zero the returned buffer after use;
// implementations that need to retain state must not return an alias.
Marshal() ([]byte, error)
Store backends defer clear() on the source buffer after calling Secret.Unmarshal (commit cd2a257). PassValue was aliasing the slice (m.value = data), so its value got zeroed under it. The daemon then returned base64-encoded zeros (e.g. "AAAA…" / "AAA") instead of the secret.
macOS keychain tests masked this because MockCredential.Unmarshal copies via string() conversion.
Fix: copy bytes in PassValue.Unmarshal. Also document the ownership contract on store.Secret.Unmarshal and add a regression test that zeros the source buffer after Unmarshal.