The passphrase protecting the keychain unlocker's age key was a plain string passed through encoding/json, so copies that cannot be wiped stayed in ordinary memory when an unlocker was created and each time one was used.
generateRandomPassphrase now hex-encodes random bytes from one locked buffer into another.
KeychainData holds the passphrase as a *memguard.LockedBuffer and moved to internal/secret/keychaindata.go, a file that is not darwin-only. encode copies the parts straight into a locked buffer and refuses a passphrase that is not hex, since hex never needs JSON escaping. decodeKeychainData reads the passphrase field as a json.RawMessage, moves it into a locked buffer and wipes it. The JSON field names are unchanged.
keychainunlocker.go only calls these. It moves the bytes read from the keychain into locked memory straight away, and stores the item from the locked buffer instead of a []byte(string) copy.
helpers_darwin.go is deleted: its one function has no callers left.
Verification: keychaindata.go and its tests are compiled and run on Linux. The keychainunlocker.go changes are darwin-only and were checked only by reading; nothing here compiles them (#50).
Confirmed in the memguard v0.22.5 source: LockedBuffer.String() points at the locked memory and does not copy it.
Not fixed here, because the copies are made outside this code: the keychain library returns the item as an ordinary []byte (wiped as soon as it is moved) and the OS keeps its own copies; age's scrypt functions copy the passphrase into their own []byte, which affects every passphrase unlocker.
Model: opus-5-5
Fixes https://git.eeqj.de/sneak/secret/issues/36.
The passphrase protecting the keychain unlocker's age key was a plain `string` passed through `encoding/json`, so copies that cannot be wiped stayed in ordinary memory when an unlocker was created and each time one was used.
- `generateRandomPassphrase` now hex-encodes random bytes from one locked buffer into another.
- `KeychainData` holds the passphrase as a `*memguard.LockedBuffer` and moved to `internal/secret/keychaindata.go`, a file that is not darwin-only. `encode` copies the parts straight into a locked buffer and refuses a passphrase that is not hex, since hex never needs JSON escaping. `decodeKeychainData` reads the passphrase field as a `json.RawMessage`, moves it into a locked buffer and wipes it. The JSON field names are unchanged.
- `keychainunlocker.go` only calls these. It moves the bytes read from the keychain into locked memory straight away, and stores the item from the locked buffer instead of a `[]byte(string)` copy.
- `helpers_darwin.go` is deleted: its one function has no callers left.
Verification: `keychaindata.go` and its tests are compiled and run on Linux. The `keychainunlocker.go` changes are darwin-only and were checked only by reading; nothing here compiles them (https://git.eeqj.de/sneak/secret/issues/50).
Confirmed in the memguard v0.22.5 source: `LockedBuffer.String()` points at the locked memory and does not copy it.
Not fixed here, because the copies are made outside this code: the keychain library returns the item as an ordinary `[]byte` (wiped as soon as it is moved) and the OS keeps its own copies; age's scrypt functions copy the passphrase into their own `[]byte`, which affects every passphrase unlocker.
Model: opus-5-5
The passphrase protecting the keychain unlocker's age key was a plain
string passed through encoding/json, leaving copies in ordinary memory
when an unlocker was created and each time one was used.
It is now generated into a locked buffer, and KeychainData, moved to
keychaindata.go, which is not darwin-only so its tests run on Linux,
writes and reads the keychain JSON itself: encode copies the parts
straight into a locked buffer, and decodeKeychainData takes the
passphrase from a json.RawMessage that it wipes. The JSON field names
are unchanged. keychainunlocker.go only calls this code and stores the
item from the locked buffer without a string copy.
Model: opus-5-5
PASS: the keychain unlocker passphrase now stays in locked memory when it is generated, written into the keychain JSON, stored and read back, as #36 and its scope note require.
Unverified item: the keychainunlocker.go changes were checked only by reading them against the memguard and go-keychain sources; nothing here compiles darwin code (#50).
Judgement call: the copies of the passphrase that age makes when it builds a scrypt recipient or identity, and that the scrypt key derivation under it makes, are accepted as outside this issue, as the PR states; they affect every passphrase unlocker.
Model: opus-5-5
PASS: the keychain unlocker passphrase now stays in locked memory when it is generated, written into the keychain JSON, stored and read back, as https://git.eeqj.de/sneak/secret/issues/36 and its scope note require.
- Unverified item: the `keychainunlocker.go` changes were checked only by reading them against the memguard and go-keychain sources; nothing here compiles darwin code (https://git.eeqj.de/sneak/secret/issues/50).
- Judgement call: the copies of the passphrase that age makes when it builds a scrypt recipient or identity, and that the scrypt key derivation under it makes, are accepted as outside this issue, as the PR states; they affect every passphrase unlocker.
Model: opus-5-5
clawbot
merged commit 7c6531eaf7 into next2026-10-03 17:07:57 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Fixes #36.
The passphrase protecting the keychain unlocker's age key was a plain
stringpassed throughencoding/json, so copies that cannot be wiped stayed in ordinary memory when an unlocker was created and each time one was used.generateRandomPassphrasenow hex-encodes random bytes from one locked buffer into another.KeychainDataholds the passphrase as a*memguard.LockedBufferand moved tointernal/secret/keychaindata.go, a file that is not darwin-only.encodecopies the parts straight into a locked buffer and refuses a passphrase that is not hex, since hex never needs JSON escaping.decodeKeychainDatareads the passphrase field as ajson.RawMessage, moves it into a locked buffer and wipes it. The JSON field names are unchanged.keychainunlocker.goonly calls these. It moves the bytes read from the keychain into locked memory straight away, and stores the item from the locked buffer instead of a[]byte(string)copy.helpers_darwin.gois deleted: its one function has no callers left.Verification:
keychaindata.goand its tests are compiled and run on Linux. Thekeychainunlocker.gochanges are darwin-only and were checked only by reading; nothing here compiles them (#50).Confirmed in the memguard v0.22.5 source:
LockedBuffer.String()points at the locked memory and does not copy it.Not fixed here, because the copies are made outside this code: the keychain library returns the item as an ordinary
[]byte(wiped as soon as it is moved) and the OS keeps its own copies; age's scrypt functions copy the passphrase into their own[]byte, which affects every passphrase unlocker.Model: opus-5-5
PASS: the keychain unlocker passphrase now stays in locked memory when it is generated, written into the keychain JSON, stored and read back, as #36 and its scope note require.
keychainunlocker.gochanges were checked only by reading them against the memguard and go-keychain sources; nothing here compiles darwin code (#50).Model: opus-5-5