check / check (push) Successful in 39s
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
143 lines
4.0 KiB
Go
143 lines
4.0 KiB
Go
package secret
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/awnumar/memguard"
|
|
)
|
|
|
|
var (
|
|
errPassphraseLength = errors.New(
|
|
"passphrase length must be a positive even number")
|
|
errPassphraseNotHex = errors.New(
|
|
"keychain passphrase must be lowercase hex")
|
|
errNoKeychainPassphrase = errors.New(
|
|
"keychain data has no agePrivKeyPassphrase string")
|
|
)
|
|
|
|
// KeychainData is what a keychain unlocker stores in the macOS keychain.
|
|
// It is stored as JSON, but encode and decodeKeychainData keep the
|
|
// passphrase out of encoding/json, which would leave copies of it in
|
|
// ordinary memory.
|
|
type KeychainData struct {
|
|
AgePublicKey string
|
|
AgePrivKeyPassphrase *memguard.LockedBuffer
|
|
EncryptedLongtermKey string
|
|
}
|
|
|
|
// generateRandomPassphrase returns length random lowercase hex characters
|
|
// in a locked buffer. The caller must destroy it.
|
|
func generateRandomPassphrase(length int) (*memguard.LockedBuffer, error) {
|
|
// Each random byte becomes two hex characters.
|
|
randomBytes := hex.DecodedLen(length)
|
|
if length <= 0 || hex.EncodedLen(randomBytes) != length {
|
|
return nil, errPassphraseLength
|
|
}
|
|
|
|
random := memguard.NewBufferRandom(randomBytes)
|
|
defer random.Destroy()
|
|
|
|
passphrase := memguard.NewBuffer(length)
|
|
hex.Encode(passphrase.Bytes(), random.Bytes())
|
|
passphrase.Freeze()
|
|
|
|
return passphrase, nil
|
|
}
|
|
|
|
// encode returns d as JSON in a locked buffer:
|
|
// {"agePublicKey":"...","agePrivKeyPassphrase":"...","encryptedLongtermKey":"..."}.
|
|
// The passphrase is copied straight into the buffer, so it must be hex,
|
|
// which JSON does not escape. The caller must destroy the returned buffer.
|
|
func (d *KeychainData) encode() (*memguard.LockedBuffer, error) {
|
|
if d.AgePrivKeyPassphrase == nil {
|
|
return nil, errNilPassphraseBuffer
|
|
}
|
|
|
|
if d.AgePrivKeyPassphrase.Size() == 0 {
|
|
return nil, errEmptyPassphrase
|
|
}
|
|
|
|
for _, c := range d.AgePrivKeyPassphrase.Bytes() {
|
|
if strings.IndexByte("0123456789abcdef", c) < 0 {
|
|
return nil, errPassphraseNotHex
|
|
}
|
|
}
|
|
|
|
publicKey, err := json.Marshal(d.AgePublicKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode age public key: %w", err)
|
|
}
|
|
|
|
longtermKey, err := json.Marshal(d.EncryptedLongtermKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode long-term key: %w", err)
|
|
}
|
|
|
|
parts := [][]byte{
|
|
[]byte(`{"agePublicKey":`), publicKey,
|
|
[]byte(`,"agePrivKeyPassphrase":"`), d.AgePrivKeyPassphrase.Bytes(),
|
|
[]byte(`","encryptedLongtermKey":`), longtermKey,
|
|
[]byte(`}`),
|
|
}
|
|
|
|
size := 0
|
|
for _, part := range parts {
|
|
size += len(part)
|
|
}
|
|
|
|
encoded := memguard.NewBuffer(size)
|
|
|
|
written := 0
|
|
for _, part := range parts {
|
|
written += copy(encoded.Bytes()[written:], part)
|
|
}
|
|
|
|
encoded.Freeze()
|
|
|
|
return encoded, nil
|
|
}
|
|
|
|
// decodeKeychainData parses keychain data written by encode. The caller
|
|
// must destroy the returned AgePrivKeyPassphrase.
|
|
func decodeKeychainData(data *memguard.LockedBuffer) (*KeychainData, error) {
|
|
if data == nil {
|
|
return nil, errNilDataBuffer
|
|
}
|
|
|
|
// json.Unmarshal gives a json.RawMessage field the field's JSON text
|
|
// unchanged, in the one copy RawMessage makes; it is wiped on return.
|
|
var fields struct {
|
|
AgePublicKey string `json:"agePublicKey"`
|
|
AgePrivKeyPassphrase json.RawMessage `json:"agePrivKeyPassphrase"`
|
|
EncryptedLongtermKey string `json:"encryptedLongtermKey"`
|
|
}
|
|
|
|
defer func() { memguard.WipeBytes(fields.AgePrivKeyPassphrase) }()
|
|
|
|
err := json.Unmarshal(data.Bytes(), &fields)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
|
}
|
|
|
|
// json.Unmarshal accepted the JSON, so text that starts with a quote is
|
|
// a whole string. The passphrase is hex, so it is the text between the
|
|
// quotes.
|
|
quoted := fields.AgePrivKeyPassphrase
|
|
if !bytes.HasPrefix(quoted, []byte(`"`)) {
|
|
return nil, errNoKeychainPassphrase
|
|
}
|
|
|
|
return &KeychainData{
|
|
AgePublicKey: fields.AgePublicKey,
|
|
// NewBufferFromBytes wipes the bytes it copies.
|
|
AgePrivKeyPassphrase: memguard.NewBufferFromBytes(
|
|
quoted[1 : len(quoted)-1]),
|
|
EncryptedLongtermKey: fields.EncryptedLongtermKey,
|
|
}, nil
|
|
}
|