gognupg

How to perform process stdin substitution using exec package in golang


I am trying to perform gpg command call in golang using the exec package to create the command.

However, I would like to be able to pass the arguments as if I am using a process substitution such as this one: gpg --verify <(cat ./test.txt.sig) <(cat ./test.txt)

This command does work well, but it's using the files name and I would like to be able to use the io.Reader interface instead to pass the content of the file (i.e. the result of the cat command).

So I tried to come up with the following solution:

VerifyStreamsWithContext(ctx context.Context, sr io.Reader, fr io.Reader) (<-chan bool, <-chan error) {
    okChan := make(chan bool, 1)
    errChan := make(chan error)
    cmd := exec.CommandContext(ctx, "gpg", "--verify", "-", "-")

    r := io.MultiReader(sr, fr)
    cmd.Stdin = r

    go func() {
        defer close(okChan)
        defer close(errChan)

        out, err := cmd.CombinedOutput()
        if err != nil {
            fmt.Println(string(out), err)
            errChan <- err
            return
        }

        if strings.Contains(string(out), ValidSignatureCheck) {
            okChan <- true
        } else {
            okChan <- false
        }
    }()

    return okChan, errChan
}

However it seems like there is a problem with the content of the second stream since I am getting an error:

gpg: Signature made jeu. 16 mai 2024 09:27:28 CEST
gpg:                using RSA key xxx
gpg: BAD signature from "x x <xxx@x.com>" [ultimate]
 exit status 1
panic: exit status 1

The doc specifies that it should be possible by using "-":

--verify: Assume that the first argument is a signed file and verify it without generating any output. With no arguments, the signature packet is read from STDIN. If only one argument is given, the specified file is expected to include a complete signature. With more than one argument, the first argument should specify a file with a detached signature and the remaining files should contain the signed data. To read the signed data from STDIN, use - as the second filename. For security reasons, a detached signature will not read the signed material from STDIN if not explicitly specified.


Solution

  • As suggested by @Peter in the comments, here is the approach that use os.Pipe and file descriptor to perform process substitution:

    func (s *Signer) VerifyDetachedStreams(ctx context.Context, sr io.Reader, fr io.Reader) (<-chan error, error) {
        errChan := make(chan error)
    
        var stdout, stderr bytes.Buffer
        // /dev/fd/x represent a file descriptor that is opened while using an os.Pipe
        // 3 and 4 are determined by how cmd.ExtraFiles behaves
        cmd := exec.CommandContext(ctx, "gpg", "--verify", "/dev/fd/3", "/dev/fd/4")
        cmd.Stderr = &stderr
        cmd.Stdout = &stdout
    
        // Create a pipe to get the file descriptor /dev/fd/3
        spr, spw, err := os.Pipe()
        if err != nil {
            return nil, err
        }
        // Create a pipe to get the file descriptor /dev/fd/4
        fpr, fpw, err := os.Pipe()
        if err != nil {
            return nil, err
        }
        // Pass the file descriptors to the subprocess
        cmd.ExtraFiles = []*os.File{spr, fpr}
    
        go func() {
            _, err := io.Copy(fpw, fr)
            if err != nil {
                errChan <- err
            }
        }()
        go func() {
            _, err := io.Copy(spw, sr)
            if err != nil {
                errChan <- err
            }
        }()
    
        go func() {
            defer spr.Close()
            defer fpr.Close()
            defer close(errChan)
    
            if err := cmd.Start(); err != nil {
                errChan <- err
                return
            }
            // Important to close those else we get stuck
            spw.Close()
            fpw.Close()
    
            if err := cmd.Wait(); err != nil {
                errChan <- err
                return
            }
    
            // Dev Note: The output of the command is written on stderr
    
            errChan <- nil
        }()
    
        return errChan, nil
    }