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 }