Skip to content

x/crypto/openpgp: streaming encryption allocates excessively #26578

@twmb

Description

@twmb

x/crypto/openpgp.Encrypt wraps the to-be-encrypted io.Writer with a packet.seMDCWriter. This io.WriteCloser encrypts data on every write using a cipher.StreamWriter.

Every write to a cipher.StreamWriter allocates a slice as long as the input write. This is fine if we are allocating a bulk of data at once, but if we are streaming encryption over time, these allocations are unnecessary. Worse, if we are streaming through a different writer (i.e., if we stream through gzip compression), that writer may cut the writes even smaller.

We cannot add a reusable buffer to cipher.StreamWriter because all of its fields are exported. Because cipher.StreamWriter contains nearly no code in its methods, it is small enough to copy the struct and its methods into the packet package and add a reusable buffer to it. This cuts allocations significantly for streaming input and is basically a no-op for bulk encryption.

The following diff would accomplish what I am describing:

diff --git a/openpgp/packet/symmetrically_encrypted.go b/openpgp/packet/symmetrically_encrypted.go
index 6126030..03e0aa7 100644
--- a/openpgp/packet/symmetrically_encrypted.go
+++ b/openpgp/packet/symmetrically_encrypted.go
@@ -280,7 +280,7 @@ func SerializeSymmetricallyEncrypted(w io.Writer, c CipherFunction, key []byte,
        if err != nil {
                return
        }
-       plaintext := cipher.StreamWriter{S: s, W: ciphertext}
+       plaintext := &streamWriter{S: s, W: ciphertext}
 
        h := sha1.New()
        h.Write(iv)
@@ -288,3 +288,31 @@ func SerializeSymmetricallyEncrypted(w io.Writer, c CipherFunction, key []byte,
        contents = &seMDCWriter{w: plaintext, h: h}
        return
 }
+
+type streamWriter struct {
+       buf []byte
+       s   cipher.Stream
+       w   io.Writer
+}
+
+func (w *streamWriter) Write(src []byte) (n int, err error) {
+       if cap(w.buf) < len(src) {
+               w.buf = make([]byte, len(src))
+       }
+       w.buf = w.buf[:len(src)]
+       w.s.XORKeyStream(w.buf, src)
+       n, err = w.w.Write(w.buf)
+       if n != len(src) && err == nil { // should never happen
+               err = io.ErrShortWrite
+       }
+       return
+}
+
+func (w streamWriter) Close() error {
+       if c, ok := w.w.(io.Closer); ok {
+               return c.Close()
+       }
+       return nil
+}

If this is an OK idea, I would like to issue this patch. For some numbers, applying this patch to a project results in the following bench diff (names changed):

name                   old time/op    new time/op    delta
Foo/gpg=true,gzip=0-4    12.3ms ± 2%    12.1ms ± 4%     ~     (p=0.105 n=10+10)
Foo/gpg=true,gzip=1-4    18.6ms ± 1%    18.1ms ± 6%   -2.69%  (p=0.029 n=10+10)
Foo/gpg=true,gzip=9-4    46.0ms ± 1%    46.5ms ± 4%     ~     (p=0.497 n=9+10)

name                   old speed      new speed      delta
Foo/gpg=true,gzip=0-4  85.9MB/s ± 2%  87.1MB/s ± 3%     ~     (p=0.165 n=10+10)
Foo/gpg=true,gzip=1-4  56.8MB/s ± 1%  58.2MB/s ± 6%   +2.57%  (p=0.026 n=9+10)
Foo/gpg=true,gzip=9-4  22.9MB/s ± 1%  22.7MB/s ± 4%     ~     (p=0.484 n=9+10)

name                   old alloc/op   new alloc/op   delta
Foo/gpg=true,gzip=0-4    2.41MB ± 0%    1.35MB ± 0%  -43.79%  (p=0.000 n=10+9)
Foo/gpg=true,gzip=1-4    1.95MB ± 0%    1.14MB ± 0%  -41.40%  (p=0.000 n=10+10)
Foo/gpg=true,gzip=9-4    1.99MB ± 0%    1.19MB ± 0%  -39.98%  (p=0.000 n=10+10)

name                   old allocs/op  new allocs/op  delta
Foo/gpg=true,gzip=0-4       232 ± 2%        88 ± 0%  -62.15%  (p=0.000 n=10+9)
Foo/gpg=true,gzip=1-4     26.5k ± 1%      0.1k ± 0%  -99.75%  (p=0.000 n=10+9)
Foo/gpg=true,gzip=9-4     26.1k ± 0%      0.1k ± 1%  -99.76%  (p=0.000 n=10+10)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions