age identity .String() creates unprotected copies of private keys at six call sites #38
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, with one part of the original TODO claim now stale.
Threat
age.X25519Identity.String()returns a plain Gostringcontaining the bech32-encoded X25519 private key. At each site below the code calls.String()and only afterwards wraps the result in aLockedBuffer— so an unprotected, immutable, unzeroable copy of the private key is allocated in ordinary heap first, and a second copy is created by the[]byte(...)conversion:internal/secret/keychainunlocker.go:278—memguard.NewBufferFromBytes([]byte(ltIdentity.String()))— this one is the vault long-term keyinternal/secret/keychainunlocker.go:382-383—agePrivKeyStr := ageIdentity.String()then wrapinternal/secret/pgpunlocker.go:308—memguard.NewBufferFromBytes([]byte(ageIdentity.String()))internal/vault/unlockers.go:401and:478—privKeyStr := unlockerIdentity.String()internal/secret/version.go:167-168— version keypair private keyinternal/secret/seunlocker_darwin.go:347Several of these are the long-term vault key, from which every secret in the vault is derivable. Go strings cannot be zeroed and are garbage-collected, so these copies persist in the heap past the point the code believes it has cleaned up, exposed via swap, hibernation images, core dumps, and same-uid
/proc/<pid>/memreads.Correction to the old TODO entry: it listed
internal/cli/version.goas one of these sites. That is stale — that file contains no.String()call on an identity.internal/cli/crypto.gois also largely fixed: the key is held in aLockedBufferthroughout, with the one genuine residual beingcrypto.go:95,memguard.NewBufferFromBytes([]byte(identity.String())), which belongs on this list. The other.String()calls in that file (:114,:139,:220,:225) areLockedBuffer.String(), which in memguard v0.22 aliases the locked pages rather than copying, and are therefore materially lower risk.Definition of done
secret.identityToLockedBuffer(id)— performs the identity-to-locked-buffer conversion in exactly one place.internal/cli/crypto.go:95) use it. No[]byte(identity.String())pattern remains anywhere in non-test code; a grep for it in the PR must come back empty.age's API returns astringby value, a perfect fix is not reachable without reimplementing bech32 encoding directly into locked memory. A best-effort helper with the limitation documented in a comment is acceptable and is the expected outcome — but the limitation must be written down at the helper, not left implicit.make checkgreen.TODO.mdupdated in the same commit, including correcting the staleinternal/cli/version.goreference.Implementation requirements
go.modand the actual semantics ofLockedBuffer.String()before relying on the aliasing behavior described above. If it copies rather than aliases, the fourinternal/cli/crypto.gosites become real findings and must be handled too.keychainunlocker.go,seunlocker_darwin.go) are Darwin-gated and are not compiled or linted by the Linux CI runner. A green CI proves nothing about them. State in the PR how those two were verified.KeychainDataplain-string passphrase or theGetSecretVersionplaintext copy; both are tracked separately.