Skip to content

gpg-passphrase request errors when trying to prompt for age key passphrase #1977

Closed

Description

@termaxima

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.

Activity

  1. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    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)

  2. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    This function is then called only here, where location is likely already erroneously set to an empty string.

  3. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    location itself contains keys from the readers map, which are set by these variables :

    • SopsAgeKeyEnv
    • SopsAgeKeyFileEnv
    • SopsAgeKeyCmdEnv
    • ageKeyFilePath

    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)

  4. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    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
    	// ...
    )
  5. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    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...

  6. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    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...

    After further investigation, this is impossible. The error messages I've shown are only produced with the correct call site for age keys.

  7. termaxima commented on Oct 17, 2025

    @termaxima
    Author

    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 !

  8. felixfontein commented on Oct 18, 2025

    @felixfontein
    Contributor

    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.)

  9. termaxima commented on Oct 20, 2025

    @termaxima
    Author

    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.)

    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 ?

  10. lysiszegerman commented on Apr 5, 2026

    @lysiszegerman

    I can see the same error message on MacOS (gpg-agent (GnuPG) 2.5.18, sops 3.12.2), and some quick testing seems to point to the length of the string pointing to keys.txt indeed.
    Without any tweaking MacOS chooses $HOME/Library/Application Support/sops/age/keys.txt, which without $HOME is already 46 characters long, so adding $HOME will always push it over the 50 characters limit. In this case I get the same error message as quited here.

    Setting export XDG_CONFIG_HOME=~/.config/ and copying sops/age/keys.txt to this location works however.

    Any way this can be fixed? It seems to break default gpg agent configuration on MacOS.

  11. lysiszegerman commented on Apr 5, 2026

    @lysiszegerman

    I can see the same error message on MacOS (gpg-agent (GnuPG) 2.5.18, sops 3.12.2), and some quick testing seems to point to the length of the string pointing to keys.txt indeed. Without any tweaking MacOS chooses $HOME/Library/Application Support/sops/age/keys.txt, which without $HOME is already 46 characters long, so adding $HOME will always push it over the 50 characters limit. In this case I get the same error message as quited here.

    Setting export XDG_CONFIG_HOME=~/.config/ and copying sops/age/keys.txt to this location works however.

    Any way this can be fixed? It seems to break default gpg agent configuration on MacOS.

    Self follow-up, as @termaxima suggested hashing the location name of the key file works. Since this is the first time I touched Go at all, I'm definitely not submitting a push request for this, but leave the crude hack I used to verify it as a diff here, for someone more competent to pick it up and add it:

    diff --git a/age/encrypted_keys.go b/age/encrypted_keys.go
    index 01faabdaa..3658454db 100644
    --- a/age/encrypted_keys.go
    +++ b/age/encrypted_keys.go
    @@ -18,6 +18,8 @@ import (
            "fmt"
            "io"
    
    +       "crypto/sha512"
    +       "encoding/hex"
            "filippo.io/age"
            "filippo.io/age/armor"
    
    @@ -108,6 +110,8 @@ func unwrapIdentities(location string, reader io.Reader) (ParsedIdentities, erro
            b := bufio.NewReader(reader)
            p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
            peeked := string(p)
    +       location_hash :=  sha512.Sum512([]byte(location))
    +       location_hash_hex := hex.EncodeToString(location_hash[:24])
    
            switch {
            // An age encrypted file, plain or armored.
    @@ -134,7 +138,7 @@ func unwrapIdentities(location string, reader io.Reader) (ParsedIdentities, erro
                                            log.Errorf("failed to close connection with gpg-agent: %s", err)
                                    }
                            }(conn)
    -                       err = conn.RemoveFromCache(location)
    +                       err = conn.RemoveFromCache(location_hash_hex)
                            if err != nil {
                                    log.Warnf("gpg-agent remove cache request errored: %s", err)
                                    return
    @@ -155,7 +159,7 @@ func unwrapIdentities(location string, reader io.Reader) (ParsedIdentities, erro
    
                                    req := gpgagent.PassphraseRequest{
                                            // TODO is the cachekey good enough?
    -                                       CacheKey: location,
    +                                       CacheKey: location_hash_hex,
                                            Prompt:   "Passphrase",
                                            Desc:     fmt.Sprintf("Enter passphrase for identity '%s':", location),
                                    }
    

    To reproduce/test:

    $ echo $XDG_CONFIG_HOME
    /Users/lysis/.config/
    $ sops edit something.sops
    (works)
    $ ln -s $XDG_CONFIG_HOME /tmp/long_filename_012345678901234567890123456789123
    $ls -la /tmp/long_filename_012345678901234567890123456789123
    lrwxr-xr-x 1 lysis wheel 21 Apr  5 17:48 /tmp/long_filename_012345678901234567890123456789123 -> /Users/lysis/.config/
    $ export XDG_CONFIG_HOME=/tmp/long_filename_012345678901234567890123456789123
    $ sops edit something.sops
    (fails)
    
  12. lysiszegerman commented on Apr 6, 2026

    @lysiszegerman

    Sorry, just saw that there might be a nicer way to do this. age/keysource.go set the full path plus filename to keys.txt as location in loadIdentities(). If we go with the same logic as for environment variables the following will work as well:

    diff --git a/age/keysource.go b/age/keysource.go
    index cdf6b1d69..b961afd6f 100644
    --- a/age/keysource.go
    +++ b/age/keysource.go
    @@ -50,6 +50,8 @@ const (
            xdgConfigHome = "XDG_CONFIG_HOME"
            // KeyTypeIdentifier is the string used to identify an age MasterKey.
            KeyTypeIdentifier = "age"
    +       // get age keys from UserConfigDir
    +       SopsUserConfigDir = "USER_CONFIG_DIR"
     )
    
     // log is the global logger for any age MasterKey.
    @@ -449,7 +451,7 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, []string, errSet) {
                            unusedLocations = append(unusedLocations, ageKeyFilePath)
                    } else if err == nil {
                            defer f.Close()
    -                       readers[ageKeyFilePath] = f
    +                       readers[SopsUserConfigDir] = f
                    }
            }
    

    I.e. do not set the full key file path, but a made up string as location.

  13. felixfontein commented on Apr 7, 2026

    @felixfontein
    Contributor

    I think using the full path as the source is helpful for the error messages generated if it can't be read.

    I've implemented a slightly different version of your idea here: #2140

    Can you give it a try?

  14. felixfontein commented on Apr 8, 2026

    @felixfontein
    Contributor

    I created a second PR to fix this: #2145

    This one uses a hashed version of the encrypted key as the cache key. That way, even if you are using different keys with SOPS_AGE_KEY_FILE or SOPS_AGE_KEY_CMD etc. for different runs, you won't have the problem of populating the cache with a password that's valid for one such key, but not the other.

  15. lysiszegerman commented on Apr 8, 2026

    @lysiszegerman

    I created a second PR to fix this: #2145

    This one uses a hashed version of the encrypted key as the cache key. That way, even if you are using different keys with SOPS_AGE_KEY_FILE or SOPS_AGE_KEY_CMD etc. for different runs, you won't have the problem of populating the cache with a password that's valid for one such key, but not the other.

    Looks good! This is working for a long path to keys.txt and it shows the full path in the pinentry dialogue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions