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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user