KeychainData holds the age key passphrase as a plain string through JSON marshalling #36
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
From the 1.0 security survey. Confirmed still present.
Threat
internal/secret/keychainunlocker.go:49-53:AgePrivKeyPassphraseis the 64-hex-character passphrase protecting the keychain unlocker's age private key. It is handled as an ordinary Gostringon both the create and the unlock path, which means it exists in unlocked, swappable, GC-managed heap in at least three simultaneous copies:generateRandomPassphrase()(:368, backed bygenerateRandomStringininternal/secret/helpers.go) returns a plainstring. It is assigned into the struct at:421.json.Marshalat:425produces a second unprotected copy as[]byte. Only at:431is anything wrapped in aLockedBuffer— by which point the plaintext has already been allocated twice in ordinary heap.json.Unmarshalat:86decodes into the same plain-string struct, and the wrap does not happen until:113.Go strings are immutable and garbage-collected; there is no way to zero them, and
encoding/jsonallocates its own internal buffers that the caller never sees and cannot wipe. So these copies persist in the heap until the allocator happens to reuse the pages, well past the point where the code believes it has cleaned up.Recovering this passphrase from a swap file, hibernation image, or core dump — or by reading
/proc/<pid>/memas the same uid — yields the age private key for the keychain unlocker, and combined with the on-diskpriv.agethat is the vault's long-term key and therefore every secret in the vault. The rest of the codebase takes memguard seriously, which makes this path the weak link rather than a consistent level of protection.Definition of done
stringor[]byteat any point in its lifetime, on either the create or the unlock path.generateRandomPassphrase/generateRandomStringreturn a*memguard.LockedBufferrather than astring, or a locked-buffer variant is added and used here.encoding/jsonever holding the plaintext passphrase. Acceptable approaches: unmarshal intojson.RawMessageand copy only the passphrase field into a locked buffer, or hand-roll the encode/decode for this struct. Whichever is chosen, the JSON wire format on disk must be unchanged so existing keychain items still load.make checkgreen.TODO.mdupdated in the same commit.Implementation requirements
go.modbefore relying onLockedBuffer.String()semantics: in memguard v0.22 it returns a string aliasing the locked pages rather than copying, which is safe, but this must be confirmed rather than assumed. If it copies, that defeats the whole change.fmt.Sprintf, string concatenation,[]byte(s)conversion, and passing a string to any stdlib function that buffers internally all reintroduce the problem.identity.String()sites, orGetSecretVersionreturning plaintext). Those are tracked separately and this should stay reviewable on its own.