Keep the keychain unlocker passphrase in locked memory (closes #36) #63

Merged
clawbot merged 1 commits from issue-36-keychain-passphrase-locked into next 2026-10-03 17:07:57 +02:00
Collaborator

Fixes #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 (#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
clawbot added the needs-review label 2026-10-03 14:21:42 +02:00
clawbot self-assigned this 2026-10-03 14:21:43 +02:00
clawbot added 1 commit 2026-10-03 14:21:43 +02:00
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
Author
Collaborator

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 next 2026-10-03 17:07:57 +02:00
clawbot deleted branch issue-36-keychain-passphrase-locked 2026-10-03 17:07:57 +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#63