Implement StreamEncrypt/StreamDecrypt using 1 MiB ChaCha20-Poly1305 blocks with the STIM v2 wire format (magic header, Argon2id salt/params, per-block random nonces, and zero-length EOF marker). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
198 lines
5.5 KiB
Go
198 lines
5.5 KiB
Go
package tim
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
"golang.org/x/crypto/chacha20poly1305"
|
|
|
|
borgtrix "github.com/Snider/Borg/pkg/trix"
|
|
)
|
|
|
|
const (
|
|
blockSize = 1024 * 1024 // 1 MiB plaintext blocks
|
|
saltSize = 16
|
|
nonceSize = 12 // chacha20poly1305.NonceSize
|
|
lengthSize = 4
|
|
headerSize = 33 // 4 (magic) + 1 (version) + 16 (salt) + 12 (argon2 params)
|
|
)
|
|
|
|
var (
|
|
stimMagic = [4]byte{'S', 'T', 'I', 'M'}
|
|
|
|
ErrInvalidMagic = errors.New("invalid STIM magic header")
|
|
ErrUnsupportedVersion = errors.New("unsupported STIM version")
|
|
ErrStreamDecrypt = errors.New("stream decryption failed")
|
|
)
|
|
|
|
// StreamEncrypt reads plaintext from r and writes STIM v2 chunked AEAD
|
|
// encrypted data to w. Each 1 MiB block is independently encrypted with
|
|
// ChaCha20-Poly1305 using a unique random nonce.
|
|
func StreamEncrypt(r io.Reader, w io.Writer, password string) error {
|
|
// Generate random salt
|
|
salt := make([]byte, saltSize)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return fmt.Errorf("failed to generate salt: %w", err)
|
|
}
|
|
|
|
// Derive key using Argon2id with default params
|
|
params := borgtrix.DefaultArgon2Params()
|
|
key := borgtrix.DeriveKeyArgon2(password, salt)
|
|
|
|
// Create AEAD cipher
|
|
aead, err := chacha20poly1305.New(key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create AEAD: %w", err)
|
|
}
|
|
|
|
// Write header: magic(4) + version(1) + salt(16) + argon2params(12) = 33 bytes
|
|
header := make([]byte, headerSize)
|
|
copy(header[0:4], stimMagic[:])
|
|
header[4] = 2 // version
|
|
copy(header[5:21], salt)
|
|
copy(header[21:33], params.Encode())
|
|
|
|
if _, err := w.Write(header); err != nil {
|
|
return fmt.Errorf("failed to write header: %w", err)
|
|
}
|
|
|
|
// Encrypt data in blocks
|
|
buf := make([]byte, blockSize)
|
|
nonce := make([]byte, nonceSize)
|
|
|
|
for {
|
|
n, readErr := io.ReadFull(r, buf)
|
|
|
|
if n > 0 {
|
|
// Generate unique nonce for this block
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return fmt.Errorf("failed to generate nonce: %w", err)
|
|
}
|
|
|
|
// Encrypt: ciphertext includes the Poly1305 auth tag (16 bytes)
|
|
ciphertext := aead.Seal(nil, nonce, buf[:n], nil)
|
|
|
|
// Write [nonce(12)][length(4)][ciphertext(n+16)]
|
|
if _, err := w.Write(nonce); err != nil {
|
|
return fmt.Errorf("failed to write nonce: %w", err)
|
|
}
|
|
|
|
lenBuf := make([]byte, lengthSize)
|
|
binary.LittleEndian.PutUint32(lenBuf, uint32(len(ciphertext)))
|
|
if _, err := w.Write(lenBuf); err != nil {
|
|
return fmt.Errorf("failed to write length: %w", err)
|
|
}
|
|
|
|
if _, err := w.Write(ciphertext); err != nil {
|
|
return fmt.Errorf("failed to write ciphertext: %w", err)
|
|
}
|
|
}
|
|
|
|
if readErr != nil {
|
|
if readErr == io.EOF || readErr == io.ErrUnexpectedEOF {
|
|
break
|
|
}
|
|
return fmt.Errorf("failed to read input: %w", readErr)
|
|
}
|
|
}
|
|
|
|
// Write EOF marker: [nonce(12)][length=0(4)]
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return fmt.Errorf("failed to generate EOF nonce: %w", err)
|
|
}
|
|
if _, err := w.Write(nonce); err != nil {
|
|
return fmt.Errorf("failed to write EOF nonce: %w", err)
|
|
}
|
|
|
|
eofLen := make([]byte, lengthSize)
|
|
// length is already zero (zero-value)
|
|
if _, err := w.Write(eofLen); err != nil {
|
|
return fmt.Errorf("failed to write EOF length: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// StreamDecrypt reads STIM v2 chunked AEAD encrypted data from r and writes
|
|
// the decrypted plaintext to w. Returns an error if the header is invalid,
|
|
// the password is wrong, or data has been tampered with.
|
|
func StreamDecrypt(r io.Reader, w io.Writer, password string) error {
|
|
// Read header
|
|
header := make([]byte, headerSize)
|
|
if _, err := io.ReadFull(r, header); err != nil {
|
|
return fmt.Errorf("failed to read header: %w", err)
|
|
}
|
|
|
|
// Validate magic
|
|
if header[0] != stimMagic[0] || header[1] != stimMagic[1] ||
|
|
header[2] != stimMagic[2] || header[3] != stimMagic[3] {
|
|
return ErrInvalidMagic
|
|
}
|
|
|
|
// Validate version
|
|
if header[4] != 2 {
|
|
return fmt.Errorf("%w: got %d", ErrUnsupportedVersion, header[4])
|
|
}
|
|
|
|
// Extract salt and params
|
|
salt := header[5:21]
|
|
params := borgtrix.DecodeArgon2Params(header[21:33])
|
|
|
|
// Derive key using stored params
|
|
key := deriveKeyWithParams(password, salt, params)
|
|
|
|
// Create AEAD cipher
|
|
aead, err := chacha20poly1305.New(key)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create AEAD: %w", err)
|
|
}
|
|
|
|
// Decrypt blocks
|
|
nonce := make([]byte, nonceSize)
|
|
lenBuf := make([]byte, lengthSize)
|
|
|
|
for {
|
|
// Read nonce
|
|
if _, err := io.ReadFull(r, nonce); err != nil {
|
|
return fmt.Errorf("failed to read block nonce: %w", err)
|
|
}
|
|
|
|
// Read length
|
|
if _, err := io.ReadFull(r, lenBuf); err != nil {
|
|
return fmt.Errorf("failed to read block length: %w", err)
|
|
}
|
|
|
|
ctLen := binary.LittleEndian.Uint32(lenBuf)
|
|
|
|
// EOF marker: length == 0
|
|
if ctLen == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Read ciphertext
|
|
ciphertext := make([]byte, ctLen)
|
|
if _, err := io.ReadFull(r, ciphertext); err != nil {
|
|
return fmt.Errorf("failed to read ciphertext: %w", err)
|
|
}
|
|
|
|
// Decrypt and authenticate
|
|
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrStreamDecrypt, err)
|
|
}
|
|
|
|
if _, err := w.Write(plaintext); err != nil {
|
|
return fmt.Errorf("failed to write plaintext: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// deriveKeyWithParams derives a 32-byte key using Argon2id with specific
|
|
// parameters read from the STIM header (rather than using defaults).
|
|
func deriveKeyWithParams(password string, salt []byte, params borgtrix.Argon2Params) []byte {
|
|
return argon2.IDKey([]byte(password), salt, params.Time, params.Memory, uint8(params.Threads), 32)
|
|
}
|