Initial vault: single-binary post-quantum file vault

Strip Fury back to its vault functionality in one `vault` command:
genkey, init, add (file or directory, optional folder inside the vault),
list, get, mv and rm.

The hybrid FrodoKEM-640 + ML-KEM-768 encryption core is kept under
internal/pqcrypt. Signing, policies, profiles, backups, SSH remotes,
FIDO2, agents, provenance and compression are removed.
This commit is contained in:
Andy Dixon
2026-09-26 14:05:10 +01:00
commit d95f2d04a4
26 changed files with 5689 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package format implements the fury file format.
package format
import (
"bufio"
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"strings"
)
const MaxHeaderSize = 16 << 20 // 16 MiB
const MaxStanzaSize = 1 << 20 // 1 MiB
type Header struct {
Recipients []*Stanza
MAC []byte
}
// Stanza is the serialised recipient block used in a fury header.
type Stanza struct {
Type string
Args []string
Body []byte
}
var b64 = base64.RawStdEncoding.Strict()
func DecodeString(s string) ([]byte, error) {
// CR and LF are ignored by DecodeString, but we don't want any malleability.
if strings.ContainsAny(s, "\n\r") {
return nil, errors.New(`unexpected newline character`)
}
return b64.DecodeString(s)
}
var EncodeToString = b64.EncodeToString
const ColumnsPerLine = 64
const BytesPerLine = ColumnsPerLine / 4 * 3
// NewWrappedBase64Encoder returns a WrappedBase64Encoder that writes to dst.
func NewWrappedBase64Encoder(enc *base64.Encoding, dst io.Writer) *WrappedBase64Encoder {
w := &WrappedBase64Encoder{dst: dst}
w.enc = base64.NewEncoder(enc, WriterFunc(w.writeWrapped))
return w
}
type WriterFunc func(p []byte) (int, error)
func (f WriterFunc) Write(p []byte) (int, error) { return f(p) }
// WrappedBase64Encoder is a standard base64 encoder that inserts an LF
// character every ColumnsPerLine bytes. It does not insert a newline neither at
// the beginning nor at the end of the stream, but it ensures the last line is
// shorter than ColumnsPerLine, which means it might be empty.
type WrappedBase64Encoder struct {
enc io.WriteCloser
dst io.Writer
written int
buf bytes.Buffer
}
func (w *WrappedBase64Encoder) Write(p []byte) (int, error) { return w.enc.Write(p) }
func (w *WrappedBase64Encoder) Close() error {
return w.enc.Close()
}
func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
if w.buf.Len() != 0 {
panic("fury: internal error: non-empty WrappedBase64Encoder.buf")
}
total := len(p)
for len(p) > 0 {
toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
n, _ := w.buf.Write(p[:toWrite])
w.written += n
p = p[n:]
if w.written%ColumnsPerLine == 0 {
w.buf.Write([]byte("\n"))
}
}
if _, err := w.buf.WriteTo(w.dst); err != nil {
// We always return n = 0 on error because it's hard to work back to the
// input length that ended up written out. Not ideal, but Write errors
// are not recoverable anyway.
return 0, err
}
return total, nil
}
// LastLineIsEmpty returns whether the last output line was empty, either
// because no input was written, or because a multiple of BytesPerLine was.
//
// Calling LastLineIsEmpty before Close is meaningless.
func (w *WrappedBase64Encoder) LastLineIsEmpty() bool {
return w.written%ColumnsPerLine == 0
}
const intro = "fury-encryption.org/v1\n"
var stanzaPrefix = []byte("->")
var footerPrefix = []byte("---")
func (r *Stanza) Marshal(w io.Writer) error {
if _, err := w.Write(stanzaPrefix); err != nil {
return err
}
for _, a := range append([]string{r.Type}, r.Args...) {
if _, err := io.WriteString(w, " "+a); err != nil {
return err
}
}
if _, err := io.WriteString(w, "\n"); err != nil {
return err
}
ww := NewWrappedBase64Encoder(b64, w)
if _, err := ww.Write(r.Body); err != nil {
return err
}
if err := ww.Close(); err != nil {
return err
}
_, err := io.WriteString(w, "\n")
return err
}
func (h *Header) MarshalWithoutMAC(w io.Writer) error {
if _, err := io.WriteString(w, intro); err != nil {
return err
}
for _, r := range h.Recipients {
if err := r.Marshal(w); err != nil {
return err
}
}
_, err := fmt.Fprintf(w, "%s", footerPrefix)
return err
}
func (h *Header) Marshal(w io.Writer) error {
if err := h.MarshalWithoutMAC(w); err != nil {
return err
}
mac := b64.EncodeToString(h.MAC)
_, err := fmt.Fprintf(w, " %s\n", mac)
return err
}
type StanzaReader struct {
r *bufio.Reader
err error
lastBytes int64
}
func NewStanzaReader(r *bufio.Reader) *StanzaReader {
return &StanzaReader{r: r}
}
func (r *StanzaReader) ReadStanza() (s *Stanza, err error) {
// Read errors are unrecoverable.
if r.err != nil {
return nil, r.err
}
var bytesRead int64
defer func() {
r.lastBytes = bytesRead
r.err = err
}()
s = &Stanza{}
line, err := readLimitedLine(r.r, &bytesRead, MaxStanzaSize)
if errors.Is(err, errSizeLimitExceeded) {
return nil, fmt.Errorf("stanza exceeds %d byte limit", MaxStanzaSize)
}
if err != nil {
return nil, fmt.Errorf("failed to read line: %w", err)
}
if !bytes.HasPrefix(line, stanzaPrefix) {
return nil, fmt.Errorf("malformed stanza opening line: %q", line)
}
prefix, args := splitArgs(line)
if prefix != string(stanzaPrefix) || len(args) < 1 {
return nil, fmt.Errorf("malformed stanza: %q", line)
}
for _, a := range args {
if !isValidString(a) {
return nil, fmt.Errorf("malformed stanza: %q", line)
}
}
s.Type = args[0]
s.Args = args[1:]
for {
line, err := readLimitedLine(r.r, &bytesRead, MaxStanzaSize)
if errors.Is(err, errSizeLimitExceeded) {
return nil, fmt.Errorf("stanza exceeds %d byte limit", MaxStanzaSize)
}
if err != nil {
return nil, fmt.Errorf("failed to read line: %w", err)
}
b, err := DecodeString(strings.TrimSuffix(string(line), "\n"))
if err != nil {
if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) {
return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line", line)
}
return nil, errorf("malformed body line %q: %v", line, err)
}
if len(b) > BytesPerLine {
return nil, errorf("malformed body line %q: too long", line)
}
s.Body = append(s.Body, b...)
if len(b) < BytesPerLine {
// A stanza body always ends with a short line.
return s, nil
}
}
}
var errSizeLimitExceeded = errors.New("size limit exceeded")
func readLimitedLine(r *bufio.Reader, bytesRead *int64, limit int64) ([]byte, error) {
var line []byte
for {
frag, err := r.ReadSlice('\n')
if len(frag) > 0 {
if limit > 0 && *bytesRead+int64(len(frag)) > limit {
return nil, errSizeLimitExceeded
}
*bytesRead += int64(len(frag))
line = append(line, frag...)
}
if err == nil {
return line, nil
}
if err != bufio.ErrBufferFull {
return nil, err
}
}
}
type ParseError struct {
err error
}
func (e *ParseError) Error() string {
return "parsing fury header: " + e.err.Error()
}
func (e *ParseError) Unwrap() error {
return e.err
}
func errorf(format string, a ...any) error {
return &ParseError{fmt.Errorf(format, a...)}
}
// Parse returns the header and a Reader that begins at the start of the
// payload.
func Parse(input io.Reader) (*Header, io.Reader, error) {
h := &Header{}
rr := bufio.NewReader(input)
var headerBytes int64
lineBytes, err := readLimitedLine(rr, &headerBytes, MaxHeaderSize)
line := string(lineBytes)
if errors.Is(err, errSizeLimitExceeded) {
return nil, nil, errorf("header exceeds %d byte limit", MaxHeaderSize)
}
if err == io.EOF {
return nil, nil, errorf("file is empty")
} else if err != nil {
return nil, nil, errorf("failed to read intro: %w", err)
}
if line != intro {
return nil, nil, errorf("unexpected intro: %q", line)
}
sr := NewStanzaReader(rr)
for {
peek, err := rr.Peek(len(footerPrefix))
if err != nil {
return nil, nil, errorf("failed to read header: %w", err)
}
if bytes.Equal(peek, footerPrefix) {
line, err := readLimitedLine(rr, &headerBytes, MaxHeaderSize)
if errors.Is(err, errSizeLimitExceeded) {
return nil, nil, errorf("header exceeds %d byte limit", MaxHeaderSize)
}
if err != nil {
return nil, nil, fmt.Errorf("failed to read header: %w", err)
}
prefix, args := splitArgs(line)
if prefix != string(footerPrefix) || len(args) != 1 {
return nil, nil, errorf("malformed closing line: %q", line)
}
h.MAC, err = DecodeString(args[0])
if err != nil || len(h.MAC) != 32 {
return nil, nil, errorf("malformed closing line %q: %v", line, err)
}
break
}
s, err := sr.ReadStanza()
if err != nil {
return nil, nil, fmt.Errorf("failed to parse header: %w", err)
}
headerBytes += sr.lastBytes
if headerBytes > MaxHeaderSize {
return nil, nil, errorf("header exceeds %d byte limit", MaxHeaderSize)
}
h.Recipients = append(h.Recipients, s)
}
// If input is a bufio.Reader, rr might be equal to input because
// bufio.NewReader short-circuits. In this case we can just return it (and
// we would end up reading the buffer twice if we prepended the peek below).
if rr == input {
return h, rr, nil
}
// Otherwise, unwind the bufio overread and return the unbuffered input.
buf, err := rr.Peek(rr.Buffered())
if err != nil {
return nil, nil, errorf("internal error: %v", err)
}
payload := io.MultiReader(bytes.NewReader(buf), input)
return h, payload, nil
}
func splitArgs(line []byte) (string, []string) {
l := strings.TrimSuffix(string(line), "\n")
parts := strings.Split(l, " ")
return parts[0], parts[1:]
}
func isValidString(s string) bool {
if len(s) == 0 {
return false
}
for _, c := range s {
if c < 33 || c > 126 {
return false
}
}
return true
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package format_test
import (
"bufio"
"bytes"
"io"
"testing"
"github.com/andydixon/vault/internal/format"
)
func TestStanzaMarshal(t *testing.T) {
s := &format.Stanza{
Type: "test",
Args: []string{"1", "2", "3"},
Body: nil, // empty
}
buf := &bytes.Buffer{}
s.Marshal(buf)
if exp := "-> test 1 2 3\n\n"; buf.String() != exp {
t.Errorf("wrong empty stanza encoding: expected %q, got %q", exp, buf.String())
}
buf.Reset()
s.Body = []byte("AAA")
s.Marshal(buf)
if exp := "-> test 1 2 3\nQUFB\n"; buf.String() != exp {
t.Errorf("wrong normal stanza encoding: expected %q, got %q", exp, buf.String())
}
buf.Reset()
s.Body = bytes.Repeat([]byte("A"), format.BytesPerLine)
s.Marshal(buf)
if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp {
t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String())
}
}
func FuzzMalleability(f *testing.F) {
f.Fuzz(func(t *testing.T, data []byte) {
h, payload, err := format.Parse(bytes.NewReader(data))
if err != nil {
if h != nil {
t.Error("h != nil on error")
}
if payload != nil {
t.Error("payload != nil on error")
}
t.Skip()
}
w := &bytes.Buffer{}
if err := h.Marshal(w); err != nil {
t.Fatal(err)
}
if _, err := io.Copy(w, payload); err != nil {
t.Fatal(err)
}
if !bytes.Equal(w.Bytes(), data) {
t.Error("Marshal output different from input")
}
})
}
// FuzzStanza exercises the recipient-stanza reader directly. Parse reaches it
// only through a well-formed intro line, so fuzzing it on its own covers
// stanza shapes a whole-file seed cannot easily reach.
func FuzzStanza(f *testing.F) {
f.Fuzz(func(t *testing.T, data []byte) {
reader := format.NewStanzaReader(bufio.NewReader(bytes.NewReader(data)))
for range 64 {
stanza, err := reader.ReadStanza()
if err != nil {
if stanza != nil {
t.Fatal("stanza returned alongside an error")
}
return
}
if stanza == nil {
t.Fatal("no stanza and no error")
}
// Parser limits must hold: nothing an attacker supplies may make
// the reader materialise more than the stanza size limit.
if len(stanza.Body) > format.MaxStanzaSize {
t.Fatalf("stanza body of %d bytes exceeds the %d byte limit", len(stanza.Body), format.MaxStanzaSize)
}
}
})
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package format
import (
"bufio"
"bytes"
"strings"
"testing"
)
func TestStanzaReaderRejectsOversizedStanza(t *testing.T) {
var input bytes.Buffer
input.WriteString("-> test ")
input.WriteString(strings.Repeat("a", MaxStanzaSize))
input.WriteString("\n\n")
sr := NewStanzaReader(bufio.NewReader(&input))
if _, err := sr.ReadStanza(); err == nil {
t.Fatal("ReadStanza accepted an oversized stanza")
} else if !strings.Contains(err.Error(), "stanza exceeds") {
t.Fatalf("ReadStanza error = %v, want stanza size limit error", err)
}
}
func TestParseRejectsOversizedHeader(t *testing.T) {
var input bytes.Buffer
input.WriteString(intro)
for input.Len() <= MaxHeaderSize {
input.WriteString("-> test ")
input.WriteString(strings.Repeat("a", MaxStanzaSize-1024))
input.WriteString("\n\n")
}
input.WriteString("--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n")
if _, _, err := Parse(&input); err == nil {
t.Fatal("Parse accepted an oversized header")
} else if !strings.Contains(err.Error(), "header exceeds") {
t.Fatalf("Parse error = %v, want header size limit error", err)
}
}
+405
View File
@@ -0,0 +1,405 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Package fury implements post-quantum file encryption. It provides a
// post-quantum-only file format, hybrid KEM key agreement, and a 256-bit file key.
//
// For most use cases, generate a [PQIdentity] with [GeneratePQIdentity], obtain
// its public [PQRecipient] with [PQIdentity.Recipient], and use the [Encrypt]
// and [Decrypt] functions.
//
// fury encrypted files are binary and not malleable. For encoding them as text,
// use the github.com/andydixon/fury/armor package.
//
// # Cryptographic agility
//
// Key agreement is provided by registered suites (see the suite registry).
// Each suite embeds its identifier in the file, so files remain decryptable as
// newer, stronger suites are added and made the default. The default suite is a
// hybrid of FrodoKEM-640 and ML-KEM-768: the file key stays secret unless both
// KEMs are broken.
//
// # Key management
//
// fury does not have a global keyring; generate dedicated keys per task. Public
// recipients are shared as "fury1..." strings (or, being large, as files), or
// referenced by a short "furyfp1..." fingerprint. Private identities are always
// encrypted, with passphrase recovery and optional external unlock slots; there
// is no API to serialise an unencrypted private key.
package pqcrypt
import (
"crypto/hmac"
"crypto/rand"
"errors"
"fmt"
"io"
"runtime"
"slices"
"sort"
"github.com/andydixon/vault/internal/format"
"github.com/andydixon/vault/internal/stream"
)
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
// plugin, or a custom implementation.
type Identity interface {
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
// the recipient stanzas match the identity, any other error will be
// considered fatal.
//
// Most fury API users won't need to interact with this method directly, and
// should instead pass [Identity] implementations to [Decrypt].
Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
}
// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
// recipient stanzas match the identity.
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
// a plugin, or a custom implementation.
type Recipient interface {
// Most fury API users won't need to interact with this method directly, and
// should instead pass [Recipient] implementations to [Encrypt].
Wrap(fileKey []byte) ([]*Stanza, error)
}
// RecipientWithLabels can be optionally implemented by a [Recipient], in which
// case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
//
// Encrypt will succeed only if the labels returned by all the recipients
// (assuming the empty set for those that don't implement RecipientWithLabels)
// are the same.
//
// This can be used to ensure a recipient is only used with other recipients
// with equivalent properties (for example by setting a "postquantum" label) or
// to ensure a recipient is always used alone (by returning a random label, for
// example to preserve its authentication properties).
type RecipientWithLabels interface {
WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error)
}
// A Stanza is a section of the fury header that encapsulates the file key as
// encrypted to a specific recipient.
//
// Most fury API users won't need to interact with this type directly, and should
// instead pass [Recipient] implementations to [Encrypt] and [Identity]
// implementations to [Decrypt].
type Stanza struct {
Type string
Args []string
Body []byte
}
// fileKeySize is 32 bytes (256-bit) so the symmetric layer's post-quantum
// (Grover) security floor of ~128 bits matches the KEMs, rather than the 64-bit
// floor a 128-bit file key would impose. See suite.go and the payload STREAM.
const fileKeySize = 32
const streamNonceSize = 16
// maxAutoWorkers bounds the automatic worker count for parallel STREAM
// chunk encryption/decryption. ChaCha20-Poly1305 is memory-bandwidth-bound
// past a modest number of cores, so an unbounded runtime.NumCPU() on very
// large machines would just add goroutine/channel overhead for no benefit.
const maxAutoWorkers = 32
// MaxWorkers is the largest value [Options.Workers] will accept.
//
// The limit is set by what a worker actually costs, not by taste. Each worker
// may have two chunks in flight (see the stream package's pipeline depth), and
// a chunk is 64 KiB of plaintext plus its ciphertext buffer, so the pipeline
// holds roughly 256 KiB per worker: about 256 MiB at this limit, plus 1024
// goroutines and channels sized to match. That is already far past the point
// where throughput stops improving, and comfortably above the thread count of
// the largest machines currently shipping. A request beyond it is a
// configuration error rather than a tuning choice, so Fury rejects it instead
// of quietly attempting it or silently substituting another number.
const MaxWorkers = 1024
// Options configures [EncryptWithOptions] and [DecryptWithOptions] beyond
// the defaults used by [Encrypt] and [Decrypt].
type Options struct {
// Workers controls how many goroutines seal or open STREAM chunks in
// parallel. STREAM chunk nonces are a pure counter independent of chunk
// content, so parallelising this work never changes the resulting
// ciphertext or plaintext, only how fast it's produced.
//
// Valid values are 0 to [MaxWorkers]. Zero (the default) means
// automatic: runtime.NumCPU(), capped at a value past which
// ChaCha20-Poly1305 stops getting faster. One forces the sequential
// code path. A negative value, or one above MaxWorkers, is rejected.
Workers int
}
// ErrInvalidWorkers is returned when [Options.Workers] is outside 0 to
// [MaxWorkers].
var ErrInvalidWorkers = errors.New("fury: worker count out of range")
// Validate reports whether the options are usable. Every entry point that
// accepts [Options] calls it, so an out-of-range worker count is refused by
// the library itself and not only by the command-line tool.
func (o Options) Validate() error {
if o.Workers < 0 {
return fmt.Errorf("%w: %d is negative", ErrInvalidWorkers, o.Workers)
}
if o.Workers > MaxWorkers {
return fmt.Errorf("%w: %d exceeds the maximum of %d", ErrInvalidWorkers, o.Workers, MaxWorkers)
}
return nil
}
func (o Options) workers() int {
if o.Workers > 0 {
return o.Workers
}
if n := runtime.NumCPU(); n < maxAutoWorkers {
return n
}
return maxAutoWorkers
}
// encryptHdr wraps a file key to every recipient and authenticates the result.
//
// INVARIANT (no silent downgrade). All recipients must return identical label
// sets. A recipient that does not implement [RecipientWithLabels] contributes
// the empty set, so it can never be mixed with a "postquantum" recipient. Any
// future recipient type with weaker properties fails closed rather than
// quietly lowering the security of the whole file.
func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
if len(recipients) == 0 {
return nil, errors.New("no recipients specified")
}
hdr := &format.Header{}
var labels []string
for i, r := range recipients {
stanzas, l, err := wrapWithLabels(r, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
}
sort.Strings(l)
if i == 0 {
labels = l
} else if !slices.Equal(labels, l) {
return nil, incompatibleLabelsError(labels, l)
}
for _, s := range stanzas {
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
}
}
if mac, err := headerMAC(fileKey, hdr); err != nil {
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
} else {
hdr.MAC = mac
}
return hdr, nil
}
// Encrypt encrypts a file to one or more recipients. Every recipient will be
// able to decrypt the file.
//
// Writes to the returned WriteCloser are encrypted and written to dst as a fury
// file. The caller must call Close on the WriteCloser when done for the last
// chunk to be encrypted and flushed to dst.
//
// The payload is sealed using multiple goroutines automatically; see
// [EncryptWithOptions] to control that.
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
return EncryptWithOptions(dst, Options{}, recipients...)
}
// EncryptWithOptions is like [Encrypt], with explicit [Options].
//
// INVARIANT (fresh file key). Every call generates a new 32-byte file key
// from crypto/rand. Nothing derives a file key from the plaintext, the
// recipients or a counter, so no two encryptions share one.
func EncryptWithOptions(dst io.Writer, opts Options, recipients ...Recipient) (io.WriteCloser, error) {
if err := opts.Validate(); err != nil {
return nil, err
}
fileKey := make([]byte, fileKeySize)
if _, err := rand.Read(fileKey); err != nil {
return nil, fmt.Errorf("failed to generate file key: %w", err)
}
defer wipe(fileKey)
hdr, err := encryptHdr(fileKey, recipients...)
if err != nil {
return nil, err
}
if err := hdr.Marshal(dst); err != nil {
return nil, fmt.Errorf("failed to write header: %w", err)
}
nonce := make([]byte, streamNonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
if _, err := dst.Write(nonce); err != nil {
return nil, fmt.Errorf("failed to write nonce: %w", err)
}
key := streamKey(fileKey, nonce)
w, err := stream.NewParallelEncryptWriter(key, dst, opts.workers())
wipe(key)
return w, err
}
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
if r, ok := r.(RecipientWithLabels); ok {
return r.WrapWithLabels(fileKey)
}
s, err = r.Wrap(fileKey)
return
}
func incompatibleLabelsError(l1, l2 []string) error {
hasPQ1 := slices.Contains(l1, "postquantum")
hasPQ2 := slices.Contains(l2, "postquantum")
if hasPQ1 != hasPQ2 {
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
}
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
}
// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
// identities match the encrypted file.
type NoIdentityMatchError struct {
// Errors is a slice of all the errors returned to Decrypt by the Unwrap
// calls it made. They all wrap [ErrIncorrectIdentity].
Errors []error
// StanzaTypes are the first argument of each recipient stanza in the
// encrypted file's header.
StanzaTypes []string
}
func (e *NoIdentityMatchError) Error() string {
if len(e.Errors) == 1 {
return "identity did not match any of the recipients: " + e.Errors[0].Error()
}
return "no identity matched any of the recipients"
}
// Decrypt decrypts a file encrypted to one or more identities.
// All identities will be tried until one successfully decrypts the file.
// Native, non-interactive identities are tried before any other identities.
//
// Decrypt returns a Reader reading the decrypted plaintext of the fury file read
// from src. If no identity matches the encrypted file, the returned error will
// be of type [NoIdentityMatchError].
//
// The payload is opened using multiple goroutines automatically; see
// [DecryptWithOptions] to control that.
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
return DecryptWithOptions(src, Options{}, identities...)
}
// DecryptWithOptions is like [Decrypt], with explicit [Options].
func DecryptWithOptions(src io.Reader, opts Options, identities ...Identity) (io.Reader, error) {
if err := opts.Validate(); err != nil {
return nil, err
}
hdr, payload, err := format.Parse(src)
if err != nil {
return nil, fmt.Errorf("failed to read header: %w", err)
}
fileKey, err := decryptHdr(hdr, identities...)
if err != nil {
return nil, err
}
defer wipe(fileKey)
nonce := make([]byte, streamNonceSize)
if _, err := io.ReadFull(payload, nonce); err != nil {
return nil, fmt.Errorf("failed to read nonce: %w", err)
}
key := streamKey(fileKey, nonce)
r, err := stream.NewParallelDecryptReader(key, payload, opts.workers())
wipe(key)
return r, err
}
func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
if len(identities) == 0 {
return nil, errors.New("no identities specified")
}
slices.SortStableFunc(identities, func(a, b Identity) int {
var aIsNative, bIsNative bool
switch a.(type) {
case *PQIdentity:
aIsNative = true
}
switch b.(type) {
case *PQIdentity:
bIsNative = true
}
if aIsNative && !bIsNative {
return -1
}
if !aIsNative && bIsNative {
return 1
}
return 0
})
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
errNoMatch := &NoIdentityMatchError{}
for _, s := range hdr.Recipients {
errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
stanzas = append(stanzas, (*Stanza)(s))
}
var fileKey []byte
// The unwrapped key is copied out below; clear this copy on every exit
// path so it does not outlive the call. A closure is required: fileKey is
// assigned inside the loop, and a bare defer would capture today's nil.
defer func() { wipe(fileKey) }()
for _, id := range identities {
var err error
fileKey, err = id.Unwrap(stanzas)
if errors.Is(err, ErrIncorrectIdentity) {
errNoMatch.Errors = append(errNoMatch.Errors, err)
continue
}
if err != nil {
return nil, err
}
break
}
if fileKey == nil {
return nil, errNoMatch
}
if mac, err := headerMAC(fileKey, hdr); err != nil {
return nil, fmt.Errorf("failed to compute header MAC: %v", err)
} else if !hmac.Equal(mac, hdr.MAC) {
return nil, errors.New("bad header MAC")
}
return append([]byte(nil), fileKey...), nil
}
// multiUnwrap is a helper that implements Identity.Unwrap in terms of a
// function that unwraps a single recipient stanza.
func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byte, error) {
for _, s := range stanzas {
fileKey, err := unwrap(s)
if errors.Is(err, ErrIncorrectIdentity) {
// If we ever start returning something interesting wrapping
// ErrIncorrectIdentity, we should let it make its way up through
// Decrypt into NoIdentityMatchError.Errors.
continue
}
if err != nil {
return nil, err
}
return fileKey, nil
}
return nil, ErrIncorrectIdentity
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"testing"
)
// TestEncryptWithOptionsRoundTrip exercises the parallel STREAM path end to
// end, at a size spanning many chunks, across a few explicit worker counts
// (including 1, which forces the original sequential code path).
func TestEncryptWithOptionsRoundTrip(t *testing.T) {
id, err := GeneratePQIdentity()
if err != nil {
t.Fatal(err)
}
r := id.Recipient()
plaintext := make([]byte, 4<<20) // 4 MiB: many STREAM chunks
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
for _, workers := range []int{1, 2, 8} {
t.Run(fmt.Sprintf("workers=%d", workers), func(t *testing.T) {
buf := &bytes.Buffer{}
w, err := EncryptWithOptions(buf, Options{Workers: workers}, r)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
out, err := DecryptWithOptions(bytes.NewReader(buf.Bytes()), Options{Workers: workers}, id)
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plaintext) {
t.Fatal("round-trip mismatch")
}
})
}
}
func TestOptionsWorkers(t *testing.T) {
if got := (Options{}).workers(); got <= 0 {
t.Fatalf("default workers = %d, want > 0", got)
}
if got := (Options{Workers: 3}).workers(); got != 3 {
t.Fatalf("explicit workers = %d, want 3", got)
}
// An explicit request is honoured even above the automatic cap; only the
// zero-value automatic path is capped.
if got := (Options{Workers: 1000}).workers(); got != 1000 {
t.Fatalf("explicit workers = %d, want 1000", got)
}
}
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"io"
"github.com/andydixon/vault/internal/format"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/hkdf"
)
func wipe(b []byte) {
for i := range b {
b[i] = 0
}
}
// aeadEncrypt encrypts a message with a one-time key.
//
// INVARIANT (fixed nonce). The all-zero nonce below is safe only because
// every key reaching this function is a fresh, unique, single-use derived
// KEK. It is not a shortcut and must not be reused anywhere that condition
// does not hold: repeating a (key, nonce) pair under ChaCha20-Poly1305
// destroys both confidentiality and authenticity.
//
// The condition holds today because aeadEncrypt has exactly one caller, the
// default suite's wrap, whose KEK is
// HKDF-SHA-256(ss1 || ss2, info = label || ct1 || ct2). Both KEM
// encapsulations are performed freshly for each wrap, so ss1, ss2, ct1 and
// ct2 differ for every call even when the same file key is wrapped to the
// same recipient twice. Any new caller must establish the same property, or
// carry its own nonce.
//
// For the code that encrypts the actual payload, which uses a counter nonce
// rather than a fixed one, see the
// github.com/andydixon/vault/internal/stream package.
func aeadEncrypt(key, plaintext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Seal(nil, nonce, plaintext, nil), nil
}
var errIncorrectCiphertextSize = errors.New("encrypted value has unexpected length")
// aeadDecrypt decrypts a message of an expected fixed size.
//
// The message size is limited to mitigate multi-key attacks, where a ciphertext
// can be crafted that decrypts successfully under multiple keys. Short
// ciphertexts can only target two keys, which has limited impact.
func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
if len(ciphertext) != size+aead.Overhead() {
return nil, errIncorrectCiphertextSize
}
nonce := make([]byte, chacha20poly1305.NonceSize)
return aead.Open(nil, nonce, ciphertext, nil)
}
// headerMAC computes the header authentication tag.
//
// INVARIANT (header authentication). The MAC covers the complete header
// except the MAC line itself: the intro line, every recipient stanza in
// order, and the footer prefix. Nothing that a decryptor acts upon is left
// unauthenticated, so a stanza cannot be added, removed, reordered or edited
// without detection by anyone who can recover the file key.
//
// INVARIANT (domain separation). The MAC key and the payload key are both
// HKDF-SHA-256 expansions of the same file key under distinct info strings
// ("header" and "payload"), so neither can be substituted for the other.
func headerMAC(fileKey []byte, hdr *format.Header) ([]byte, error) {
h := hkdf.New(sha256.New, fileKey, nil, []byte("header"))
hmacKey := make([]byte, 32)
if _, err := io.ReadFull(h, hmacKey); err != nil {
wipe(hmacKey)
return nil, err
}
// The MAC key is derived per call and is of no use once the hash is
// keyed, so it does not need to outlive this function.
defer wipe(hmacKey)
hh := hmac.New(sha256.New, hmacKey)
if err := hdr.MarshalWithoutMAC(hh); err != nil {
return nil, err
}
return hh.Sum(nil), nil
}
// streamKey derives the payload key.
//
// INVARIANT (payload key uniqueness). The 16-byte file nonce is fresh
// randomness written into every file and used here as the HKDF salt, so two
// encryptions never share a payload key even if a file key were somehow
// repeated. Combined with the STREAM counter nonce, no (key, nonce) pair is
// ever reused.
func streamKey(fileKey, nonce []byte) []byte {
h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload"))
streamKey := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(h, streamKey); err != nil {
panic("fury: internal error: failed to read from HKDF: " + err.Error())
}
return streamKey
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"crypto/rand"
"errors"
"fmt"
)
// A pqSuite is a registered post-quantum KEM suite. Each suite wraps the file
// key to a recipient and unwraps it with the corresponding identity. Suites are
// self-describing: their stanza tag is written into the header, so a file made
// with one suite keeps decrypting even after newer suites are added and made the
// default. This is the "use the best now, add better later, still decrypt older"
// property.
//
// Recipient public keys and identity private keys are opaque byte slices whose
// layout is defined by the suite. Identities are derived deterministically from
// a compact master seed so they can be stored and passphrase-wrapped cheaply.
type pqSuite interface {
// id is a stable numeric identifier embedded in recipient encodings.
id() uint16
// tag is the header stanza Type for this suite.
tag() string
// rank orders suites when picking a default; higher wins.
rank() int
// seedSize is the length of the master seed accepted by deriveKeypair.
seedSize() int
// deriveKeypair deterministically expands a master seed into a suite public
// key and private key.
deriveKeypair(seed []byte) (pub, priv []byte, err error)
// wrap encapsulates fileKey to pub, returning one or more header stanzas.
wrap(pub, fileKey []byte) ([]*Stanza, error)
// unwrap recovers fileKey from a stanza using priv. It must return an error
// wrapping ErrIncorrectIdentity if the stanza does not match.
unwrap(priv []byte, s *Stanza) ([]byte, error)
// publicKeySize is the expected length of a suite public key.
publicKeySize() int
}
var (
pqSuitesByTag = map[string]pqSuite{}
pqSuitesByID = map[uint16]pqSuite{}
)
// registerSuite adds a suite to the registry. It is called from suite files'
// init functions.
func registerSuite(s pqSuite) {
if _, ok := pqSuitesByTag[s.tag()]; ok {
panic("fury: duplicate suite tag " + s.tag())
}
if _, ok := pqSuitesByID[s.id()]; ok {
panic(fmt.Sprintf("fury: duplicate suite id %#04x", s.id()))
}
pqSuitesByTag[s.tag()] = s
pqSuitesByID[s.id()] = s
}
// defaultSuite returns the highest-ranked registered suite, used for key
// generation and encryption.
func defaultSuite() pqSuite {
var best pqSuite
for _, s := range pqSuitesByTag {
if best == nil || s.rank() > best.rank() {
best = s
}
}
if best == nil {
panic("fury: no PQ suites registered")
}
return best
}
// PQRecipient is a post-quantum public key. Messages encrypted to it can be
// decrypted with the corresponding [PQIdentity].
//
// It is anonymous: an observer can't tell from a message alone whether it is
// encrypted to a given recipient.
type PQRecipient struct {
suite pqSuite
pub []byte
}
var _ Recipient = &PQRecipient{}
var _ RecipientWithLabels = &PQRecipient{}
func (r *PQRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
return r.suite.wrap(r.pub, fileKey)
}
// WrapWithLabels returns a "postquantum" label so a PQRecipient can only be
// combined with other post-quantum recipients, never with a recipient type that
// would defeat its security.
func (r *PQRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
s, err := r.Wrap(fileKey)
if err != nil {
return nil, nil, err
}
return s, []string{"postquantum"}, nil
}
// SuiteID returns the numeric identifier of the recipient's suite.
func (r *PQRecipient) SuiteID() uint16 { return r.suite.id() }
// Bytes returns the raw suite public key.
func (r *PQRecipient) Bytes() []byte { return append([]byte(nil), r.pub...) }
// PQIdentity is a post-quantum private key, derived from a compact master seed,
// which can decrypt messages encrypted to the corresponding [PQRecipient].
type PQIdentity struct {
suite pqSuite
seed []byte
pub []byte
priv []byte
}
var _ Identity = &PQIdentity{}
// GeneratePQIdentity randomly generates a new identity using the default suite.
func GeneratePQIdentity() (*PQIdentity, error) {
s := defaultSuite()
seed := make([]byte, s.seedSize())
if _, err := rand.Read(seed); err != nil {
return nil, fmt.Errorf("failed to generate seed: %w", err)
}
id, err := newPQIdentityFromSeed(s, seed)
wipe(seed)
return id, err
}
// newPQIdentityFromSeed derives an identity from a suite and master seed.
func newPQIdentityFromSeed(s pqSuite, seed []byte) (*PQIdentity, error) {
if len(seed) != s.seedSize() {
return nil, fmt.Errorf("invalid seed size %d for suite %s", len(seed), s.tag())
}
seed = append([]byte(nil), seed...)
pub, priv, err := s.deriveKeypair(seed)
if err != nil {
return nil, fmt.Errorf("failed to derive keypair: %w", err)
}
return &PQIdentity{suite: s, seed: seed, pub: pub, priv: priv}, nil
}
// usable reports whether the identity still holds its secret material. A
// closed identity, and one that was never fully initialised, both report
// false rather than panicking on a nil slice.
func (i *PQIdentity) usable() bool {
return i != nil && i.suite != nil && len(i.seed) == i.suite.seedSize() && len(i.priv) != 0
}
// ErrIdentityClosed is returned by operations on an identity whose secret
// material has been cleared by Close.
var ErrIdentityClosed = errors.New("fury identity is closed or uninitialised")
// Close overwrites the secret material this identity owns: its master seed
// and its expanded private key. Repeated calls are safe, and a partially
// initialised identity closes without panicking. The public key is retained,
// so [PQIdentity.Recipient] keeps working on a closed identity.
//
// Explicit overwriting shortens the lifetime of buffers Fury knows about. It
// cannot guarantee removal from physical memory: the Go compiler and runtime
// may have copied a value during allocation, garbage collection, stack growth
// or escape analysis, and Fury has no way to find or clear those copies. Treat
// this as defence in depth, not as erasure.
func (i *PQIdentity) Close() {
if i == nil {
return
}
wipe(i.seed)
wipe(i.priv)
i.seed, i.priv = nil, nil
}
func (i *PQIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
if !i.usable() {
return nil, ErrIdentityClosed
}
return multiUnwrap(i.unwrap, stanzas)
}
func (i *PQIdentity) unwrap(s *Stanza) ([]byte, error) {
su, ok := pqSuitesByTag[s.Type]
if !ok || su.tag() != i.suite.tag() {
return nil, ErrIncorrectIdentity
}
fileKey, err := su.unwrap(i.priv, s)
if err != nil {
return nil, err
}
// Enforce the file key length centrally, so no future suite can feed a
// wrong-sized key into the payload STREAM by forgetting its own check.
if len(fileKey) != fileKeySize {
return nil, fmt.Errorf("suite %s returned a file key of %d bytes, want %d", su.tag(), len(fileKey), fileKeySize)
}
return fileKey, nil
}
// Recipient returns the public [PQRecipient] corresponding to i. It remains
// valid after [PQIdentity.Close], because a public key is not secret.
func (i *PQIdentity) Recipient() *PQRecipient {
return &PQRecipient{suite: i.suite, pub: append([]byte(nil), i.pub...)}
}
// Seed returns a copy of the compact master seed, or nil if the identity is
// closed.
//
// The returned slice is secret-equivalent: it regenerates both the private and
// the public key, so anything that can read it can decrypt everything the
// identity can. It must never be shared as a recipient, written unencrypted,
// logged, or passed to a subprocess.
//
// The copy is deliberate: it is never an alias into the identity's own buffer,
// so closing the identity does not clear it. From the moment it is returned it
// is the caller's responsibility to wipe it when finished and to keep its
// lifetime short.
func (i *PQIdentity) Seed() []byte {
if !i.usable() {
return nil
}
return append([]byte(nil), i.seed...)
}
// SuiteID returns the numeric identifier of the identity's suite.
func (i *PQIdentity) SuiteID() uint16 { return i.suite.id() }
// NewPQIdentityFromSeed derives an identity of the given suite from its master
// seed, as returned by [PQIdentity.Seed].
func NewPQIdentityFromSeed(suiteID uint16, seed []byte) (*PQIdentity, error) {
s, ok := pqSuitesByID[suiteID]
if !ok {
return nil, fmt.Errorf("unknown suite id %#04x", suiteID)
}
return newPQIdentityFromSeed(s, seed)
}
+215
View File
@@ -0,0 +1,215 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"slices"
"github.com/andydixon/vault/internal/format"
"github.com/cloudflare/circl/kem"
"github.com/cloudflare/circl/kem/frodo/frodo640shake"
"github.com/cloudflare/circl/kem/mlkem/mlkem768"
"golang.org/x/crypto/hkdf"
)
// frodoMLKEM768 is the default post-quantum suite: a hybrid of FrodoKEM-640
// (unstructured-lattice) and ML-KEM-768 (module-lattice). The file key stays
// secret unless both KEMs are broken. It is pure Go, so fury keeps building as a
// static binary with no cgo. (A code-based suite such as HQC or Classic McEliece
// via liboqs can be registered later with a higher rank; see suite.go.)
type frodoMLKEM768 struct{}
func init() { registerSuite(frodoMLKEM768{}) }
func (frodoMLKEM768) id() uint16 { return 0x0001 }
func (frodoMLKEM768) tag() string { return "frodo640shake+mlkem768" }
func (frodoMLKEM768) rank() int { return 10 }
func (frodoMLKEM768) seedSize() int { return 32 }
func (frodoMLKEM768) schemes() (frodo, ml kem.Scheme) {
return frodo640shake.Scheme(), mlkem768.Scheme()
}
func (s frodoMLKEM768) publicKeySize() int {
frodo, ml := s.schemes()
return frodo.PublicKeySize() + ml.PublicKeySize()
}
// deriveKeypair expands a 32-byte master seed into per-KEM seeds via HKDF, then
// derives each keypair deterministically. The public key is frodoPub||mlkemPub
// and the private key is frodoPriv||mlkemPriv.
func (s frodoMLKEM768) deriveKeypair(seed []byte) (pub, priv []byte, err error) {
frodo, ml := s.schemes()
r := hkdf.New(sha256.New, seed, nil, []byte("fury/v1 seed-expand "+s.tag()))
frodoSeed := make([]byte, frodo.SeedSize())
mlSeed := make([]byte, ml.SeedSize())
// The per-KEM seeds regenerate the private keys, so they are as sensitive
// as the master seed and are cleared as soon as the keys are derived.
defer wipe(frodoSeed)
defer wipe(mlSeed)
if _, err := io.ReadFull(r, frodoSeed); err != nil {
return nil, nil, err
}
if _, err := io.ReadFull(r, mlSeed); err != nil {
return nil, nil, err
}
fpk, fsk := frodo.DeriveKeyPair(frodoSeed)
mpk, msk := ml.DeriveKeyPair(mlSeed)
fpkb, err := fpk.MarshalBinary()
if err != nil {
return nil, nil, err
}
mpkb, err := mpk.MarshalBinary()
if err != nil {
return nil, nil, err
}
fskb, err := fsk.MarshalBinary()
if err != nil {
return nil, nil, err
}
defer wipe(fskb)
mskb, err := msk.MarshalBinary()
if err != nil {
return nil, nil, err
}
defer wipe(mskb)
// fpkb and mpkb are public key bytes; wiping them would be theatre.
return slices.Concat(fpkb, mpkb), slices.Concat(fskb, mskb), nil
}
// wrap encapsulates to both KEMs and seals the file key under the combined
// KEK.
//
// INVARIANT (fresh encapsulation). Both Encapsulate calls draw fresh
// randomness on every invocation. Wrapping the same file key to the same
// recipient twice therefore produces two independent KEKs, never a repeat.
func (s frodoMLKEM768) wrap(pub, fileKey []byte) ([]*Stanza, error) {
frodo, ml := s.schemes()
if len(pub) != s.publicKeySize() {
return nil, fmt.Errorf("invalid %s public key size", s.tag())
}
fpk, err := frodo.UnmarshalBinaryPublicKey(pub[:frodo.PublicKeySize()])
if err != nil {
return nil, fmt.Errorf("invalid FrodoKEM public key: %v", err)
}
mpk, err := ml.UnmarshalBinaryPublicKey(pub[frodo.PublicKeySize():])
if err != nil {
return nil, fmt.Errorf("invalid ML-KEM public key: %v", err)
}
ct1, ss1, err := frodo.Encapsulate(fpk)
if err != nil {
return nil, fmt.Errorf("FrodoKEM encapsulation failed: %v", err)
}
ct2, ss2, err := ml.Encapsulate(mpk)
if err != nil {
return nil, fmt.Errorf("ML-KEM encapsulation failed: %v", err)
}
kek := s.deriveKEK(ss1, ss2, ct1, ct2)
defer wipe(ss1)
defer wipe(ss2)
defer wipe(kek)
body, err := aeadEncrypt(kek, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to wrap file key: %v", err)
}
return []*Stanza{{
Type: s.tag(),
Args: []string{format.EncodeToString(ct1), format.EncodeToString(ct2)},
Body: body,
}}, nil
}
func (s frodoMLKEM768) unwrap(priv []byte, st *Stanza) ([]byte, error) {
frodo, ml := s.schemes()
if st.Type != s.tag() {
return nil, ErrIncorrectIdentity
}
if len(st.Args) != 2 {
return nil, fmt.Errorf("invalid %s stanza: expected 2 arguments", s.tag())
}
ct1, err := format.DecodeString(st.Args[0])
if err != nil {
return nil, fmt.Errorf("invalid FrodoKEM ciphertext: %v", err)
}
ct2, err := format.DecodeString(st.Args[1])
if err != nil {
return nil, fmt.Errorf("invalid ML-KEM ciphertext: %v", err)
}
if len(ct1) != frodo.CiphertextSize() || len(ct2) != ml.CiphertextSize() {
return nil, errIncorrectCiphertextSize
}
if len(priv) != frodo.PrivateKeySize()+ml.PrivateKeySize() {
return nil, errors.New("invalid private key size")
}
fsk, err := frodo.UnmarshalBinaryPrivateKey(priv[:frodo.PrivateKeySize()])
if err != nil {
return nil, fmt.Errorf("invalid FrodoKEM private key: %v", err)
}
// priv itself belongs to the caller's identity and is cleared by
// PQIdentity.Close; the parsed key objects are owned by CIRCL and expose
// no buffer Fury can clear.
msk, err := ml.UnmarshalBinaryPrivateKey(priv[frodo.PrivateKeySize():])
if err != nil {
return nil, fmt.Errorf("invalid ML-KEM private key: %v", err)
}
ss1, err := frodo.Decapsulate(fsk, ct1)
if err != nil {
return nil, fmt.Errorf("FrodoKEM decapsulation failed: %v", err)
}
ss2, err := ml.Decapsulate(msk, ct2)
if err != nil {
return nil, fmt.Errorf("ML-KEM decapsulation failed: %v", err)
}
// Both KEMs use implicit rejection, so a mismatched key yields a wrong
// shared secret rather than an error; detection happens here at aeadDecrypt.
kek := s.deriveKEK(ss1, ss2, ct1, ct2)
defer wipe(ss1)
defer wipe(ss2)
defer wipe(kek)
fileKey, err := aeadDecrypt(kek, fileKeySize, st.Body)
if err != nil {
return nil, ErrIncorrectIdentity
}
return fileKey, nil
}
// deriveKEK combines the two KEM shared secrets and both ciphertexts into a
// 256-bit key-encryption key.
//
// INVARIANT (both secrets contribute). ss1 and ss2 are concatenated as the
// HKDF input keying material, so the KEK stays unpredictable unless both KEMs
// are broken for the same target. Dropping either would silently reduce the
// construction to a single KEM.
//
// INVARIANT (transcript binding). Both ciphertexts are bound into the HKDF
// info string, preventing a mix-and-match of encapsulations across the two
// KEMs: an attacker cannot pair ct1 from one stanza with ct2 from another and
// obtain a KEK either party would compute.
//
// INVARIANT (single use). Because both encapsulations are performed freshly
// for every wrap, the resulting KEK is unique to that wrap. That is what
// makes the fixed AEAD nonce in aeadEncrypt safe; see primitives.go.
func (s frodoMLKEM768) deriveKEK(ss1, ss2, ct1, ct2 []byte) []byte {
secret := slices.Concat(ss1, ss2)
defer wipe(secret)
info := slices.Concat([]byte("fury/v1 kek "+s.tag()), ct1, ct2)
r := hkdf.New(sha256.New, secret, nil, info)
kek := make([]byte, 32)
if _, err := io.ReadFull(r, kek); err != nil {
panic("fury: internal error: failed to read from HKDF: " + err.Error())
}
return kek
}
+135
View File
@@ -0,0 +1,135 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package pqcrypt
import (
"bytes"
"io"
"testing"
)
// TestPQRoundTrip exercises the full Encrypt -> Decrypt path through the default
// post-quantum suite with a 256-bit file key.
func TestPQRoundTrip(t *testing.T) {
id, err := GeneratePQIdentity()
if err != nil {
t.Fatal(err)
}
r := id.Recipient()
plaintext := []byte("fury: post-quantum only, hard g like GIF")
buf := &bytes.Buffer{}
w, err := Encrypt(buf, r)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
out, err := Decrypt(bytes.NewReader(buf.Bytes()), id)
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plaintext) {
t.Fatalf("round-trip mismatch: got %q want %q", got, plaintext)
}
}
func TestPQFileKeyIs256Bit(t *testing.T) {
if fileKeySize != 32 {
t.Fatalf("fileKeySize = %d, want 32 (256-bit)", fileKeySize)
}
}
// TestPQWrongIdentity confirms a different identity cannot decrypt.
func TestPQWrongIdentity(t *testing.T) {
id, _ := GeneratePQIdentity()
other, _ := GeneratePQIdentity()
buf := &bytes.Buffer{}
w, err := Encrypt(buf, id.Recipient())
if err != nil {
t.Fatal(err)
}
w.Write([]byte("secret"))
w.Close()
if _, err := Decrypt(bytes.NewReader(buf.Bytes()), other); err == nil {
t.Fatal("decryption with wrong identity unexpectedly succeeded")
}
}
// TestSuiteRegistry checks the default suite is registered and self-describing.
func TestSuiteRegistry(t *testing.T) {
s := defaultSuite()
if s.tag() != "frodo640shake+mlkem768" {
t.Fatalf("unexpected default suite %q", s.tag())
}
if pqSuitesByID[s.id()] != s {
t.Fatal("suite not registered by id")
}
if pqSuitesByTag[s.tag()] != s {
t.Fatal("suite not registered by tag")
}
}
// TestSeedDeterministic confirms an identity round-trips through its seed, which
// is what makes compact passphrase-wrapped identities possible.
func TestSeedDeterministic(t *testing.T) {
id, err := GeneratePQIdentity()
if err != nil {
t.Fatal(err)
}
id2, err := newPQIdentityFromSeed(defaultSuite(), id.Seed())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(id.pub, id2.pub) || !bytes.Equal(id.priv, id2.priv) {
t.Fatal("identity not deterministic from seed")
}
}
func TestIdentityAndRecipientAccessorsReturnCopies(t *testing.T) {
id, err := GeneratePQIdentity()
if err != nil {
t.Fatal(err)
}
seed := id.Seed()
seed[0] ^= 0xff
if id.seed[0] == seed[0] {
t.Fatal("Seed returned mutable identity internals")
}
inputSeed := id.Seed()
id2, err := newPQIdentityFromSeed(defaultSuite(), inputSeed)
if err != nil {
t.Fatal(err)
}
inputSeed[0] ^= 0xff
if id2.seed[0] == inputSeed[0] {
t.Fatal("newPQIdentityFromSeed retained caller-owned seed slice")
}
recipient := id.Recipient()
pub := recipient.Bytes()
pub[0] ^= 0xff
if recipient.pub[0] == pub[0] {
t.Fatal("Bytes returned mutable recipient internals")
}
recipient.pub[0] ^= 0xff
if id.pub[0] == recipient.pub[0] {
t.Fatal("Recipient returned mutable identity public-key internals")
}
}
+271
View File
@@ -0,0 +1,271 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package stream
import (
"crypto/cipher"
"errors"
"fmt"
"io"
"sync"
"golang.org/x/crypto/chacha20poly1305"
)
// NewParallelDecryptReader is equivalent to [NewDecryptReader], except that
// chunks are opened by a pool of worker goroutines instead of inline on the
// calling goroutine.
//
// The sequential DecryptReader disambiguates a full-length final chunk by
// trying to open it with the non-last nonce first and, on authentication
// failure, retrying with the last-chunk nonce. That trick is inherently
// sequential (try, then fall back), so this reader instead keeps a single
// raw chunk of read-ahead: the one goroutine that reads from src (reads must
// stay sequential, there is only one underlying stream) always reads one
// chunk further than it has dispatched, so by the time chunk i is handed to
// the worker pool, whether it is the last chunk is already known for
// certain from the shape of the next read. Each worker then does exactly
// one Open call, with a predetermined nonce, no retry.
//
// For well-formed files this produces byte-for-byte identical plaintext and
// the same errors as NewDecryptReader. For adversarially malformed files,
// the accept/reject verdict is always the same, but the exact error message
// in some corner cases (a corrupted chunk that happens to be full-length)
// may differ, because the classification strategy differs; see the package
// tests for the cases this was checked against.
//
// workers <= 1 returns a plain [NewDecryptReader] with no pipeline overhead.
//
// The returned Reader starts background goroutines that run until the
// stream is fully consumed or a fatal error occurs; abandoning it before
// reading to EOF leaks those goroutines (bounded to a small multiple of
// workers) until they unblock or the process exits.
func NewParallelDecryptReader(key []byte, src io.Reader, workers int) (io.Reader, error) {
if workers <= 1 {
return NewDecryptReader(key, src)
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
depth := workers * pipelineDepthPerWorker
r := &parallelDecryptReader{
a: aead,
src: src,
jobs: make(chan decJob, depth),
order: make(chan chan decResult, depth),
}
r.wg.Add(workers)
for i := 0; i < workers; i++ {
go r.work()
}
go r.pump()
return r, nil
}
type decJob struct {
nonce [chacha20poly1305.NonceSize]byte
ciphertext []byte
result chan decResult
}
// decResult is either a successfully decrypted chunk (err == nil) or a
// terminal event: a decryption failure, a raw read error, or a trailing-data
// violation. There is no explicit "clean EOF" value: a clean end of stream
// is represented by the order channel closing with nothing left to receive.
type decResult struct {
plaintext []byte
err error
}
type parallelDecryptReader struct {
a cipher.AEAD
src io.Reader
jobs chan decJob
order chan chan decResult
wg sync.WaitGroup
unread []byte
err error
}
func (r *parallelDecryptReader) Read(p []byte) (int, error) {
if len(r.unread) > 0 {
n := copy(p, r.unread)
r.unread = r.unread[n:]
return n, nil
}
if r.err != nil {
return 0, r.err
}
if len(p) == 0 {
return 0, nil
}
ch, ok := <-r.order
if !ok {
r.err = io.EOF
return 0, r.err
}
res := <-ch
if res.err != nil {
r.err = res.err
return 0, r.err
}
r.unread = res.plaintext
n := copy(p, r.unread)
r.unread = r.unread[n:]
return n, nil
}
// dispatch hands a raw chunk, whose classification is already known for
// certain, to the worker pool.
func (r *parallelDecryptReader) dispatch(index int64, ciphertext []byte, last bool) {
nonce := nonceForChunk(index)
if last {
setLastChunkFlag(nonce)
}
ch := make(chan decResult, 1)
r.order <- ch
r.jobs <- decJob{nonce: *nonce, ciphertext: ciphertext, result: ch}
}
// emitDirect enqueues a terminal result produced by the pump goroutine
// itself (a read error, a malformed-stream error, or a trailing-data
// violation), without involving the worker pool.
func (r *parallelDecryptReader) emitDirect(res decResult) {
ch := make(chan decResult, 1)
r.order <- ch
ch <- res
}
// work opens dispatched chunks. Unlike the sequential reader, there is no
// retry here: the pump goroutine has already determined, from the shape of
// the raw stream, whether each chunk is the last one.
func (r *parallelDecryptReader) work() {
defer r.wg.Done()
for j := range r.jobs {
out := make([]byte, 0, ChunkSize)
plaintext, err := r.a.Open(out, j.nonce[:], j.ciphertext, nil)
if err != nil {
j.result <- decResult{err: errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")}
continue
}
j.result <- decResult{plaintext: plaintext}
}
}
type chunkKind int
const (
chunkFull chunkKind = iota
chunkShort
chunkEOF
chunkErr
)
type rawChunk struct {
kind chunkKind
data []byte
err error
}
// readRawChunk reads exactly one raw encChunkSize block from src into a
// freshly allocated buffer and classifies the read, mirroring the switch at
// the top of the sequential DecryptReader.readChunk.
func readRawChunk(src io.Reader) rawChunk {
buf := make([]byte, encChunkSize)
n, err := io.ReadFull(src, buf)
switch {
case err == nil:
return rawChunk{kind: chunkFull, data: buf}
case err == io.EOF:
return rawChunk{kind: chunkEOF}
case err == io.ErrUnexpectedEOF:
return rawChunk{kind: chunkShort, data: buf[:n]}
default:
return rawChunk{kind: chunkErr, err: err}
}
}
// pump is the single goroutine that reads raw chunks from src, in order,
// always staying one chunk ahead so that every dispatched chunk's
// last-chunk status is already certain. It closes r.jobs and r.order when
// the stream ends or a fatal error is found, which is what lets Read
// observe a clean end of stream (order closed with nothing left) versus an
// explicit terminal error (an emitDirect result).
func (r *parallelDecryptReader) pump() {
defer close(r.jobs)
defer close(r.order)
first := readRawChunk(r.src)
switch first.kind {
case chunkErr:
r.emitDirect(decResult{err: first.err})
return
case chunkEOF:
// A message can't end without a marked chunk; this message is
// truncated, exactly like the sequential reader's first read.
r.emitDirect(decResult{err: io.ErrUnexpectedEOF})
return
case chunkShort:
// This is chunk index 0: an empty chunk here is the valid
// empty-plaintext-file case, not the "empty last chunk" error,
// which can only apply to a later chunk.
r.dispatch(0, first.data, true)
r.probeTrailing()
return
}
// first.kind == chunkFull: ambiguous until we see what follows it.
pendingIndex := int64(0)
pending := first.data
for {
next := readRawChunk(r.src)
switch next.kind {
case chunkErr:
r.dispatch(pendingIndex, pending, false)
r.emitDirect(decResult{err: next.err})
return
case chunkEOF:
// Nothing follows: pending was a full-length final chunk. The
// clean EOF we just observed is itself the trailing-data proof,
// no separate probe needed.
r.dispatch(pendingIndex, pending, true)
return
case chunkShort:
r.dispatch(pendingIndex, pending, false)
if len(next.data) == chacha20poly1305.Overhead {
// A short chunk here is never index 0, so an empty one is
// always the disallowed case.
r.emitDirect(decResult{err: errors.New("last chunk is empty; encrypted payload is malformed")})
return
}
r.dispatch(pendingIndex+1, next.data, true)
r.probeTrailing()
return
case chunkFull:
r.dispatch(pendingIndex, pending, false)
pendingIndex++
pending = next.data
continue
}
}
}
// probeTrailing confirms there is no data after a short final chunk, exactly
// as the sequential DecryptReader.Read does after processing a last chunk.
func (r *parallelDecryptReader) probeTrailing() {
var b [1]byte
_, err := r.src.Read(b[:])
switch {
case err == nil:
r.emitDirect(decResult{err: errors.New("trailing data after end of encrypted file")})
case err != io.EOF:
r.emitDirect(decResult{err: fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)})
}
}
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package stream
import (
"bytes"
"crypto/cipher"
"errors"
"io"
"sync"
"golang.org/x/crypto/chacha20poly1305"
)
// pipelineDepthPerWorker bounds how many chunks may be queued for, or
// awaiting collection from, a single worker at once. It caps the pipeline's
// memory use to a small multiple of ChunkSize regardless of input size.
const pipelineDepthPerWorker = 2
// NewParallelEncryptWriter is equivalent to [NewEncryptWriter], except that
// chunks are sealed by a pool of worker goroutines instead of inline on the
// calling goroutine. Its output is byte-for-byte identical to
// NewEncryptWriter's: STREAM chunk nonces are a pure counter, so sealing
// chunk i never depends on chunk i-1. Only the write order to dst must stay
// sequential, which a dedicated collector goroutine enforces.
//
// workers <= 1 returns a plain [NewEncryptWriter] with no pipeline overhead.
func NewParallelEncryptWriter(key []byte, dst io.Writer, workers int) (io.WriteCloser, error) {
if workers <= 1 {
return NewEncryptWriter(key, dst)
}
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
depth := workers * pipelineDepthPerWorker
w := &parallelEncryptWriter{
a: aead,
dst: dst,
jobs: make(chan encJob, depth),
order: make(chan chan []byte, depth),
done: make(chan struct{}),
}
w.wg.Add(workers)
for i := 0; i < workers; i++ {
go w.work()
}
go w.collect()
return w, nil
}
type encJob struct {
nonce [chacha20poly1305.NonceSize]byte
plaintext []byte
result chan []byte
}
type parallelEncryptWriter struct {
a cipher.AEAD
dst io.Writer
buf bytes.Buffer
index int64
jobs chan encJob
order chan chan []byte
wg sync.WaitGroup
done chan struct{} // closed once the collector goroutine has returned
mu sync.Mutex
err error
}
func (w *parallelEncryptWriter) getErr() error {
w.mu.Lock()
defer w.mu.Unlock()
return w.err
}
func (w *parallelEncryptWriter) setErr(err error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.err == nil {
w.err = err
}
}
func (w *parallelEncryptWriter) Write(p []byte) (n int, err error) {
if err := w.getErr(); err != nil {
return 0, err
}
if len(p) == 0 {
return 0, nil
}
total := len(p)
for len(p) > 0 {
room := ChunkSize - w.buf.Len()
nn := min(len(p), room)
w.buf.Write(p[:nn])
p = p[nn:]
// Only submit a chunk once we know more data is coming, so the very
// last chunk (submitted from Close) is always the one that gets the
// last-chunk-flag nonce, exactly like the sequential EncryptWriter.
if w.buf.Len() == ChunkSize && len(p) > 0 {
if err := w.submitChunk(false); err != nil {
w.setErr(err)
return 0, err
}
}
}
return total, nil
}
// Close flushes the last chunk and waits for every queued chunk to be
// sealed and written to dst, in order. It does not close the underlying
// Writer.
func (w *parallelEncryptWriter) Close() error {
if err := w.getErr(); err != nil {
return err
}
// submitChunk cannot fail here: we just confirmed there is no prior
// error, and channel sends to w.jobs/w.order only block, they never
// return an error.
_ = w.submitChunk(true)
close(w.jobs)
close(w.order)
w.wg.Wait()
<-w.done
if err := w.getErr(); err != nil {
return err
}
w.setErr(errors.New("stream.Writer is already closed"))
return nil
}
// submitChunk copies out the current buffer as a chunk of plaintext, assigns
// it the next sequential nonce, and hands it to the worker pool. last must be
// true only for the final call, made from Close.
func (w *parallelEncryptWriter) submitChunk(last bool) error {
if err := w.getErr(); err != nil {
return err
}
plaintext := append([]byte(nil), w.buf.Bytes()...)
w.buf.Reset()
nonce := nonceForChunk(w.index)
if last {
setLastChunkFlag(nonce)
}
w.index++
result := make(chan []byte, 1)
w.order <- result
w.jobs <- encJob{nonce: *nonce, plaintext: plaintext, result: result}
return nil
}
// work seals queued chunks. Seal cannot fail: the nonce is always the
// correct size, so the only possible failure in this pipeline is dst.Write,
// handled in collect.
func (w *parallelEncryptWriter) work() {
defer w.wg.Done()
for j := range w.jobs {
ciphertext := w.a.Seal(make([]byte, 0, len(j.plaintext)+w.a.Overhead()), j.nonce[:], j.plaintext, nil)
j.result <- ciphertext
}
}
// collect writes sealed chunks to dst strictly in submission order, even
// though workers may finish sealing them in a different order.
func (w *parallelEncryptWriter) collect() {
defer close(w.done)
var failed bool
for result := range w.order {
ciphertext := <-result
if failed {
continue
}
if _, err := w.dst.Write(ciphertext); err != nil {
w.setErr(err)
failed = true
}
}
}
+332
View File
@@ -0,0 +1,332 @@
// Copyright 2026 The fury Authors.
// SPDX-License-Identifier: GPL-3.0-or-later
package stream_test
import (
"bytes"
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"runtime"
"testing"
"github.com/andydixon/vault/internal/stream"
"golang.org/x/crypto/chacha20poly1305"
)
func workerCounts() []int {
return []int{1, 2, 4, runtime.NumCPU() + 4}
}
func randBytes(t *testing.T, n int) []byte {
t.Helper()
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
return b
}
// TestParallelEncryptMatchesSequential is the main compatibility check: for a
// range of lengths and worker counts, the parallel encrypter must produce
// byte-identical ciphertext to the sequential one, and ciphertext from
// either encrypter must decrypt correctly with either reader.
func TestParallelEncryptMatchesSequential(t *testing.T) {
lengths := []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500}
for _, length := range lengths {
for _, workers := range workerCounts() {
t.Run(fmt.Sprintf("len=%d,workers=%d", length, workers), func(t *testing.T) {
src := randBytes(t, length)
key := randBytes(t, chacha20poly1305.KeySize)
seqBuf := &bytes.Buffer{}
sw, err := stream.NewEncryptWriter(key, seqBuf)
if err != nil {
t.Fatal(err)
}
if _, err := sw.Write(src); err != nil {
t.Fatal(err)
}
if err := sw.Close(); err != nil {
t.Fatal(err)
}
parBuf := &bytes.Buffer{}
pw, err := stream.NewParallelEncryptWriter(key, parBuf, workers)
if err != nil {
t.Fatal(err)
}
if _, err := pw.Write(src); err != nil {
t.Fatal(err)
}
if err := pw.Close(); err != nil {
t.Fatal(err)
}
if !bytes.Equal(seqBuf.Bytes(), parBuf.Bytes()) {
t.Fatalf("parallel ciphertext (%d bytes) differs from sequential (%d bytes)",
parBuf.Len(), seqBuf.Len())
}
t.Run("decrypt-with-sequential", func(t *testing.T) {
dr, err := stream.NewDecryptReader(key, bytes.NewReader(parBuf.Bytes()))
if err != nil {
t.Fatal(err)
}
checkRoundTrip(t, dr, src)
})
t.Run("decrypt-with-parallel", func(t *testing.T) {
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(parBuf.Bytes()), workers)
if err != nil {
t.Fatal(err)
}
checkRoundTrip(t, dr, src)
})
t.Run("sequential-ciphertext-decrypts-with-parallel", func(t *testing.T) {
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(seqBuf.Bytes()), workers)
if err != nil {
t.Fatal(err)
}
checkRoundTrip(t, dr, src)
})
})
}
}
}
func checkRoundTrip(t *testing.T, r io.Reader, want []byte) {
t.Helper()
got, err := io.ReadAll(r)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("decrypted mismatch: got %d bytes, want %d bytes", len(got), len(want))
}
}
// TestParallelEncryptWriteInSteps confirms the parallel writer's output is
// unaffected by how the caller chunks its Write calls, mirroring the
// sequential TestRoundTrip's step-size coverage but scoped to a couple of
// representative lengths to keep runtime reasonable.
func TestParallelEncryptWriteInSteps(t *testing.T) {
lengths := []int{cs + 100, 2*cs + 500}
steps := []int{1, 537, cs}
for _, length := range lengths {
for _, step := range steps {
t.Run(fmt.Sprintf("len=%d,step=%d", length, step), func(t *testing.T) {
src := randBytes(t, length)
key := randBytes(t, chacha20poly1305.KeySize)
seqBuf := &bytes.Buffer{}
sw, err := stream.NewEncryptWriter(key, seqBuf)
if err != nil {
t.Fatal(err)
}
writeInSteps(t, sw, src, step)
if err := sw.Close(); err != nil {
t.Fatal(err)
}
parBuf := &bytes.Buffer{}
pw, err := stream.NewParallelEncryptWriter(key, parBuf, 4)
if err != nil {
t.Fatal(err)
}
writeInSteps(t, pw, src, step)
if err := pw.Close(); err != nil {
t.Fatal(err)
}
if !bytes.Equal(seqBuf.Bytes(), parBuf.Bytes()) {
t.Fatalf("parallel ciphertext differs from sequential with step=%d", step)
}
})
}
}
}
func writeInSteps(t *testing.T, w io.Writer, src []byte, step int) {
t.Helper()
for len(src) > 0 {
n := min(step, len(src))
if _, err := w.Write(src[:n]); err != nil {
t.Fatal(err)
}
src = src[n:]
}
}
// TestParallelDecryptMalformed exercises the same categories of malformed
// input the sequential reader rejects. The parallel reader resolves the
// last-chunk ambiguity from the shape of the raw stream (read-ahead) rather
// than by a trial decryption, so in adversarial cases the exact error
// message can differ from the sequential reader's -- what must not differ is
// the accept/reject verdict. Where the message is deterministic under the
// new strategy too, it's asserted explicitly.
func TestParallelDecryptMalformed(t *testing.T) {
newCiphertext := func(t *testing.T, key []byte, plaintext []byte) []byte {
t.Helper()
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
t.Run("wrong key", func(t *testing.T) {
key := randBytes(t, chacha20poly1305.KeySize)
ciphertext := newCiphertext(t, key, randBytes(t, 1000))
wrongKey := randBytes(t, chacha20poly1305.KeySize)
dr, err := stream.NewParallelDecryptReader(wrongKey, bytes.NewReader(ciphertext), 4)
if err != nil {
t.Fatal(err)
}
if _, err := io.ReadAll(dr); err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("corrupted byte in non-final chunk", func(t *testing.T) {
key := randBytes(t, chacha20poly1305.KeySize)
ciphertext := newCiphertext(t, key, randBytes(t, 2*cs+500))
ciphertext[10] ^= 0xff // inside the first (non-final) chunk
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(ciphertext), 4)
if err != nil {
t.Fatal(err)
}
if _, err := io.ReadAll(dr); err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("truncated", func(t *testing.T) {
key := randBytes(t, chacha20poly1305.KeySize)
ciphertext := newCiphertext(t, key, randBytes(t, 2*cs+500))
truncated := ciphertext[:len(ciphertext)-100]
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(truncated), 4)
if err != nil {
t.Fatal(err)
}
if _, err := io.ReadAll(dr); err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("trailing data appended after short last chunk", func(t *testing.T) {
// With a bytes.Reader, io.ReadFull for the (undersized) chunk buffer
// greedily consumes the real short chunk AND the trailing garbage in
// one read, since everything is available at once: the result is a
// misaligned AEAD tag, not a distinguishable "trailing data" case.
// This is also true of the sequential reader (same io.ReadFull over
// the same fixed-size buffer) -- the verdict, an error, is what must
// hold, not a specific message.
key := randBytes(t, chacha20poly1305.KeySize)
ciphertext := newCiphertext(t, key, randBytes(t, 1000)) // ends in a short chunk
withTrailer := append(append([]byte(nil), ciphertext...), 1, 2, 3, 4, 5)
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(withTrailer), 4)
if err != nil {
t.Fatal(err)
}
if _, err := io.ReadAll(dr); err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("trailing data after full-length last chunk", func(t *testing.T) {
key := randBytes(t, chacha20poly1305.KeySize)
ciphertext := newCiphertext(t, key, randBytes(t, cs)) // exact multiple: full-length last chunk
withTrailer := append(append([]byte(nil), ciphertext...), 1, 2, 3, 4, 5)
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(withTrailer), 4)
if err != nil {
t.Fatal(err)
}
// The trailing bytes are re-interpreted as a short "final" chunk here
// (a different, but still safe, rejection path than the sequential
// reader's trailing-byte probe); the verdict must still be an error.
if _, err := io.ReadAll(dr); err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("empty chunk after a non-empty chunk is rejected", func(t *testing.T) {
key := randBytes(t, chacha20poly1305.KeySize)
aead, err := chacha20poly1305.New(key)
if err != nil {
t.Fatal(err)
}
var malformed []byte
malformed = append(malformed, sealChunkForTest(t, aead, 0, false, randBytes(t, cs))...)
malformed = append(malformed, sealChunkForTest(t, aead, 1, true, nil)...)
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(malformed), 4)
if err != nil {
t.Fatal(err)
}
_, err = io.ReadAll(dr)
if err == nil {
t.Fatal("expected error, got nil")
}
if got, want := err.Error(), "last chunk is empty; encrypted payload is malformed"; got != want {
t.Fatalf("error = %q, want %q", got, want)
}
})
}
// sealChunkForTest manually seals a chunk using the same nonce scheme as the
// stream package (a big-endian chunk counter in bytes [3:11], a last-chunk
// flag in the final byte), to construct deliberately malformed streams that
// aren't reachable through the public encrypt API.
func sealChunkForTest(t *testing.T, aead cipher.AEAD, index int64, last bool, plaintext []byte) []byte {
t.Helper()
var nonce [chacha20poly1305.NonceSize]byte
binary.BigEndian.PutUint64(nonce[3:11], uint64(index))
if last {
nonce[len(nonce)-1] = 1
}
return aead.Seal(nil, nonce[:], plaintext, nil)
}
// TestParallelRace exercises the pipeline with oversubscribed workers on a
// multi-chunk payload; run with -race to catch data races in the ordered
// fan-in.
func TestParallelRace(t *testing.T) {
key := randBytes(t, chacha20poly1305.KeySize)
src := randBytes(t, 6*cs+12345)
buf := &bytes.Buffer{}
w, err := stream.NewParallelEncryptWriter(key, buf, runtime.NumCPU()*2)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(src); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(buf.Bytes()), runtime.NumCPU()*2)
if err != nil {
t.Fatal(err)
}
checkRoundTrip(t, dr, src)
}
+468
View File
@@ -0,0 +1,468 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package stream implements a variant of the STREAM chunked encryption scheme.
//
// The invariants this package exists to hold:
//
// - Counter uniqueness. A chunk's nonce is a pure big-endian counter over
// bytes 3..10, incremented once per chunk and never reset within a
// stream. incNonce panics rather than wrapping, so a (key, nonce) pair
// can never repeat: the counter is 88 bits and the panic is unreachable
// for any real file.
// - Content independence. The nonce depends only on the chunk index and the
// last-chunk flag, never on chunk contents. That is what lets the parallel
// encoder seal chunks out of order and still produce byte-identical
// output.
// - Truncation resistance. The final chunk is sealed under a nonce whose
// last byte carries lastChunkFlag. A truncated stream therefore fails to
// authenticate: the attacker would have to forge a last-chunk tag for a
// chunk the writer sealed as non-final.
// - No trailing data. After a last chunk is accepted, the reader confirms
// the underlying source is at EOF and fails otherwise, so appended bytes
// are rejected rather than silently ignored.
package stream
import (
"bytes"
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
"io"
"sync/atomic"
"golang.org/x/crypto/chacha20poly1305"
)
const ChunkSize = 64 * 1024
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
// Empty plaintext, the only case that allows (and requires) an empty chunk.
if plaintextSize == 0 {
expChunks = 1
}
if expChunks != chunks {
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
}
return chunks, nil
}
func PlaintextSize(encryptedSize int64) (int64, error) {
chunks, err := EncryptedChunkCount(encryptedSize)
if err != nil {
return 0, err
}
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
return plaintextSize, nil
}
type DecryptReader struct {
a cipher.AEAD
src io.Reader
unread []byte // decrypted but unread data, backed by buf
buf [encChunkSize]byte
err error
nonce [chacha20poly1305.NonceSize]byte
}
const (
encChunkSize = ChunkSize + chacha20poly1305.Overhead
lastChunkFlag = 0x01
)
func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
return &DecryptReader{a: aead, src: src}, nil
}
func (r *DecryptReader) Read(p []byte) (int, error) {
if len(r.unread) > 0 {
n := copy(p, r.unread)
r.unread = r.unread[n:]
return n, nil
}
if r.err != nil {
return 0, r.err
}
if len(p) == 0 {
return 0, nil
}
last, err := r.readChunk()
if err != nil {
r.err = err
return 0, err
}
n := copy(p, r.unread)
r.unread = r.unread[n:]
if last {
// Ensure there is an EOF after the last chunk as expected. In other
// words, check for trailing data after a full-length final chunk.
// Hopefully, the underlying reader supports returning EOF even if it
// had previously returned an EOF to ReadFull.
if _, err := r.src.Read(make([]byte, 1)); err == nil {
r.err = errors.New("trailing data after end of encrypted file")
} else if err != io.EOF {
r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)
} else {
r.err = io.EOF
}
}
return n, nil
}
// readChunk reads the next chunk of ciphertext from r.src and makes it available
// in r.unread. last is true if the chunk was marked as the end of the message.
// readChunk must not be called again after returning a last chunk or an error.
func (r *DecryptReader) readChunk() (last bool, err error) {
if len(r.unread) != 0 {
panic("stream: internal error: readChunk called with dirty buffer")
}
in := r.buf[:]
n, err := io.ReadFull(r.src, in)
switch {
case err == io.EOF:
// A message can't end without a marked chunk. This message is truncated.
return false, io.ErrUnexpectedEOF
case err == io.ErrUnexpectedEOF:
// The last chunk can be short, but not empty unless it's the first and
// only chunk.
if !nonceIsZero(&r.nonce) && n == r.a.Overhead() {
return false, errors.New("last chunk is empty; encrypted payload is malformed")
}
in = in[:n]
last = true
setLastChunkFlag(&r.nonce)
case err != nil:
return false, err
}
outBuf := make([]byte, 0, ChunkSize)
out, err := r.a.Open(outBuf, r.nonce[:], in, nil)
if err != nil && !last {
// Check if this was a full-length final chunk.
last = true
setLastChunkFlag(&r.nonce)
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
}
if err != nil {
return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
}
incNonce(&r.nonce)
r.unread = r.buf[:copy(r.buf[:], out)]
return last, nil
}
func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
for i := len(nonce) - 2; i >= 0; i-- {
nonce[i]++
if nonce[i] != 0 {
return
}
}
// The counter is 88 bits, this is unreachable.
panic("stream: chunk counter wrapped around")
}
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
var nonce [chacha20poly1305.NonceSize]byte
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
return &nonce
}
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
nonce[len(nonce)-1] = lastChunkFlag
}
func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
return *nonce == [chacha20poly1305.NonceSize]byte{}
}
type EncryptWriter struct {
a cipher.AEAD
dst io.Writer
buf bytes.Buffer
nonce [chacha20poly1305.NonceSize]byte
err error
}
func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
return &EncryptWriter{a: aead, dst: dst}, nil
}
func (w *EncryptWriter) Write(p []byte) (n int, err error) {
if w.err != nil {
return 0, w.err
}
if len(p) == 0 {
return 0, nil
}
total := len(p)
for len(p) > 0 {
n := min(len(p), ChunkSize-w.buf.Len())
w.buf.Write(p[:n])
p = p[n:]
// Only flush if there's a full chunk with bytes still to write, or we
// can't know if this is the last chunk yet.
if w.buf.Len() == ChunkSize && len(p) > 0 {
if err := w.flushChunk(notLastChunk); err != nil {
w.err = err
return 0, err
}
}
}
return total, nil
}
// Close flushes the last chunk. It does not close the underlying Writer.
func (w *EncryptWriter) Close() error {
if w.err != nil {
return w.err
}
w.err = w.flushChunk(lastChunk)
if w.err != nil {
return w.err
}
w.err = errors.New("stream.Writer is already closed")
return nil
}
const (
lastChunk = true
notLastChunk = false
)
func (w *EncryptWriter) flushChunk(last bool) error {
if !last && w.buf.Len() != ChunkSize {
panic("stream: internal error: flush called with partial chunk")
}
if last {
setLastChunkFlag(&w.nonce)
}
w.buf.Grow(chacha20poly1305.Overhead)
ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
_, err := w.dst.Write(ciphertext)
incNonce(&w.nonce)
w.buf.Reset()
return err
}
type EncryptReader struct {
a cipher.AEAD
src io.Reader
// The first ready bytes of buf are already encrypted. This may be less than
// buf.Len(), because we need to over-read to know if a chunk is the last.
ready int
buf bytes.Buffer
nonce [chacha20poly1305.NonceSize]byte
err error
}
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
return &EncryptReader{a: aead, src: src}, nil
}
func (r *EncryptReader) Read(p []byte) (int, error) {
if r.ready > 0 {
n, err := r.buf.Read(p[:min(len(p), r.ready)])
r.ready -= n
return n, err
}
if r.err != nil {
return 0, r.err
}
if len(p) == 0 {
return 0, nil
}
if err := r.feedBuffer(); err != nil {
r.err = err
return 0, err
}
n, err := r.buf.Read(p[:min(len(p), r.ready)])
r.ready -= n
return n, err
}
// feedBuffer reads and encrypts the next chunk from r.src and appends it to
// r.buf. It sets r.ready to the number of newly available bytes in r.buf.
func (r *EncryptReader) feedBuffer() error {
if r.ready > 0 {
panic("stream: internal error: feedBuffer called with dirty buffer")
}
// CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
// We need ChunkSize + 1 bytes to determine if this is the last chunk.
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
if err != nil && err != io.EOF {
return err
}
if last := r.buf.Len() <= ChunkSize; last {
setLastChunkFlag(&r.nonce)
// After Grow, we know r.buf.Bytes() has enough capacity for the
// overhead. We encrypt in place and then do a Write to include the
// overhead in the buffer.
r.buf.Grow(chacha20poly1305.Overhead)
plaintext := r.buf.Bytes()
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
incNonce(&r.nonce)
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
r.ready = r.buf.Len()
r.err = io.EOF
return nil
}
// Same, but accounting for the tail byte which will remain unencrypted and
// needs to be shifted past the overhead.
if r.buf.Len() != ChunkSize+1 {
panic("stream: internal error: unexpected buffer length")
}
tailByte := r.buf.Bytes()[ChunkSize]
r.buf.Grow(chacha20poly1305.Overhead)
plaintext := r.buf.Bytes()[:ChunkSize]
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
incNonce(&r.nonce)
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
r.buf.WriteByte(tailByte)
r.ready = ChunkSize + chacha20poly1305.Overhead
return nil
}
type DecryptReaderAt struct {
a cipher.AEAD
src io.ReaderAt
size int64
chunks int64
cache atomic.Pointer[cachedChunk]
}
type cachedChunk struct {
off int64
data []byte
}
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, err
}
// Check that size is valid by decrypting the final chunk.
chunks, err := EncryptedChunkCount(size)
if err != nil {
return nil, err
}
finalChunkIndex := chunks - 1
finalChunkOff := finalChunkIndex * encChunkSize
finalChunkSize := size - finalChunkOff
finalChunk := make([]byte, finalChunkSize)
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
return nil, fmt.Errorf("failed to read final chunk: %w", err)
}
nonce := nonceForChunk(finalChunkIndex)
setLastChunkFlag(nonce)
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
}
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
plaintextSize := size - chunks*chacha20poly1305.Overhead
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
r.cache.Store(cache)
return r, nil
}
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
if off < 0 || off > r.size {
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
}
if len(p) == 0 {
return 0, nil
}
var cacheUpdate *cachedChunk
chunk := make([]byte, encChunkSize)
for len(p) > 0 && off < r.size {
chunkIndex := off / ChunkSize
chunkOff := chunkIndex * encChunkSize
encSize := r.size + r.chunks*chacha20poly1305.Overhead
chunkSize := min(encSize-chunkOff, encChunkSize)
cached := r.cache.Load()
var plaintext []byte
if cached != nil && cached.off == chunkOff {
plaintext = cached.data
cacheUpdate = nil
} else {
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
if err == io.EOF {
if int64(nn) != chunkSize {
err = io.ErrUnexpectedEOF
} else {
err = nil
}
}
if err != nil {
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
}
nonce := nonceForChunk(chunkIndex)
if chunkIndex == r.chunks-1 {
setLastChunkFlag(nonce)
}
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
if err != nil {
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
}
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
}
plainChunkOff := int(off - chunkIndex*ChunkSize)
copySize := min(len(plaintext)-plainChunkOff, len(p))
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
p = p[copySize:]
off += int64(copySize)
n += copySize
}
if cacheUpdate != nil {
r.cache.Store(cacheUpdate)
}
if off == r.size {
return n, io.EOF
}
return n, nil
}
+950
View File
@@ -0,0 +1,950 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package stream_test
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"testing"
"testing/iotest"
"github.com/andydixon/vault/internal/stream"
"golang.org/x/crypto/chacha20poly1305"
)
const cs = stream.ChunkSize
func TestRoundTrip(t *testing.T) {
for _, length := range []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} {
for _, stepSize := range []int{512, 600, 1000, cs - 1, cs, cs + 1} {
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
testRoundTrip(t, stepSize, length)
})
}
}
length, stepSize := 2*cs+500, 1
t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) {
testRoundTrip(t, stepSize, length)
})
}
func testRoundTrip(t *testing.T, stepSize, length int) {
src := make([]byte, length)
if _, err := rand.Read(src); err != nil {
t.Fatal(err)
}
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
var ciphertext []byte
t.Run("EncryptWriter", func(t *testing.T) {
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
var n int
for n < length {
b := min(length-n, stepSize)
nn, err := w.Write(src[n : n+b])
if err != nil {
t.Fatal(err)
}
if nn != b {
t.Errorf("Write returned %d, expected %d", nn, b)
}
n += nn
nn, err = w.Write(src[n:n])
if err != nil {
t.Fatal(err)
}
if nn != 0 {
t.Errorf("Write returned %d, expected 0", nn)
}
}
if err := w.Close(); err != nil {
t.Error("Close returned an error:", err)
}
ciphertext = buf.Bytes()
})
t.Run("DecryptReader", func(t *testing.T) {
r, err := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
if err != nil {
t.Fatal(err)
}
var n int
readBuf := make([]byte, stepSize)
for n < length {
nn, err := r.Read(readBuf)
if err != nil {
t.Fatalf("Read error at index %d: %v", n, err)
}
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
}
n += nn
}
t.Run("TestReader", func(t *testing.T) {
if length > 1000 && testing.Short() {
t.Skip("skipping slow iotest.TestReader on long input")
}
r, _ := stream.NewDecryptReader(key, bytes.NewReader(ciphertext))
if err := iotest.TestReader(r, src); err != nil {
t.Error("iotest.TestReader error on DecryptReader:", err)
}
})
})
t.Run("DecryptReaderAt", func(t *testing.T) {
rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
rr := io.NewSectionReader(rAt, 0, int64(len(ciphertext)))
var n int
readBuf := make([]byte, stepSize)
for n < length {
nn, err := rr.Read(readBuf)
if n+nn == length && err == io.EOF {
err = nil
}
if err != nil {
t.Fatalf("ReadAt error at index %d: %v", n, err)
}
if !bytes.Equal(readBuf[:nn], src[n:n+nn]) {
t.Errorf("wrong data at indexes %d - %d", n, n+nn)
}
n += nn
}
t.Run("TestReader", func(t *testing.T) {
if length > 1000 && testing.Short() {
t.Skip("skipping slow iotest.TestReader on long input")
}
rr := io.NewSectionReader(rAt, 0, int64(len(src)))
if err := iotest.TestReader(rr, src); err != nil {
t.Error("iotest.TestReader error on DecryptReaderAt:", err)
}
})
})
t.Run("EncryptReader", func(t *testing.T) {
er, err := stream.NewEncryptReader(key, bytes.NewReader(src))
if err != nil {
t.Fatal(err)
}
var n int
readBuf := make([]byte, stepSize)
for {
nn, err := er.Read(readBuf)
if nn == 0 && err == io.EOF {
break
} else if err != nil {
t.Fatalf("EncryptReader Read error at index %d: %v", n, err)
}
if !bytes.Equal(readBuf[:nn], ciphertext[n:n+nn]) {
t.Errorf("EncryptReader wrong data at indexes %d - %d", n, n+nn)
}
n += nn
}
if n != len(ciphertext) {
t.Errorf("EncryptReader read %d bytes, expected %d", n, len(ciphertext))
}
t.Run("TestReader", func(t *testing.T) {
if length > 1000 && testing.Short() {
t.Skip("skipping slow iotest.TestReader on long input")
}
er, _ := stream.NewEncryptReader(key, bytes.NewReader(src))
if err := iotest.TestReader(er, ciphertext); err != nil {
t.Error("iotest.TestReader error on EncryptReader:", err)
}
})
})
}
// trackingReaderAt wraps an io.ReaderAt and tracks whether ReadAt was called.
type trackingReaderAt struct {
r io.ReaderAt
called bool
}
func (t *trackingReaderAt) ReadAt(p []byte, off int64) (int, error) {
t.called = true
return t.r.ReadAt(p, off)
}
func (t *trackingReaderAt) reset() {
t.called = false
}
func TestDecryptReaderAt(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
// Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third
// Chunk 0: [0, cs)
// Chunk 1: [cs, 2*cs)
// Chunk 2: [2*cs, 2*cs+500)
plaintextSize := 2*cs + 500
plaintext := make([]byte, plaintextSize)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
// Encrypt
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
// Create tracking ReaderAt
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
// Create DecryptReaderAt (this reads and caches the final chunk)
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// Helper to check reads
checkRead := func(name string, off int64, size int, wantN int, wantEOF bool, wantSrcRead bool) {
t.Helper()
tracker.reset()
p := make([]byte, size)
n, err := ra.ReadAt(p, off)
if wantEOF {
if err != io.EOF {
t.Errorf("%s: got err=%v, want EOF", name, err)
}
} else {
if err != nil {
t.Errorf("%s: got err=%v, want nil", name, err)
}
}
if n != wantN {
t.Errorf("%s: got n=%d, want %d", name, n, wantN)
}
if tracker.called != wantSrcRead {
t.Errorf("%s: src.ReadAt called=%v, want %v", name, tracker.called, wantSrcRead)
}
// Verify data correctness
if n > 0 && off >= 0 && off < int64(plaintextSize) {
end := int(off) + n
if end > plaintextSize {
end = plaintextSize
}
if !bytes.Equal(p[:n], plaintext[off:end]) {
t.Errorf("%s: data mismatch", name)
}
}
}
// Test 1: Read from final chunk (cached by constructor)
checkRead("final chunk (cached)", int64(2*cs+100), 100, 100, false, false)
// Test 2: Read spanning second and third chunk
checkRead("span chunks 1-2", int64(cs+cs-50), 100, 100, false, true)
// Test 3: Read from final chunk again (cached from test 2)
// When reading across chunks 1-2 in test 2, the loop processes chunk 1 then chunk 2,
// so chunk 2 ends up in the cache.
checkRead("final chunk after span", int64(2*cs+200), 100, 100, false, false)
// Test 4: Read from final chunk again (now cached)
checkRead("final chunk (cached again)", int64(2*cs+50), 50, 50, false, false)
// Test 5: Read from first chunk (not cached)
checkRead("first chunk", 0, 100, 100, false, true)
// Test 6: Read from first chunk again (now cached)
checkRead("first chunk (cached)", 50, 100, 100, false, false)
// Test 7: Read spanning all chunks
tracker.reset()
p := make([]byte, plaintextSize)
n, err := ra.ReadAt(p, 0)
if err != io.EOF {
t.Errorf("span all: got err=%v, want EOF", err)
}
if n != plaintextSize {
t.Errorf("span all: got n=%d, want %d", n, plaintextSize)
}
if !bytes.Equal(p, plaintext) {
t.Errorf("span all: data mismatch")
}
// Test 8: Read beyond the end (offset > size)
tracker.reset()
p = make([]byte, 100)
n, err = ra.ReadAt(p, int64(plaintextSize+100))
if err == nil {
t.Error("beyond end: expected error, got nil")
}
if n != 0 {
t.Errorf("beyond end: got n=%d, want 0", n)
}
// Test 9: Read with off = size (should return 0, EOF)
tracker.reset()
p = make([]byte, 100)
n, err = ra.ReadAt(p, int64(plaintextSize))
if err != io.EOF {
t.Errorf("off=size: got err=%v, want EOF", err)
}
if n != 0 {
t.Errorf("off=size: got n=%d, want 0", n)
}
// Test 10: Read spanning last chunk and beyond
tracker.reset()
p = make([]byte, 1000) // request more than available
n, err = ra.ReadAt(p, int64(2*cs+400))
if err != io.EOF {
t.Errorf("span last+beyond: got err=%v, want EOF", err)
}
wantN := 500 - 400 // only 100 bytes available from offset 2*cs+400
if n != wantN {
t.Errorf("span last+beyond: got n=%d, want %d", n, wantN)
}
if !bytes.Equal(p[:n], plaintext[2*cs+400:]) {
t.Error("span last+beyond: data mismatch")
}
// Test 11: Read spanning second+last chunk and beyond
tracker.reset()
p = make([]byte, cs+1000) // request more than available
n, err = ra.ReadAt(p, int64(cs+100))
if err != io.EOF {
t.Errorf("span 1-2+beyond: got err=%v, want EOF", err)
}
wantN = plaintextSize - (cs + 100)
if n != wantN {
t.Errorf("span 1-2+beyond: got n=%d, want %d", n, wantN)
}
if !bytes.Equal(p[:n], plaintext[cs+100:]) {
t.Error("span 1-2+beyond: data mismatch")
}
// Test 12: Negative offset
tracker.reset()
p = make([]byte, 100)
n, err = ra.ReadAt(p, -1)
if err == nil {
t.Error("negative offset: expected error, got nil")
}
if n != 0 {
t.Errorf("negative offset: got n=%d, want 0", n)
}
// Test 13: Zero-length read in the middle
tracker.reset()
p = make([]byte, 0)
n, err = ra.ReadAt(p, 100)
if err != nil {
t.Errorf("zero-length middle: got err=%v, want nil", err)
}
if n != 0 {
t.Errorf("zero-length middle: got n=%d, want 0", n)
}
// Test 14: Zero-length read at end
tracker.reset()
p = make([]byte, 0)
n, err = ra.ReadAt(p, int64(plaintextSize))
if err != nil {
t.Errorf("zero-length end: got err=%v, want nil", err)
}
if n != 0 {
t.Errorf("zero-length end: got n=%d, want 0", n)
}
// Test 15: Read exactly one chunk at chunk boundary
checkRead("exact chunk at boundary", int64(cs), cs, cs, false, true)
// Test 16: Read one byte at each chunk boundary
checkRead("one byte at start", 0, 1, 1, false, true)
checkRead("one byte at cs-1", int64(cs-1), 1, 1, false, false) // cached from test 15
checkRead("one byte at cs", int64(cs), 1, 1, false, true)
checkRead("one byte at 2*cs-1", int64(2*cs-1), 1, 1, false, false) // same chunk
checkRead("one byte at 2*cs", int64(2*cs), 1, 1, false, true)
checkRead("last byte", int64(plaintextSize-1), 1, 1, true, false) // same chunk, EOF because we reach end
// Test 17: Read crossing exactly one chunk boundary
checkRead("cross boundary 0-1", int64(cs-50), 100, 100, false, true)
checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true)
}
func TestDecryptReaderAtEmpty(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
// Create empty encrypted file
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// Test 1: Read from empty file at offset 0
p := make([]byte, 100)
n, err := ra.ReadAt(p, 0)
if err != io.EOF {
t.Errorf("empty read: got err=%v, want EOF", err)
}
if n != 0 {
t.Errorf("empty read: got n=%d, want 0", n)
}
// Test 2: Zero-length read from empty file
p = make([]byte, 0)
n, err = ra.ReadAt(p, 0)
if err != nil {
t.Errorf("empty zero-length: got err=%v, want nil", err)
}
if n != 0 {
t.Errorf("empty zero-length: got n=%d, want 0", n)
}
// Test 3: Read beyond empty file
p = make([]byte, 100)
n, err = ra.ReadAt(p, 1)
if err == nil {
t.Error("empty beyond: expected error, got nil")
}
if n != 0 {
t.Errorf("empty beyond: got n=%d, want 0", n)
}
}
func TestDecryptReaderAtSingleChunk(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
// Single chunk, not full
plaintext := make([]byte, 1000)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// All reads should use cache (final chunk = only chunk)
p := make([]byte, 100)
n, err := ra.ReadAt(p, 0)
if err != nil {
t.Errorf("single chunk start: got err=%v, want nil", err)
}
if n != 100 {
t.Errorf("single chunk start: got n=%d, want 100", n)
}
if tracker.called {
t.Error("single chunk start: unexpected src.ReadAt call")
}
if !bytes.Equal(p[:n], plaintext[:100]) {
t.Error("single chunk start: data mismatch")
}
// Read at end
n, err = ra.ReadAt(p, 900)
if err != io.EOF {
t.Errorf("single chunk end: got err=%v, want EOF", err)
}
if n != 100 {
t.Errorf("single chunk end: got n=%d, want 100", n)
}
if tracker.called {
t.Error("single chunk end: unexpected src.ReadAt call")
}
if !bytes.Equal(p[:n], plaintext[900:]) {
t.Error("single chunk end: data mismatch")
}
}
func TestDecryptReaderAtFullChunks(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
// Exactly 2 full chunks
plaintext := make([]byte, 2*cs)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)}
ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
tracker.reset()
// Read last byte of second chunk (cached)
p := make([]byte, 1)
n, err := ra.ReadAt(p, int64(2*cs-1))
if err != io.EOF {
t.Errorf("last byte: got err=%v, want EOF", err)
}
if n != 1 {
t.Errorf("last byte: got n=%d, want 1", n)
}
if tracker.called {
t.Error("last byte: unexpected src.ReadAt call (should be cached)")
}
if p[0] != plaintext[2*cs-1] {
t.Error("last byte: data mismatch")
}
// Read at exactly the boundary between chunks
p = make([]byte, 100)
n, err = ra.ReadAt(p, int64(cs-50))
if err != nil {
t.Errorf("boundary: got err=%v, want nil", err)
}
if n != 100 {
t.Errorf("boundary: got n=%d, want 100", n)
}
if !bytes.Equal(p, plaintext[cs-50:cs+50]) {
t.Error("boundary: data mismatch")
}
}
func TestDecryptReaderAtWrongKey(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
plaintext := make([]byte, 1000)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
// Try to decrypt with wrong key
wrongKey := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(wrongKey); err != nil {
t.Fatal(err)
}
_, err = stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err == nil {
t.Error("wrong key: expected error, got nil")
}
}
func TestDecryptReaderAtInvalidSize(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
plaintext := make([]byte, 1000)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
// Wrong size (too small)
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1))
if err == nil {
t.Error("wrong size (small): expected error, got nil")
}
// Wrong size (too large)
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)+1))
if err == nil {
t.Error("wrong size (large): expected error, got nil")
}
// Size that would imply empty final chunk (invalid)
// This would be: one full encrypted chunk + just overhead
invalidSize := int64(cs + chacha20poly1305.Overhead + chacha20poly1305.Overhead)
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(make([]byte, invalidSize)), invalidSize)
if err == nil {
t.Error("invalid size (empty final chunk): expected error, got nil")
}
}
func TestDecryptReaderAtTruncated(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
plaintext := make([]byte, 2*cs+500)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
// Truncate ciphertext but lie about size
truncated := ciphertext[:len(ciphertext)-100]
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), int64(len(ciphertext)))
if err == nil {
t.Error("truncated: expected error, got nil")
}
}
func TestDecryptReaderAtTruncatedChunk(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
// Create 4 chunks: 3 full + 1 partial
plaintext := make([]byte, 3*cs+500)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
// Truncate to 3 chunks (remove the actual final chunk)
// The third chunk was NOT encrypted with the last chunk flag,
// so decryption should fail when we try to use it as the final chunk.
encChunkSize := cs + 16 // ChunkSize + Overhead
truncatedSize := int64(3 * encChunkSize)
truncated := ciphertext[:truncatedSize]
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize)
if err == nil {
t.Error("truncated at chunk boundary: expected error, got nil")
}
}
func TestDecryptReaderAtConcurrent(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
// Create plaintext spanning 3 chunks: 2 full + partial
plaintextSize := 2*cs + 500
plaintext := make([]byte, plaintextSize)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
// Encrypt
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := buf.Bytes()
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)))
if err != nil {
t.Fatal(err)
}
t.Run("same chunk", func(t *testing.T) {
t.Parallel()
const goroutines = 10
const iterations = 100
errc := make(chan error, goroutines)
for g := range goroutines {
go func(id int) {
for i := range iterations {
off := int64((id*iterations + i) % 500)
p := make([]byte, 100)
n, err := ra.ReadAt(p, off)
if err != nil {
errc <- fmt.Errorf("goroutine %d iter %d: %v", id, i, err)
return
}
if n != 100 {
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want 100", id, i, n)
return
}
if !bytes.Equal(p, plaintext[off:off+100]) {
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
return
}
}
errc <- nil
}(g)
}
for range goroutines {
if err := <-errc; err != nil {
t.Error(err)
}
}
})
t.Run("different chunks", func(t *testing.T) {
t.Parallel()
const goroutines = 10
const iterations = 100
errc := make(chan error, goroutines)
for g := range goroutines {
go func(id int) {
for i := range iterations {
// Each goroutine reads from a different chunk based on id
chunkIdx := id % 3
off := int64(chunkIdx*cs + (i % 400))
size := 100
if off+int64(size) > int64(plaintextSize) {
size = plaintextSize - int(off)
}
p := make([]byte, size)
n, err := ra.ReadAt(p, off)
if n == size && err == io.EOF {
err = nil // EOF at end is acceptable
}
if err != nil {
errc <- fmt.Errorf("goroutine %d iter %d: off=%d: %v", id, i, off, err)
return
}
if n != size {
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
return
}
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
return
}
}
errc <- nil
}(g)
}
for range goroutines {
if err := <-errc; err != nil {
t.Error(err)
}
}
})
t.Run("across chunks", func(t *testing.T) {
t.Parallel()
const goroutines = 10
const iterations = 100
errc := make(chan error, goroutines)
for g := range goroutines {
go func(id int) {
for i := range iterations {
// Read across chunk boundaries
boundary := (id%2 + 1) * cs // either cs or 2*cs
off := int64(boundary - 50 + (i % 30))
size := 100
if off+int64(size) > int64(plaintextSize) {
size = plaintextSize - int(off)
}
if size <= 0 {
continue
}
p := make([]byte, size)
n, err := ra.ReadAt(p, off)
if n == size && err == io.EOF {
err = nil
}
if err != nil {
errc <- fmt.Errorf("goroutine %d iter %d: off=%d size=%d: %v", id, i, off, size, err)
return
}
if n != size {
errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size)
return
}
if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) {
errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i)
return
}
}
errc <- nil
}(g)
}
for range goroutines {
if err := <-errc; err != nil {
t.Error(err)
}
}
})
}
func TestDecryptReaderAtCorrupted(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
plaintext := make([]byte, 2*cs+500)
if _, err := rand.Read(plaintext); err != nil {
t.Fatal(err)
}
buf := &bytes.Buffer{}
w, err := stream.NewEncryptWriter(key, buf)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(plaintext); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
ciphertext := bytes.Clone(buf.Bytes())
// Corrupt final chunk - should fail in constructor
corruptedFinal := bytes.Clone(ciphertext)
corruptedFinal[len(corruptedFinal)-10] ^= 0xFF
_, err = stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal)))
if err == nil {
t.Error("corrupted final: expected error, got nil")
}
// Corrupt first chunk - should fail on read
corruptedFirst := bytes.Clone(ciphertext)
corruptedFirst[10] ^= 0xFF
ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFirst), int64(len(corruptedFirst)))
if err != nil {
t.Fatalf("corrupted first constructor: unexpected error: %v", err)
}
p := make([]byte, 100)
_, err = ra.ReadAt(p, 0)
if err == nil {
t.Error("corrupted first read: expected error, got nil")
}
}
+89
View File
@@ -0,0 +1,89 @@
package term
import (
"fmt"
"os"
"runtime"
"golang.org/x/term"
)
// enableVirtualTerminalProcessing tries to enable virtual terminal processing
// on Windows. If it fails, avoid using escape sequences to prevent weird
// characters being printed to the console.
var enableVirtualTerminalProcessing func(out *os.File) error
// clearLine clears the current line on the terminal, or opens a new line if
// terminal escape codes don't work.
func clearLine(out *os.File) {
const (
CUI = "\033[" // Control Sequence Introducer
CPL = CUI + "F" // Cursor Previous Line
EL = CUI + "K" // Erase in Line
)
// First, open a new line, which is guaranteed to work everywhere. Then, try
// to erase the line above with escape codes, if possible.
//
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
// cursor would not go back to the start of the line with a simple LF.
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
fmt.Fprintf(out, "\r\n")
if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil {
fmt.Fprintf(out, CPL+EL)
}
}
// WithTerminal runs f with the terminal input and output files, if available.
// WithTerminal does not open a non-terminal stdin, so the caller does not need
// to check if stdin is in use.
func WithTerminal(f func(in, out *os.File) error) error {
if runtime.GOOS == "windows" {
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
return err
}
defer out.Close()
return f(in, out)
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()
return f(tty, tty)
} else if term.IsTerminal(int(os.Stdin.Fd())) {
return f(os.Stdin, os.Stdin)
} else {
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
}
}
// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral.
func ReadSecret(prompt string) (s []byte, err error) {
err = WithTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
defer clearLine(out)
s, err = term.ReadPassword(int(in.Fd()))
return err
})
return
}
// ReadPassphrase returns the passphrase from the environment variable env if
// it is set, otherwise prompts on the terminal.
func ReadPassphrase(prompt, env string) (string, error) {
if p, ok := os.LookupEnv(env); ok {
if p == "" {
return "", fmt.Errorf("%s is set but empty", env)
}
return p, nil
}
p, err := ReadSecret(prompt)
if err != nil {
return "", fmt.Errorf("could not read passphrase (set %s for non-interactive use): %v", env, err)
}
return string(p), nil
}
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package term
import (
"errors"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func init() {
enableVirtualTerminalProcessing = func(out *os.File) error {
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
// do not have the virtual terminal processing enabled, which is necessary to
// make terminal escape sequences work. For this reason the clearLine function
// may not properly work. Here we enable the virtual terminal processing, if
// possible.
//
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
const (
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
)
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
var mode uint32
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
return err
}
mode |= ENABLE_PROCESSED_OUTPUT
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
// If the SetConsoleMode function fails, the return value is zero.
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
return errors.New("SetConsoleMode failed")
}
return nil
}
}