gpg-passphrase request errors when trying to prompt for age key passphrase
With a running gpg-agent, and a passphrase-protected key in ~/.config/sops/age/keys.txt, sops attempts to let the agent prompt for a passphrase, but it seems the request is malformed, here is the error :
age1k6akl9r65sr3hdyfmqrqek0d38k24cqtex3k45nuckam6g5kzy9ss7yugt: FAILED
- | failed to create reader for decrypting sops data key with
| age: failed to decrypt identity file: could not read
| passphrase: gpg-agent passphrase request errored: ERR
| 67109144 IPC parameter error <GPG Agent> - invalid length of
| cacheID
| . Did not find keys in locations
| 'SOPS_AGE_SSH_PRIVATE_KEY_FILE',
| '/home/termaxima/.ssh/id_rsa', 'SOPS_AGE_KEY',
| 'SOPS_AGE_KEY_FILE', and 'SOPS_AGE_KEY_CMD'.
With gpg-agent, passphrase requests work perfectly well (although the lack of caching means the user is re-prompted for the same passphrase multiple times, if the protected key is not the first on the recipient list)
This was tested using sops 3.11.0 and gpg-agent 2.4.8 (libcrypt 1.11.2), using NixOS.
Possible links with pull request #1400
I have found this message on the GnuPG mailing list, which seems to show how to reproduce a similarly malformed request to the gpg passphrase prompt (and how to fix it). The error code is the same.
I'm trying to find the cause (see comments below) but I'm a total noob in Go, so I'm not sure I can fix this myself.
Investigating a little more, it seems the offending call is here
req.CacheKey is somehow being set to an empty string (Since even a single-letter cacheID is valid)
This function is then called only here, where location is likely already erroneously set to an empty string.
location itself contains keys from the readers map, which are set by these variables :
SopsAgeKeyEnvSopsAgeKeyFileEnvSopsAgeKeyCmdEnvageKeyFilePath
One of these four is being set to an empty string somewhere, I'll try to find where.
(btw, probably not going to be able to fix this myself, as I have 0 skills in Go yet. I hope these comments will make the work easier tho)
I think I have hit a dead end, I do not see how any of these variables can end up set to an empty string.
The most likely culprit is ageKeyFilePath, since the other ones are constants set at the top of the file to non-empty values, but I do not see a hole in the logic setting this variable.
userConfigDir, err := getUserConfigDir()
if err != nil && len(readers) == 0 && len(identities) == 0 {
errs = append(errs, fmt.Errorf("user config directory could not be determined: %w", err))
} else if userConfigDir != "" {
// This line is likely the culprit, but userConfigDir is guaranteed non-empty
// by the very previous line, so the result of `filepath.Join` should at worst
// be just `userConfigDir`
ageKeyFilePath := filepath.Join(userConfigDir, filepath.FromSlash(SopsAgeKeyUserConfigPath))
// But then this call would fail, and readers[ageKeyFilePath] would never get set.
// and since ageKeyFilePath would be non-empty anyway, this is clearly not
// what is happening
f, err := os.Open(ageKeyFilePath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("failed to open file: %w", err))
} else if errors.Is(err, os.ErrNotExist) && len(readers) == 0 && len(identities) == 0 {
unusedLocations = append(unusedLocations, ageKeyFilePath)
} else if err == nil {
defer f.Close()
readers[ageKeyFilePath] = f
}
}
Higher in the file, here is where the other three variables are set :
const (
// SopsAgeKeyEnv can be set as an environment variable with a string list
// of age keys as value.
SopsAgeKeyEnv = "SOPS_AGE_KEY" // NOTE: clearly non-empty
// SopsAgeKeyFileEnv can be set as an environment variable pointing to an
// age keys file.
SopsAgeKeyFileEnv = "SOPS_AGE_KEY_FILE" // NOTE : clearly non-empty
// SopsAgeKeyCmdEnv can be set as an environment variable with a command
// to execute that returns the age keys.
SopsAgeKeyCmdEnv = "SOPS_AGE_KEY_CMD" // NOTE : clealy non-empty
// ...
)
There is a possibility that the only other call to GetPassphrase is the offending one, as it is doing some weird stuff I don't understand. Link to the exact line
This would indicate a deeper problem, since I found this bug using an age key specifically, and the other call is supposedly only used for pgp keys...
There is a possibility that the only other call to
GetPassphraseis the offending one, as it is doing some weird stuff I don't understand. Link to the exact lineThis would indicate a deeper problem, since I found this bug using an
agekey specifically, and the other call is supposedly only used forpgpkeys...
After further investigation, this is impossible. The error messages I've shown are only produced with the correct call site for age keys.
I'll let this be for now, I don't know how to use the language at all so I can't do any actual debugging in Go. I hope that by just reading the code, I've given some good leads to whoever ends up picking this issue up !
If you look at https://dev.gnupg.org/source/gnupg/browse/master/agent/command.c$2082, you can see that the error also comes up if the length of location is > 50 bytes, and not only if it is empty. My guess is that the string happens to be longer than that.
(For some quick debugging, I usually insert fmt.Printf("%#v\n", valueOfInterest) and run make install, and then use ~/go/bin/sops.)
If you look at https://dev.gnupg.org/source/gnupg/browse/master/agent/command.c$2082, you can see that the error also comes up if the length of
locationis > 50 bytes, and not only if it is empty. My guess is that the string happens to be longer than that.(For some quick debugging, I usually insert
fmt.Printf("%#v\n", valueOfInterest)and runmake install, and then use~/go/bin/sops.)
Indeed, seems more likely ! Though this means there are some shenanigans going on, as my key is stored under /home/termaxima/.config/sops/age/keys.txt which is only 41 characters. Maybe in the url stripping phase ?
Maybe the path should be hashed into a known correct size UUID instead ?