All checks were successful
check / check (pull_request) Successful in 3m11s
Three findings remain that cannot be fixed without making a repo-wide naming decision, so each carries a per-site //nolint directive with its justification. revive var-naming (internal/log, internal/crypto, internal/types): fixing these means renaming packages across the whole codebase, which is the repo owner's call, not a lint fix. Neither stdlib log nor stdlib crypto is imported anywhere in the repo, so nothing is actually shadowed today. The rename decision is tracked in issue #76. revive reports a package-name failure only once per package directory, on whichever file it happens to lint first, so every file of the affected packages carries the directive and lists nolintlint alongside revive so the ones that lose the race are not reported as unused. No gosec directives are needed: the pinned golangci-lint v2.12.2 that CI and the Dockerfile use reports nothing at the term.IsTerminal conversions in internal/log and internal/ui or at the os.Remove calls in internal/vaultik/verify.go, so suppressing there would itself fail nolintlint as an unused directive. Verification is script/cibuild, which builds the hash-pinned lint image: it exits 0, with make lint reporting "0 issues" under the canonical .golangci.yml (sha256 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb, unmodified), plus make fmt-check, make test and the release build. That also unblocks issue #59. make check is not a valid gate here: script/lint runs whatever golangci-lint is on PATH rather than the pinned version, which is tracked in issue #78. Also corrects the capacity comment in collectBatchFlushData: an empty file contributes no chunk mappings, so the pending-file count is a rough starting capacity, not a lower bound. TODO.md: record this work, note that the previous next step (reconciling uncommitted ARCHITECTURE.md edits) needed no work because the tree is clean, and move the next step on to the stale-branch triage.
225 lines
6.7 KiB
Go
225 lines
6.7 KiB
Go
// Package crypto provides thread-safe age encryption and decryption
|
|
// helpers used to protect blob and metadata content.
|
|
package crypto //nolint:revive,nolintlint // stdlib crypto unused; see #76
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
|
|
"filippo.io/age"
|
|
"go.uber.org/fx"
|
|
)
|
|
|
|
// ErrNoRecipients is returned when an encryptor is created or updated
|
|
// without any recipient public keys.
|
|
var ErrNoRecipients = errors.New("at least one recipient is required")
|
|
|
|
// Encryptor provides thread-safe encryption using the age encryption library.
|
|
// It supports encrypting data for multiple recipients simultaneously, allowing
|
|
// any of the corresponding private keys to decrypt the data. This is useful
|
|
// for backup scenarios where multiple parties should be able to decrypt the data.
|
|
type Encryptor struct {
|
|
recipients []age.Recipient
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewEncryptor creates a new encryptor with the given age public keys.
|
|
// Each public key should be a valid age X25519 recipient string (e.g., "age1...")
|
|
// At least one recipient must be provided. Returns an error if any of the
|
|
// public keys are invalid or if no recipients are specified.
|
|
func NewEncryptor(publicKeys []string) (*Encryptor, error) {
|
|
if len(publicKeys) == 0 {
|
|
return nil, ErrNoRecipients
|
|
}
|
|
|
|
recipients := make([]age.Recipient, 0, len(publicKeys))
|
|
for _, key := range publicKeys {
|
|
recipient, err := age.ParseX25519Recipient(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing age recipient %s: %w", key, err)
|
|
}
|
|
|
|
recipients = append(recipients, recipient)
|
|
}
|
|
|
|
return &Encryptor{
|
|
recipients: recipients,
|
|
}, nil
|
|
}
|
|
|
|
// Encrypt encrypts data using age encryption for all configured recipients.
|
|
// The encrypted data can be decrypted by any of the corresponding private keys.
|
|
// This method is suitable for small to medium amounts of data that fit in memory.
|
|
// For large data streams, use EncryptStream or EncryptWriter instead.
|
|
func (e *Encryptor) Encrypt(data []byte) ([]byte, error) {
|
|
e.mu.RLock()
|
|
recipients := e.recipients
|
|
e.mu.RUnlock()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
// Create encrypted writer for all recipients
|
|
w, err := age.Encrypt(&buf, recipients...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating encrypted writer: %w", err)
|
|
}
|
|
|
|
// Write data
|
|
_, err = w.Write(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("writing encrypted data: %w", err)
|
|
}
|
|
|
|
// Close to flush
|
|
err = w.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("closing encrypted writer: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// EncryptStream encrypts data from reader to writer using age encryption.
|
|
// This method is suitable for encrypting large files or streams as it processes
|
|
// data in a streaming fashion without loading everything into memory.
|
|
// The encrypted data is written directly to the destination writer.
|
|
func (e *Encryptor) EncryptStream(dst io.Writer, src io.Reader) error {
|
|
e.mu.RLock()
|
|
recipients := e.recipients
|
|
e.mu.RUnlock()
|
|
|
|
// Create encrypted writer for all recipients
|
|
w, err := age.Encrypt(dst, recipients...)
|
|
if err != nil {
|
|
return fmt.Errorf("creating encrypted writer: %w", err)
|
|
}
|
|
|
|
// Copy data
|
|
_, err = io.Copy(w, src)
|
|
if err != nil {
|
|
return fmt.Errorf("copying encrypted data: %w", err)
|
|
}
|
|
|
|
// Close to flush
|
|
err = w.Close()
|
|
if err != nil {
|
|
return fmt.Errorf("closing encrypted writer: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EncryptWriter creates a writer that encrypts data written to it.
|
|
// All data written to the returned WriteCloser will be encrypted and written
|
|
// to the destination writer. The caller must call Close() on the returned
|
|
// writer to ensure all encrypted data is properly flushed and finalized.
|
|
// This is useful for integrating encryption into existing writer-based pipelines.
|
|
func (e *Encryptor) EncryptWriter(dst io.Writer) (io.WriteCloser, error) {
|
|
e.mu.RLock()
|
|
recipients := e.recipients
|
|
e.mu.RUnlock()
|
|
|
|
// Create encrypted writer for all recipients
|
|
w, err := age.Encrypt(dst, recipients...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating encrypted writer: %w", err)
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// UpdateRecipients updates the recipients for future encryption operations.
|
|
// This method is thread-safe and can be called while other encryption operations
|
|
// are in progress. Existing encryption operations will continue with the old
|
|
// recipients. At least one recipient must be provided. Returns an error if any
|
|
// of the public keys are invalid or if no recipients are specified.
|
|
func (e *Encryptor) UpdateRecipients(publicKeys []string) error {
|
|
if len(publicKeys) == 0 {
|
|
return ErrNoRecipients
|
|
}
|
|
|
|
recipients := make([]age.Recipient, 0, len(publicKeys))
|
|
for _, key := range publicKeys {
|
|
recipient, err := age.ParseX25519Recipient(key)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing age recipient %s: %w", key, err)
|
|
}
|
|
|
|
recipients = append(recipients, recipient)
|
|
}
|
|
|
|
e.mu.Lock()
|
|
e.recipients = recipients
|
|
e.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Decryptor provides thread-safe decryption using the age encryption library.
|
|
// It uses a private key to decrypt data that was encrypted for the corresponding
|
|
// public key.
|
|
type Decryptor struct {
|
|
identity age.Identity
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewDecryptor creates a new decryptor with the given age private key.
|
|
// The private key should be a valid age X25519 identity string.
|
|
// Returns an error if the private key is invalid.
|
|
func NewDecryptor(privateKey string) (*Decryptor, error) {
|
|
identity, err := age.ParseX25519Identity(privateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing age identity: %w", err)
|
|
}
|
|
|
|
return &Decryptor{
|
|
identity: identity,
|
|
}, nil
|
|
}
|
|
|
|
// Decrypt decrypts data using age decryption.
|
|
// This method is suitable for small to medium amounts of data that fit in memory.
|
|
// For large data streams, use DecryptStream instead.
|
|
func (d *Decryptor) Decrypt(data []byte) ([]byte, error) {
|
|
d.mu.RLock()
|
|
identity := d.identity
|
|
d.mu.RUnlock()
|
|
|
|
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating decrypted reader: %w", err)
|
|
}
|
|
|
|
decrypted, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading decrypted data: %w", err)
|
|
}
|
|
|
|
return decrypted, nil
|
|
}
|
|
|
|
// DecryptStream returns a reader that decrypts data from the provided reader.
|
|
// This method is suitable for decrypting large files or streams as it processes
|
|
// data in a streaming fashion without loading everything into memory.
|
|
// The caller should close the input reader when done.
|
|
func (d *Decryptor) DecryptStream(src io.Reader) (io.Reader, error) {
|
|
d.mu.RLock()
|
|
identity := d.identity
|
|
d.mu.RUnlock()
|
|
|
|
r, err := age.Decrypt(src, identity)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating decrypted reader: %w", err)
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
// Module exports the crypto module for fx dependency injection.
|
|
//
|
|
//nolint:gochecknoglobals // fx module definitions are package globals
|
|
var Module = fx.Module("crypto")
|