Compare commits
1
Commits
next
..
46c295acf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c295acf3 |
+2
-1
@@ -286,6 +286,7 @@ Key methods:
|
||||
- `CreateSnapshot(ctx, hostname, version, commit)` → Create snapshot record
|
||||
- `CompleteSnapshot(ctx, snapshotID)` → Mark snapshot complete
|
||||
- `ExportSnapshotMetadata(ctx, dbPath, snapshotID)` → Export to S3
|
||||
- `CleanupIncompleteSnapshots(ctx, hostname)` → Remove failed snapshots
|
||||
|
||||
### `internal/database`
|
||||
SQLite database for local index. Single-writer mode for thread safety.
|
||||
@@ -306,7 +307,7 @@ Repository interfaces:
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
├─► CleanupIncompleteSnapshots() // Critical: avoid dedup errors
|
||||
│
|
||||
├─► SnapshotManager.CreateSnapshot() // Create DB record
|
||||
│
|
||||
|
||||
@@ -71,19 +71,14 @@ Requirements that no existing tool meets:
|
||||
## daily use
|
||||
|
||||
```sh
|
||||
# verify a snapshot (shallow: checks all blobs are present with the listed size)
|
||||
# verify a snapshot (shallow: checks all blobs exist)
|
||||
vaultik snapshot verify <snapshot-id>
|
||||
|
||||
# put the private key file in the environment (reading it from the file
|
||||
# keeps the key out of your shell history); the whole age-keygen file,
|
||||
# with one or more identities, is accepted
|
||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||
|
||||
# deep verify (downloads and cryptographically verifies every blob)
|
||||
vaultik snapshot verify --deep <snapshot-id>
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot verify --deep <snapshot-id>
|
||||
|
||||
# restore (requires the private key)
|
||||
vaultik snapshot restore <snapshot-id> /tmp/restored
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' vaultik snapshot restore <snapshot-id> /tmp/restored
|
||||
|
||||
# daily cron job: back up, keep a 4-week rolling window of snapshots
|
||||
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
|
||||
@@ -124,17 +119,15 @@ Use that remote key — the hex printed inside `<remote only:...>`, or the
|
||||
full `remote_key` from `snapshot list --json` — to restore and verify:
|
||||
|
||||
```sh
|
||||
# put the private key file in the environment (reading it from the file
|
||||
# keeps the key out of your shell history)
|
||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||
|
||||
# restore everything to /tmp/restored, then check every restored file's
|
||||
# chunk hashes
|
||||
vaultik snapshot restore --verify <remote-key> /tmp/restored
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' \
|
||||
vaultik snapshot restore --verify <remote-key> /tmp/restored
|
||||
|
||||
# optionally, deep-verify the snapshot against the store (downloads and
|
||||
# cryptographically checks every blob)
|
||||
vaultik snapshot verify --deep <remote-key>
|
||||
VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...' \
|
||||
vaultik snapshot verify --deep <remote-key>
|
||||
```
|
||||
|
||||
`age_recipients` (the public key) is not needed to restore — only the
|
||||
@@ -224,7 +217,7 @@ and `vaultik prune --json | jq .` both work as written.
|
||||
|
||||
### environment variables
|
||||
|
||||
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`). May hold the whole `age-keygen` file — comments and every identity in it are accepted. Set it from the file, e.g. `export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so the key is not typed into your shell history.
|
||||
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`)
|
||||
* `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
||||
* `VAULTIK_INDEX_PATH`: Override local SQLite index path
|
||||
* `VAULTIK_CPUPROFILE`: Write a CPU profile to this path for the duration of the run (development/debugging)
|
||||
@@ -319,9 +312,7 @@ local index alone, and still exits zero.
|
||||
logger, so stdout stays a single parseable document.
|
||||
|
||||
**`snapshot verify`**: Verify snapshot integrity.
|
||||
* Default (shallow): checks that every blob the manifest lists is present in
|
||||
storage with the size the manifest records, and that the encrypted database is
|
||||
present. It does not read blob contents.
|
||||
* Default (shallow): checks that all blobs referenced in the manifest exist in storage
|
||||
* `--deep`: Downloads and decrypts each blob, verifies chunk hashes against the
|
||||
encrypted metadata database
|
||||
* Accepts the same identifiers as `snapshot restore`: a snapshot ID, or a
|
||||
|
||||
@@ -25,29 +25,6 @@ release" is exactly the contradiction
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-22: Validated blob hashes, offsets and lengths read back from
|
||||
the destination before using them
|
||||
([issue #155](https://git.eeqj.de/sneak/vaultik/issues/155)). A blob
|
||||
hash taken from the downloaded database or the store listing was
|
||||
trusted unchecked, so a hostile remote could drive a decrypted blob to
|
||||
be written outside the cache directory (a hash like `aa/../../etc`) or
|
||||
crash a command with a short or negative value. `blobDiskCache.path`
|
||||
now refuses any key containing a path separator, and `ReadAt` rejects a
|
||||
negative offset or length and bounds with `length > size-offset` so a
|
||||
sum cannot overflow past the check. A new `isBlobHash` helper (a plain
|
||||
function, not a method — the packer stores `temp-placeholder-{uuid}` as
|
||||
a hash) gates `FetchBlob`, shallow and deep verify; the `blobs/` and
|
||||
`metadata/` listings skip a non-conforming name with a warning; and
|
||||
short-hash prefixes in log and error text go through a `shortHash`
|
||||
helper that cannot panic. `verify`'s chunk reader also rejects a
|
||||
negative `blob_chunks` length and streams the chunk instead of
|
||||
allocating a database-supplied size. `restore.go` and
|
||||
`internal/database` were left untouched to avoid colliding with the
|
||||
in-flight [issue #156](https://git.eeqj.de/sneak/vaultik/issues/156)
|
||||
work; the cache-path and `FetchBlob` guards already stop the unsafe
|
||||
write and fetch, so restore's own `buildBlobIndexes` early check is
|
||||
deferred as fail-fast defense in depth.
|
||||
|
||||
- 2026-09-21: Stopped an interrupted blob upload from making a later
|
||||
backup deduplicate against data that was never stored
|
||||
([issue #148](https://git.eeqj.de/sneak/vaultik/issues/148)). The
|
||||
|
||||
+5
-5
@@ -257,16 +257,16 @@ exclude:
|
||||
|
||||
# Storage URL - use either this OR the s3 section below
|
||||
# Supports: s3://bucket/prefix, file:///path, rclone://remote/path
|
||||
storage_url: "rclone://myremote/path/to/backups"
|
||||
storage_url: "rclone://las1stor1//srv/pool.2024.04/backups/heraklion"
|
||||
|
||||
# S3-compatible storage configuration
|
||||
#s3:
|
||||
# # S3-compatible endpoint URL
|
||||
# # Examples: https://s3.amazonaws.com, https://storage.googleapis.com
|
||||
# endpoint: https://s3.example.com
|
||||
# endpoint: http://10.100.205.122:8333
|
||||
#
|
||||
# # Bucket name where backups will be stored
|
||||
# bucket: mybucket
|
||||
# bucket: testbucket
|
||||
#
|
||||
# # Prefix (folder) within the bucket for this host's backups
|
||||
# # Useful for organizing backups from multiple hosts
|
||||
@@ -274,8 +274,8 @@ storage_url: "rclone://myremote/path/to/backups"
|
||||
# #prefix: "hosts/myserver/"
|
||||
#
|
||||
# # S3 access credentials
|
||||
# access_key_id: YOUR_ACCESS_KEY
|
||||
# secret_access_key: YOUR_SECRET_KEY
|
||||
# access_key_id: Z9GT22M9YFU08WRMC5D4
|
||||
# secret_access_key: Pi0tPKjFbN4rZlRhcA4zBtEkib04yy2WcIzI+AXk
|
||||
#
|
||||
# # S3 region
|
||||
# # Default: us-east-1
|
||||
|
||||
@@ -487,7 +487,7 @@ func (p *Packer) closeBlobWriter() (string, int64, error) {
|
||||
return "", 0, fmt.Errorf("seeking to start: %w", err)
|
||||
}
|
||||
|
||||
finalHash := p.currentBlob.writer.ContentID()
|
||||
finalHash := p.currentBlob.writer.Sum256()
|
||||
|
||||
return hex.EncodeToString(finalHash), finalSize, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package blobgen implements the blob data pipeline: streaming zstd
|
||||
// compression, age encryption, and SHA256 content hashing for blob
|
||||
// creation, plus the matching decrypt/decompress/verify reader.
|
||||
package blobgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// CompressResult contains the results of compression
|
||||
type CompressResult struct {
|
||||
Data []byte
|
||||
UncompressedSize int64
|
||||
CompressedSize int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// CompressData compresses and encrypts data, returning the result with hash
|
||||
func CompressData(
|
||||
data []byte, compressionLevel int, recipients []string,
|
||||
) (*CompressResult, error) {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Create writer
|
||||
w, err := NewWriter(&buf, compressionLevel, recipients)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating writer: %w", err)
|
||||
}
|
||||
|
||||
// Write data
|
||||
_, err = w.Write(data)
|
||||
if err != nil {
|
||||
_ = w.Close()
|
||||
|
||||
return nil, fmt.Errorf("writing data: %w", err)
|
||||
}
|
||||
|
||||
// Close to flush
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("closing writer: %w", err)
|
||||
}
|
||||
|
||||
return &CompressResult{
|
||||
Data: buf.Bytes(),
|
||||
UncompressedSize: int64(len(data)),
|
||||
CompressedSize: int64(buf.Len()),
|
||||
SHA256: hex.EncodeToString(w.Sum256()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompressStream compresses and encrypts from reader to writer, returning
|
||||
// the number of uncompressed bytes written and the content hash.
|
||||
func CompressStream(
|
||||
dst io.Writer, src io.Reader, compressionLevel int, recipients []string,
|
||||
) (int64, string, error) {
|
||||
// Create writer
|
||||
w, err := NewWriter(dst, compressionLevel, recipients)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("creating writer: %w", err)
|
||||
}
|
||||
|
||||
closed := false
|
||||
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = w.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Copy data
|
||||
_, err = io.Copy(w, src)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("copying data: %w", err)
|
||||
}
|
||||
|
||||
// Close to flush
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("closing writer: %w", err)
|
||||
}
|
||||
|
||||
closed = true
|
||||
|
||||
return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// testRecipient is a static age recipient for tests.
|
||||
const testRecipient = "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrtmu62kv3s89gmvv"
|
||||
|
||||
// TestCompressStreamNoDoubleClose is a regression test for issue #28.
|
||||
// It verifies that CompressStream does not panic or return an error due to
|
||||
// double-closing the underlying blobgen.Writer. Before the fix in PR #33,
|
||||
// the explicit Close() on the happy path combined with defer Close() would
|
||||
// cause a double close.
|
||||
func TestCompressStreamNoDoubleClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := []byte("regression test data for issue #28 double-close fix")
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
written, hash, err := blobgen.CompressStream(
|
||||
&buf, bytes.NewReader(input), 3, []string{testRecipient})
|
||||
require.NoError(t, err, "CompressStream should not return an error")
|
||||
assert.Positive(t, written, "expected bytes written > 0")
|
||||
assert.NotEmpty(t, hash, "expected non-empty hash")
|
||||
assert.Positive(t, buf.Len(), "expected non-empty output")
|
||||
}
|
||||
|
||||
// TestCompressStreamLargeInput exercises CompressStream with a larger payload
|
||||
// to ensure no double-close issues surface under heavier I/O.
|
||||
func TestCompressStreamLargeInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data := make([]byte, 512*1024) // 512 KB
|
||||
_, err := rand.Read(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
written, hash, err := blobgen.CompressStream(
|
||||
&buf, bytes.NewReader(data), 3, []string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, written)
|
||||
assert.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
// TestCompressStreamEmptyInput verifies CompressStream handles empty input
|
||||
// without double-close issues.
|
||||
func TestCompressStreamEmptyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, hash, err := blobgen.CompressStream(
|
||||
&buf, strings.NewReader(""), 3, []string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
// TestCompressDataNoDoubleClose mirrors the stream test for CompressData,
|
||||
// ensuring the explicit Close + error-path Close pattern is also safe.
|
||||
func TestCompressDataNoDoubleClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := []byte("CompressData regression test for double-close")
|
||||
|
||||
result, err := blobgen.CompressData(input, 3, []string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, result.CompressedSize)
|
||||
assert.Equal(t, result.UncompressedSize, int64(len(input)))
|
||||
assert.NotEmpty(t, result.SHA256)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// ageChunkSize is age's STREAM plaintext chunk size (64 KiB); each encrypted
|
||||
// chunk adds a 16-byte ChaCha20-Poly1305 tag.
|
||||
const (
|
||||
ageChunkSize = 64 * 1024
|
||||
ageChunkTagSize = 16
|
||||
ageSegmentSize = ageChunkSize + ageChunkTagSize
|
||||
ageNonceSize = 16
|
||||
)
|
||||
|
||||
// makeIdentity returns a fresh X25519 identity and its recipient string.
|
||||
func makeIdentity(t *testing.T) (*age.X25519Identity, string) {
|
||||
t.Helper()
|
||||
|
||||
id, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
return id, id.Recipient().String()
|
||||
}
|
||||
|
||||
// randomBytes returns n cryptographically random bytes, which do not compress
|
||||
// so the encrypted payload spans multiple age segments.
|
||||
func randomBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
require.NoError(t, err)
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// compressibleBytes returns n bytes of a repeating pattern, which zstd packs
|
||||
// down to a small payload.
|
||||
func compressibleBytes(n int) []byte {
|
||||
pattern := bytes.Repeat([]byte("compressible-"), n/13+1)
|
||||
|
||||
return pattern[:n]
|
||||
}
|
||||
|
||||
// encryptBlob compresses, encrypts and returns a blob for plaintext at
|
||||
// compression level 1.
|
||||
func encryptBlob(t *testing.T, plaintext []byte, recipients ...string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, recipients)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = w.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// ageHeaderLen returns the byte length of blob's age header, i.e. the offset
|
||||
// of the 16-byte payload nonce that follows it. The header ends with a MAC
|
||||
// line "--- <mac>\n"; the nonce begins right after that newline.
|
||||
func ageHeaderLen(t *testing.T, blob []byte) int {
|
||||
t.Helper()
|
||||
|
||||
i := bytes.Index(blob, []byte("\n--- "))
|
||||
require.GreaterOrEqual(t, i, 0, "age MAC footer line not found")
|
||||
|
||||
nl := bytes.IndexByte(blob[i+1:], '\n')
|
||||
require.GreaterOrEqual(t, nl, 0, "newline ending MAC line not found")
|
||||
|
||||
return i + 1 + nl + 1
|
||||
}
|
||||
|
||||
// requireBlobUnreadable asserts that data never decrypts to a plaintext with a
|
||||
// nil error: either NewReader fails, or reading it does.
|
||||
func requireBlobUnreadable(t *testing.T, data []byte, id age.Identity) {
|
||||
t.Helper()
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(data), id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.ReadAll(r)
|
||||
_ = r.Close()
|
||||
|
||||
require.Error(t, err, "reading a damaged blob must fail")
|
||||
}
|
||||
|
||||
// errFailWriter is returned by failAfterWriter once its byte limit is passed.
|
||||
var errFailWriter = errors.New("destination write failed")
|
||||
|
||||
// failAfterWriter accepts writes until more than limit bytes have been sent,
|
||||
// then fails every write. It models a destination that dies mid-blob.
|
||||
type failAfterWriter struct {
|
||||
limit int
|
||||
written int
|
||||
}
|
||||
|
||||
func (f *failAfterWriter) Write(p []byte) (int, error) {
|
||||
f.written += len(p)
|
||||
if f.written > f.limit {
|
||||
return 0, errFailWriter
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package blobgen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrOutputTooLarge is returned by a reader from LimitReader once it has
|
||||
// been asked for more than its limit. It bounds how far an untrusted
|
||||
// compressed stream may expand, so a small, highly compressible object
|
||||
// from the store cannot decompress without limit.
|
||||
var ErrOutputTooLarge = errors.New("output exceeds size limit")
|
||||
|
||||
// LimitReader returns a reader that yields at most limit bytes from r and
|
||||
// then fails with ErrOutputTooLarge. Unlike io.LimitReader, which reports
|
||||
// a silent io.EOF at the limit (indistinguishable from a stream that
|
||||
// simply ended), this fails, so a caller decoding or copying the stream
|
||||
// sees an error rather than a truncated value. A stream of exactly limit
|
||||
// bytes reads back cleanly to EOF; the first byte beyond it is the error.
|
||||
func LimitReader(r io.Reader, limit int64) io.Reader {
|
||||
// remaining counts down from limit+1: the extra byte is the one that,
|
||||
// if it ever arrives, proves the stream is longer than the limit.
|
||||
return &limitReader{r: r, remaining: limit + 1}
|
||||
}
|
||||
|
||||
type limitReader struct {
|
||||
r io.Reader
|
||||
remaining int64
|
||||
}
|
||||
|
||||
func (l *limitReader) Read(p []byte) (int, error) {
|
||||
if l.remaining <= 0 {
|
||||
return 0, ErrOutputTooLarge
|
||||
}
|
||||
|
||||
if int64(len(p)) > l.remaining {
|
||||
p = p[:l.remaining]
|
||||
}
|
||||
|
||||
n, err := l.r.Read(p)
|
||||
l.remaining -= int64(n)
|
||||
|
||||
if l.remaining <= 0 {
|
||||
// The (limit+1)th byte was just read: the stream is too long.
|
||||
return n, ErrOutputTooLarge
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestLimitReaderPassesExactSize checks that a stream of exactly the limit
|
||||
// reads back cleanly to EOF: the bound must not reject a legitimate blob
|
||||
// whose plaintext equals its recorded size.
|
||||
func TestLimitReaderPassesExactSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const n = 1000
|
||||
|
||||
r := blobgen.LimitReader(bytes.NewReader(bytes.Repeat([]byte("a"), n)), n)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, n)
|
||||
}
|
||||
|
||||
// TestLimitReaderFailsPastLimit feeds a large, highly compressible run of
|
||||
// zeros — the decompressed output a zip bomb would produce — through a
|
||||
// small limit and checks it fails within the bound rather than passing
|
||||
// the whole stream through.
|
||||
func TestLimitReaderFailsPastLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 1000
|
||||
|
||||
r := blobgen.LimitReader(
|
||||
bytes.NewReader(bytes.Repeat([]byte{0}, limit*1000)), limit)
|
||||
|
||||
n, err := io.Copy(io.Discard, r)
|
||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
||||
require.LessOrEqual(t, n, int64(limit)+1,
|
||||
"reader must stop within one byte of the limit")
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestNewReaderWrongIdentity covers issue case 4: opening a blob with an
|
||||
// identity other than the recipient reports no matching identity.
|
||||
func TestNewReaderWrongIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, recipient := makeIdentity(t)
|
||||
other, _ := makeIdentity(t)
|
||||
|
||||
blob := encryptBlob(t, []byte("secret payload"), recipient)
|
||||
|
||||
_, err := blobgen.NewReader(bytes.NewReader(blob), other)
|
||||
require.Error(t, err)
|
||||
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
assert.ErrorAs(t, err, &noMatch)
|
||||
}
|
||||
|
||||
// TestNewReaderTruncated covers issue case 6: a multi-segment blob cut at
|
||||
// several points must never read back as valid data. The point immediately
|
||||
// after the header and nonce is intentionally excluded: it reads as a valid
|
||||
// empty blob today and is the regression case for
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/152.
|
||||
func TestNewReaderTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
||||
h := ageHeaderLen(t, blob)
|
||||
|
||||
require.Greater(t, len(blob), h+ageNonceSize+ageSegmentSize,
|
||||
"test needs a blob of at least two age segments")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
size int
|
||||
}{
|
||||
{"inside header", h / 2},
|
||||
{"inside nonce", h + 8},
|
||||
{"inside first segment", h + ageNonceSize + 100},
|
||||
{"end of first full segment", h + ageNonceSize + ageSegmentSize},
|
||||
{"last byte removed", len(blob) - 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, blob[:tc.size], id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewReaderCorrupted covers issue case 7: one flipped byte in each region
|
||||
// of a multi-segment blob makes it unreadable.
|
||||
func TestNewReaderCorrupted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
blob := encryptBlob(t, randomBytes(t, 4*65536+123), recipient)
|
||||
h := ageHeaderLen(t, blob)
|
||||
|
||||
firstNL := bytes.IndexByte(blob, '\n')
|
||||
require.Positive(t, firstNL, "header must have a version line")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pos int
|
||||
}{
|
||||
{"header stanza", firstNL + 5},
|
||||
{"header MAC line", h - 2},
|
||||
{"nonce", h + 4},
|
||||
{"body segment", h + ageNonceSize + 50},
|
||||
{"final tag", len(blob) - 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
corrupt := append([]byte(nil), blob...)
|
||||
corrupt[tc.pos] ^= 0xff
|
||||
requireBlobUnreadable(t, corrupt, id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewReaderTrailingAndGarbage covers issue case 8: bytes appended after a
|
||||
// valid blob, empty input, and random garbage each fail to read.
|
||||
func TestNewReaderTrailingAndGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
valid := encryptBlob(t, []byte("small payload"), recipient)
|
||||
appended := append(append([]byte(nil), valid...), []byte("trailing junk")...)
|
||||
|
||||
t.Run("appended bytes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, appended, id)
|
||||
})
|
||||
t.Run("empty input", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, []byte{}, id)
|
||||
})
|
||||
t.Run("random garbage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBlobUnreadable(t, randomBytes(t, 512), id)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewWriterInvalidLevel covers the rejected end of issue case 9: an
|
||||
// out-of-range compression level errors and writes nothing to the destination.
|
||||
func TestNewWriterInvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, recipient := makeIdentity(t)
|
||||
|
||||
for _, level := range []int{0, -1, 20} {
|
||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||
require.ErrorIs(t, err, blobgen.ErrInvalidCompressionLevel)
|
||||
assert.Nil(t, w)
|
||||
assert.Zero(t, buf.Len(), "nothing written on an invalid level")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewWriterInvalidRecipients covers issue case 10: nil and empty recipient
|
||||
// lists and an unparsable recipient string each error.
|
||||
func TestNewWriterInvalidRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
}{
|
||||
{"nil list", nil},
|
||||
{"empty list", []string{}},
|
||||
{"invalid recipient string", []string{"not-a-recipient"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, tc.recipients)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, w)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewWriterFailingDestination covers issue case 11: a destination that
|
||||
// fails mid-blob surfaces its error from Write or Close.
|
||||
func TestNewWriterFailingDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, recipient := makeIdentity(t)
|
||||
|
||||
// The limit clears the age header and nonce so NewWriter succeeds, then
|
||||
// trips once the compressed body starts flowing.
|
||||
dst := &failAfterWriter{limit: 512}
|
||||
|
||||
w, err := blobgen.NewWriter(dst, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, writeErr := w.Write(randomBytes(t, 256*1024))
|
||||
closeErr := w.Close()
|
||||
|
||||
assert.True(t, writeErr != nil || closeErr != nil,
|
||||
"destination failure must surface from Write or Close")
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package blobgen
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
@@ -21,12 +20,10 @@ type Reader struct {
|
||||
bytesRead int64
|
||||
}
|
||||
|
||||
// NewReader creates a new Reader that decrypts, decompresses, and verifies
|
||||
// data. Every supplied identity is offered to age.Decrypt, so a blob
|
||||
// encrypted to any one of them can be read.
|
||||
func NewReader(r io.Reader, identities ...age.Identity) (*Reader, error) {
|
||||
// NewReader creates a new Reader that decrypts, decompresses, and verifies data
|
||||
func NewReader(r io.Reader, identity age.Identity) (*Reader, error) {
|
||||
// Create decryption reader
|
||||
decReader, err := age.Decrypt(r, identities...)
|
||||
decReader, err := age.Decrypt(r, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating decryption reader: %w", err)
|
||||
}
|
||||
@@ -57,22 +54,6 @@ func (r *Reader) Read(p []byte) (int, error) {
|
||||
n, err := r.teeReader.Read(p)
|
||||
r.bytesRead += int64(n)
|
||||
|
||||
// When the ciphertext is cut right after the age header plus its
|
||||
// 16-byte nonce, the age reader's first read fails with
|
||||
// io.ErrUnexpectedEOF, and the zstd decoder maps that to a clean
|
||||
// io.EOF at frame start. That makes a truncated stream look like a
|
||||
// valid empty one. Distinguish the two: on EOF, read once more from
|
||||
// the age reader. A genuine end leaves it at (0, io.EOF); a truncated
|
||||
// stream leaves its stored io.ErrUnexpectedEOF, which we surface.
|
||||
if errors.Is(err, io.EOF) {
|
||||
var probe [1]byte
|
||||
|
||||
m, ageErr := r.decryptor.Read(probe[:])
|
||||
if m != 0 || !errors.Is(ageErr, io.EOF) {
|
||||
return n, io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -83,9 +64,7 @@ func (r *Reader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sum256 returns the single SHA-256 of the plaintext read so far. This is the
|
||||
// first hash only; the stored object name is its double hash, which callers
|
||||
// obtain by passing this digest to DoubleSHA256.
|
||||
// Sum256 returns the SHA256 hash of all data read
|
||||
func (r *Reader) Sum256() []byte {
|
||||
return r.hasher.Sum(nil)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestMultipleRecipients verifies that data written for several recipients can
|
||||
// be read back by each recipient's identity. Moved from internal/crypto, which
|
||||
// held the only multi-recipient test; blobgen is now the sole encryption path.
|
||||
func TestMultipleRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identities := make([]*age.X25519Identity, 3)
|
||||
recipients := make([]string, 3)
|
||||
|
||||
for i := range identities {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
identities[i] = identity
|
||||
recipients[i] = identity.Recipient().String()
|
||||
}
|
||||
|
||||
plaintext := []byte("Secret message for multiple recipients")
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(&encrypted, 3, recipients)
|
||||
require.NoError(t, err)
|
||||
_, err = writer.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
// Every recipient's identity must recover the original plaintext.
|
||||
for i, identity := range identities {
|
||||
reader, err := blobgen.NewReader(
|
||||
bytes.NewReader(encrypted.Bytes()), identity)
|
||||
require.NoError(t, err, "recipient %d should open the reader", i+1)
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
require.NoError(t, err, "recipient %d should read the plaintext", i+1)
|
||||
require.NoError(t, reader.Close())
|
||||
|
||||
assert.Equal(t, plaintext, got,
|
||||
"recipient %d should recover the original plaintext", i+1)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// checkRoundTrip writes input through a Writer, reads it back through a Reader,
|
||||
// and verifies the plaintext, the byte counts, and the content hashes.
|
||||
func checkRoundTrip(
|
||||
t *testing.T, id *age.X25519Identity, recipient string,
|
||||
level int, input []byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, level, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := w.Write(input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(input), n)
|
||||
require.NoError(t, w.Close())
|
||||
require.Equal(t, int64(len(input)), w.BytesWritten())
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Equal(t, input, got, "decrypted output must equal input")
|
||||
require.Equal(t, int64(len(input)), r.BytesRead())
|
||||
|
||||
// The hash values are checked by decrypting: the reader's single SHA-256
|
||||
// is the hash of the plaintext, and hashing it once more (DoubleSHA256)
|
||||
// gives the writer's ContentID.
|
||||
single := sha256.Sum256(got)
|
||||
assert.Equal(t, single[:], r.Sum256())
|
||||
assert.Equal(t, blobgen.DoubleSHA256(r.Sum256()), w.ContentID())
|
||||
}
|
||||
|
||||
// TestWriterReaderRoundTrip covers issue cases 1 and 2: every size round trips
|
||||
// for both random and compressible data, and the reader hash, its double hash
|
||||
// and the byte counts all agree.
|
||||
func TestWriterReaderRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
// Sizes exercise the age segment boundary (64 KiB) from just below to a
|
||||
// few segments above it, plus the empty and single-byte edges.
|
||||
sizes := []int{0, 1, 65535, 65536, 65537, 4*65536 + 123}
|
||||
|
||||
kinds := []struct {
|
||||
name string
|
||||
fill func(*testing.T, int) []byte
|
||||
}{
|
||||
{"random", randomBytes},
|
||||
{"compressible", func(_ *testing.T, n int) []byte {
|
||||
return compressibleBytes(n)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, k := range kinds {
|
||||
for _, size := range sizes {
|
||||
name := fmt.Sprintf("%s/%d", k.name, size)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
checkRoundTrip(t, id, recipient, 1, k.fill(t, size))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroLengthNoWrite covers issue case 3: a Writer closed with no Write at
|
||||
// all produces the double hash of the empty input, and the blob reads back as
|
||||
// empty with no error.
|
||||
func TestZeroLengthNoWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
w, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
assert.Equal(t, int64(0), w.BytesWritten())
|
||||
|
||||
empty := sha256.Sum256(nil)
|
||||
doubled := sha256.Sum256(empty[:])
|
||||
assert.Equal(t, doubled[:], w.ContentID(),
|
||||
"ContentID of empty input is SHA256(SHA256(\"\"))")
|
||||
|
||||
r, err := blobgen.NewReader(bytes.NewReader(buf.Bytes()), id)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, r.Close())
|
||||
|
||||
assert.Empty(t, got, "empty blob decrypts to empty output")
|
||||
assert.Equal(t, int64(0), r.BytesRead())
|
||||
assert.Equal(t, empty[:], r.Sum256())
|
||||
}
|
||||
|
||||
// TestNewWriterValidLevelsRoundTrip covers the accepted end of issue case 9:
|
||||
// the boundary compression levels 1 and 19 both round trip.
|
||||
func TestNewWriterValidLevelsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, recipient := makeIdentity(t)
|
||||
input := randomBytes(t, 4096)
|
||||
|
||||
for _, level := range []int{1, 19} {
|
||||
t.Run(fmt.Sprintf("level%d", level), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
checkRoundTrip(t, id, recipient, level, input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package blobgen_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestReaderRejectsHeaderNonceTruncation guards against a stream cut right
|
||||
// after the age header plus its 16-byte nonce. age.Decrypt still succeeds on
|
||||
// such an object, and the zstd decoder maps the age reader's
|
||||
// io.ErrUnexpectedEOF to a clean io.EOF at frame start, so without the extra
|
||||
// check the truncated stream would read as a valid empty one. Reading it must
|
||||
// now fail.
|
||||
func TestReaderRejectsHeaderNonceTruncation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypting empty plaintext yields header + nonce(16) + a single
|
||||
// 16-byte final chunk tag. Dropping the trailing tag leaves exactly the
|
||||
// age header plus its nonce — the truncation point that triggers the bug.
|
||||
var full bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&full, identity.Recipient())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
truncated := full.Bytes()[:full.Len()-16]
|
||||
|
||||
reader, err := blobgen.NewReader(bytes.NewReader(truncated), identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
_, err = io.ReadAll(reader)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||
}
|
||||
|
||||
// TestReaderReadsGenuinelyEmptyBlob confirms the truncation check does not
|
||||
// reject a legitimately empty payload: a blob written with no data must round
|
||||
// trip back to zero bytes with no error.
|
||||
func TestReaderReadsGenuinelyEmptyBlob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(
|
||||
&encrypted, 3, []string{identity.Recipient().String()})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
reader, err := blobgen.NewReader(bytes.NewReader(encrypted.Bytes()), identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, data)
|
||||
}
|
||||
+13
-30
@@ -1,6 +1,3 @@
|
||||
// Package blobgen implements the blob data pipeline: streaming zstd
|
||||
// compression, age encryption, and SHA256 content hashing for blob
|
||||
// creation, plus the matching decrypt/decompress/verify reader.
|
||||
package blobgen
|
||||
|
||||
import (
|
||||
@@ -15,18 +12,6 @@ import (
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// DoubleSHA256 returns the double SHA-256 of content whose single SHA-256
|
||||
// digest is sum: it hashes that digest once more. Stored objects are named by
|
||||
// this second hash so that a name never reveals whether known content is
|
||||
// present — an attacker who knows a plaintext, and thus its SHA-256, still
|
||||
// cannot derive the stored name without hashing the digest again. Both a blob
|
||||
// and the metadata database export are named this way.
|
||||
func DoubleSHA256(sum []byte) []byte {
|
||||
h := sha256.Sum256(sum)
|
||||
|
||||
return h[:]
|
||||
}
|
||||
|
||||
// Zstd compression level bounds accepted by NewWriter.
|
||||
const (
|
||||
minCompressionLevel = 1
|
||||
@@ -42,11 +27,6 @@ const reservedCompressionCPUs = 2
|
||||
var ErrInvalidCompressionLevel = errors.New(
|
||||
"invalid compression level: must be between 1 and 19")
|
||||
|
||||
// errInvalidRecipient is returned when a recipient string does not parse as
|
||||
// an X25519 age1... public key. It omits the value, which can be sensitive.
|
||||
var errInvalidRecipient = errors.New(
|
||||
"not a valid X25519 age1... recipient")
|
||||
|
||||
// Writer wraps compression and encryption with SHA256 hashing.
|
||||
// Data flows: input -> tee(hasher, compressor -> encryptor -> destination)
|
||||
// The hash is computed on the uncompressed input for deterministic content-addressing.
|
||||
@@ -77,12 +57,10 @@ func NewWriter(
|
||||
// Parse recipients
|
||||
var ageRecipients []age.Recipient
|
||||
|
||||
for i, recipient := range recipients {
|
||||
// The recipient string can be sensitive (e.g. a secret key pasted by
|
||||
// mistake), so the error names its position, never its value.
|
||||
for _, recipient := range recipients {
|
||||
r, err := age.ParseX25519Recipient(recipient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: recipient %d", errInvalidRecipient, i)
|
||||
return nil, fmt.Errorf("parsing recipient %s: %w", recipient, err)
|
||||
}
|
||||
|
||||
ageRecipients = append(ageRecipients, r)
|
||||
@@ -145,12 +123,17 @@ func (w *Writer) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContentID returns the double SHA-256 of the uncompressed input data: the
|
||||
// name under which this content is stored. It is the second hash of the
|
||||
// running SHA-256, via DoubleSHA256; see that function for why content is
|
||||
// named this way rather than by its plain SHA-256.
|
||||
func (w *Writer) ContentID() []byte {
|
||||
return DoubleSHA256(w.hasher.Sum(nil))
|
||||
// Sum256 returns the double SHA256 hash of the uncompressed input data.
|
||||
// Double hashing (SHA256(SHA256(data))) prevents information leakage about
|
||||
// the plaintext - an attacker cannot confirm existence of known content
|
||||
// by computing its hash and checking for a matching blob filename.
|
||||
func (w *Writer) Sum256() []byte {
|
||||
// First hash: SHA256(plaintext)
|
||||
firstHash := w.hasher.Sum(nil)
|
||||
// Second hash: SHA256(firstHash) - this is the blob ID
|
||||
secondHash := sha256.Sum256(firstHash)
|
||||
|
||||
return secondHash[:]
|
||||
}
|
||||
|
||||
// BytesWritten returns the number of uncompressed bytes written
|
||||
|
||||
@@ -12,10 +12,9 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// TestWriterHashIsDoubleHash verifies that Writer.ContentID() returns
|
||||
// SHA256(SHA256(plaintext)). Stored objects are named by this second hash so a
|
||||
// name is not the plaintext's own SHA-256; this does not stop someone who
|
||||
// already holds the plaintext from confirming it.
|
||||
// TestWriterHashIsDoubleHash verifies that Writer.Sum256() returns
|
||||
// the double hash SHA256(SHA256(plaintext)) for security.
|
||||
// Double hashing prevents attackers from confirming existence of known content.
|
||||
func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -44,7 +43,7 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the hash from the writer
|
||||
writerHash := hex.EncodeToString(writer.ContentID())
|
||||
writerHash := hex.EncodeToString(writer.Sum256())
|
||||
|
||||
// Calculate the expected double hash: SHA256(SHA256(plaintext))
|
||||
firstHash := sha256.Sum256(testData)
|
||||
@@ -61,11 +60,11 @@ func TestWriterHashIsDoubleHash(t *testing.T) {
|
||||
|
||||
// The writer hash should match the double hash
|
||||
assert.Equal(t, expectedDoubleHash, writerHash,
|
||||
"Writer.ContentID() must be SHA256(SHA256(plaintext))")
|
||||
"Writer.Sum256() should return SHA256(SHA256(plaintext)) for security")
|
||||
|
||||
// It must be the second hash, not the plaintext's own SHA-256.
|
||||
// Verify it's NOT the single hash (would leak information)
|
||||
assert.NotEqual(t, singleHashStr, writerHash,
|
||||
"Writer hash must be the double hash, not the single SHA-256")
|
||||
"Writer hash should not be single hash (would allow content confirmation attacks)")
|
||||
}
|
||||
|
||||
// TestWriterDeterministicHash verifies that the same input always produces
|
||||
@@ -94,8 +93,8 @@ func TestWriterDeterministicHash(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer2.Close())
|
||||
|
||||
hash1 := hex.EncodeToString(writer1.ContentID())
|
||||
hash2 := hex.EncodeToString(writer2.ContentID())
|
||||
hash1 := hex.EncodeToString(writer1.Sum256())
|
||||
hash2 := hex.EncodeToString(writer2.Sum256())
|
||||
|
||||
// Hashes should be identical (deterministic)
|
||||
assert.Equal(t, hash1, hash2, "Same input should produce same hash")
|
||||
@@ -109,20 +108,3 @@ func TestWriterDeterministicHash(t *testing.T) {
|
||||
t.Logf("Encrypted size 1: %d bytes", buf1.Len())
|
||||
t.Logf("Encrypted size 2: %d bytes", buf2.Len())
|
||||
}
|
||||
|
||||
// TestNewWriterSecretKeyNotEchoed verifies that a secret key mistakenly passed
|
||||
// as a recipient does not appear in the returned error. A recipient string can
|
||||
// be sensitive, so the error must name only the position, not the value.
|
||||
func TestNewWriterSecretKeyNotEchoed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
secretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
||||
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := blobgen.NewWriter(&buf, 3, []string{secretKey})
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), secretKey,
|
||||
"error must not echo the recipient value")
|
||||
}
|
||||
|
||||
+57
-48
@@ -7,9 +7,12 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
@@ -158,45 +161,64 @@ func cleanStartupError(err error) error {
|
||||
return &startupError{msg: msg}
|
||||
}
|
||||
|
||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||
// then stops it. The app is asked to stop either by an OS interrupt
|
||||
// (SIGINT/SIGTERM — fx installs its own handler when app.Wait is called) or,
|
||||
// on normal completion, by the finished operation calling
|
||||
// Shutdowner.Shutdown(); both arrive on the app.Wait channel.
|
||||
//
|
||||
// Stopping runs the fx OnStop hooks, and RunApp does not return until Stop
|
||||
// returns. On an interrupt the operation's OnStop hook cancels the running
|
||||
// command and waits for it to unwind — removing its decrypted scratch files —
|
||||
// so the process cannot proceed to exit mid-cleanup (issue #159). Waiting for
|
||||
// Stop before returning is what makes that hook effective: routing the
|
||||
// interrupt through app.Stop and not returning until it completes is required,
|
||||
// because fx also fires the app.Wait channel on the signal, and an earlier
|
||||
// version returned on that alone — unwinding to os.Exit while the concurrent
|
||||
// cleanup still ran. The stop is bounded by shutdownTimeout. Returns an error
|
||||
// if startup fails.
|
||||
// RunApp starts and stops the fx application within the given context.
|
||||
// It handles graceful shutdown on interrupt signals (SIGINT, SIGTERM) and
|
||||
// ensures the application stops cleanly. The function blocks until the
|
||||
// application completes or is interrupted. Returns an error if startup fails.
|
||||
func RunApp(ctx context.Context, app *fx.App) error {
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Create a context that will be cancelled on signal
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Start the app
|
||||
err := app.Start(ctx)
|
||||
if err != nil {
|
||||
return cleanStartupError(err)
|
||||
}
|
||||
|
||||
// Block until an interrupt or the finished operation's
|
||||
// Shutdowner.Shutdown() arrives, then stop the app in this goroutine so we
|
||||
// return only after its OnStop hooks — including the operation's cleanup
|
||||
// wait — have run. Detach the stop from ctx's cancellation but keep its
|
||||
// values, and bound it by shutdownTimeout.
|
||||
<-app.Wait()
|
||||
// Handle shutdown
|
||||
shutdownComplete := make(chan struct{})
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), shutdownTimeout)
|
||||
defer cancel()
|
||||
go func() {
|
||||
defer close(shutdownComplete)
|
||||
|
||||
err = app.Stop(shutdownCtx)
|
||||
if err != nil {
|
||||
log.Error("Error during shutdown", "error", err)
|
||||
<-sigChan
|
||||
log.Notice("Received interrupt signal, shutting down gracefully...")
|
||||
|
||||
// Create a timeout context for shutdown. The parent ctx is being
|
||||
// cancelled, so detach from its cancellation but keep its values.
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), shutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
|
||||
err := app.Stop(shutdownCtx)
|
||||
if err != nil {
|
||||
log.Error("Error during shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the signal handler to complete shutdown or the app to
|
||||
// request shutdown.
|
||||
select {
|
||||
case <-shutdownComplete:
|
||||
// Shutdown completed via signal
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
// Context cancelled (shouldn't happen in normal operation)
|
||||
err := app.Stop(context.WithoutCancel(ctx))
|
||||
if err != nil {
|
||||
log.Error("Error stopping app", "error", err)
|
||||
}
|
||||
|
||||
return ctx.Err()
|
||||
case <-app.Done():
|
||||
// App finished running (e.g., backup completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// errReported marks a failure the operation has already shown the user
|
||||
@@ -216,10 +238,7 @@ var errReported = errors.New("operation failed")
|
||||
//
|
||||
// op runs in a goroutine so OnStart returns promptly and an interrupt
|
||||
// can still cancel through OnStop; when it finishes, success or failure,
|
||||
// it triggers shutdown, which is what lets RunWithApp return. On an
|
||||
// interrupt OnStop cancels op and waits for the goroutine to return, so
|
||||
// op's cleanup (removing decrypted scratch files) runs before the
|
||||
// process exits; the wait is bounded by shutdownTimeout. report is
|
||||
// it triggers shutdown, which is what lets RunWithApp return. report is
|
||||
// called with a non-canceled failure so the caller can log it (and
|
||||
// suppress it under --json) before it becomes errReported. A context
|
||||
// cancellation is the interrupt path, not a failure: it is neither
|
||||
@@ -235,11 +254,9 @@ func RunOperation(
|
||||
|
||||
opts.Invokes = append(opts.Invokes,
|
||||
fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) {
|
||||
var stop func(context.Context) bool
|
||||
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
stop = v.StartOperation(func() {
|
||||
go func() {
|
||||
err := op(v)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
report(err)
|
||||
@@ -253,20 +270,12 @@ func RunOperation(
|
||||
if stopErr != nil {
|
||||
log.Error("Failed to shutdown", "error", stopErr)
|
||||
}
|
||||
})
|
||||
}()
|
||||
|
||||
return nil
|
||||
},
|
||||
// On an interrupt, cancel the operation and wait for it to
|
||||
// unwind so its cleanup defers (which remove decrypted
|
||||
// scratch files from the temp directory) run before the
|
||||
// process exits. The wait is bounded by ctx, the existing
|
||||
// shutdownTimeout.
|
||||
OnStop: func(ctx context.Context) error {
|
||||
if !stop(ctx) {
|
||||
log.Warn("Shutdown timed out before the operation " +
|
||||
"finished; decrypted temporary files may remain")
|
||||
}
|
||||
OnStop: func(_ context.Context) error {
|
||||
v.Cancel()
|
||||
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/cli"
|
||||
)
|
||||
|
||||
// TestRunAppWaitsForOperationCleanupOnShutdown drives RunApp with an fx app
|
||||
// wired the way RunOperation wires a command: a single lifecycle hook whose
|
||||
// OnStart launches the operation in its own goroutine and whose OnStop cancels
|
||||
// it and blocks until that goroutine returns. The operation stands in for a
|
||||
// restore blocked mid-download — it holds a decrypted "scratch" file and only
|
||||
// removes it as it unwinds on cancellation.
|
||||
//
|
||||
// The app is asked to stop once the operation is running (standing in for an
|
||||
// OS interrupt; fx delivers a real signal and Shutdowner.Shutdown() on the
|
||||
// same app.Wait channel, so both drive the identical shutdown path). RunApp
|
||||
// must not return until app.Stop has run the OnStop hook, so the scratch file
|
||||
// must be gone by the time RunApp returns. Before the fix RunApp returned as
|
||||
// soon as the app.Wait/Done channel fired, without running app.Stop, so the
|
||||
// cleanup never ran and this file would still be on disk (issue #159).
|
||||
func TestRunAppWaitsForOperationCleanupOnShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scratch := filepath.Join(t.TempDir(), "decrypted-scratch")
|
||||
require.NoError(t, os.WriteFile(scratch, []byte("secret"), 0o600))
|
||||
|
||||
// Cancel and reap the operation even if RunApp returns without doing so
|
||||
// (the buggy path), so the goroutine cannot leak past the test.
|
||||
opCtx, opCancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(opCancel)
|
||||
|
||||
var stop func(context.Context) bool
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Invoke(func(lc fx.Lifecycle, sh fx.Shutdowner) {
|
||||
lc.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
// Blocked mid-operation until cancelled, then run the
|
||||
// cleanup an interrupted restore would run.
|
||||
<-opCtx.Done()
|
||||
|
||||
_ = os.Remove(scratch)
|
||||
}()
|
||||
|
||||
stop = func(ctx context.Context) bool {
|
||||
opCancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Ask the app to stop now that the operation is running.
|
||||
go func() { _ = sh.Shutdown() }()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
stop(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cli.RunApp(context.Background(), app) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("RunApp did not return after shutdown was requested")
|
||||
}
|
||||
|
||||
_, err := os.Stat(scratch)
|
||||
require.True(t, os.IsNotExist(err),
|
||||
"RunApp returned before the operation removed its decrypted scratch file")
|
||||
}
|
||||
@@ -188,11 +188,8 @@ func newSnapshotVerifyCommand() *cobra.Command {
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "verify <snapshot-id>",
|
||||
Short: "Check a snapshot's blobs are present with the listed size",
|
||||
Long: "Checks that every blob the snapshot's manifest lists is present\n" +
|
||||
"in storage with the size the manifest records, and that the\n" +
|
||||
"snapshot's encrypted database is present. It does not read blob\n" +
|
||||
"contents; use --deep to download and cryptographically verify them.\n\n" +
|
||||
Short: "Verify snapshot integrity",
|
||||
Long: "Verifies that all blobs referenced in a snapshot exist.\n\n" +
|
||||
"The snapshot may be named by its ID or, on a host with no local\n" +
|
||||
"index, by the remote key that 'snapshot list' prints for a\n" +
|
||||
"remote-only snapshot (an unambiguous leading part is enough).",
|
||||
|
||||
@@ -35,12 +35,8 @@ The snapshot may be named by its ID or, when restoring on a host with no
|
||||
local index, by the remote key that 'snapshot list' prints for a
|
||||
remote-only snapshot (an unambiguous leading part is enough).
|
||||
|
||||
Requires the age private key in the VAULTIK_AGE_SECRET_KEY environment
|
||||
variable. The variable may hold the whole age-keygen file (comments and
|
||||
all of its identities are accepted); read it from the file rather than
|
||||
typing the key, so it does not land in your shell history:
|
||||
|
||||
export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"
|
||||
Requires the VAULTIK_AGE_SECRET_KEY environment variable to be set with
|
||||
the age private key.
|
||||
|
||||
Examples:
|
||||
# Restore entire snapshot
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package cli //nolint:testpackage // exercises the unexported command constructor
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestRestoreCommandDoesNotTakeKeyAsArgument guards the fix for the age
|
||||
// key being echoed on the command line: restore must take the key only
|
||||
// from the environment, never as a flag value, and its help must show the
|
||||
// file-based form rather than a literal key that would land in shell
|
||||
// history.
|
||||
func TestRestoreCommandDoesNotTakeKeyAsArgument(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newSnapshotRestoreCommand()
|
||||
|
||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
lower := strings.ToLower(f.Name)
|
||||
for _, banned := range []string{"key", "secret", "age", "identity"} {
|
||||
if strings.Contains(lower, banned) {
|
||||
t.Errorf("restore must not accept the key as a flag; found --%s", f.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
help := cmd.Long
|
||||
if strings.Contains(help, "AGE-SECRET-KEY-") {
|
||||
t.Error("restore help must not show a literal age private key to type")
|
||||
}
|
||||
|
||||
if !strings.Contains(help, "$(cat ") {
|
||||
t.Error("restore help should read the key from a file, e.g. $(cat ...)")
|
||||
}
|
||||
}
|
||||
+23
-81
@@ -22,13 +22,6 @@ import (
|
||||
|
||||
const appName = "vaultik"
|
||||
|
||||
// secretKeyPrefix marks an age secret (private) key. It is compared
|
||||
// case-insensitively so a recipient entry that is actually a private key is
|
||||
// caught and never passed to age or echoed back.
|
||||
//
|
||||
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
||||
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
||||
|
||||
// Defaults and validation bounds for tunable settings.
|
||||
const (
|
||||
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB
|
||||
@@ -45,10 +38,6 @@ var (
|
||||
errNoConfigPath = errors.New("config path not provided")
|
||||
errNoAgeRecipients = errors.New(
|
||||
"at least one age_recipient is required (generate with: age-keygen)")
|
||||
errRecipientIsSecretKey = errors.New(
|
||||
"an age secret key was given where a public key (age1...) belongs")
|
||||
errRecipientNotX25519 = errors.New(
|
||||
"not a valid recipient; only X25519 age1... public keys are supported")
|
||||
errNoSnapshots = errors.New(
|
||||
"at least one snapshot must be configured (see config.example.yml)")
|
||||
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
||||
@@ -135,26 +124,6 @@ func (c *Config) SnapshotNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// Names of the two places the age secret key can be configured, used by
|
||||
// AgeSecretKeySourceName for error messages that must not echo the value.
|
||||
//
|
||||
//nolint:gosec // G101: these are the names of the config sources, not a key
|
||||
const (
|
||||
ageSecretKeySourceEnv = "VAULTIK_AGE_SECRET_KEY"
|
||||
ageSecretKeySourceConfig = "age_secret_key"
|
||||
)
|
||||
|
||||
// AgeSecretKeySourceName returns the human name of where AgeSecretKey was
|
||||
// configured. A Config built directly (as in tests) has no recorded
|
||||
// source, so it reports the config-file field name.
|
||||
func (c *Config) AgeSecretKeySourceName() string {
|
||||
if c.AgeSecretKeySource != "" {
|
||||
return c.AgeSecretKeySource
|
||||
}
|
||||
|
||||
return ageSecretKeySourceConfig
|
||||
}
|
||||
|
||||
// Config represents the application configuration for Vaultik.
|
||||
// It defines all settings for backup operations, including source directories,
|
||||
// encryption recipients, storage configuration, and performance tuning parameters.
|
||||
@@ -164,13 +133,8 @@ func (c *Config) AgeSecretKeySourceName() string {
|
||||
type Config struct {
|
||||
AgeRecipients []string `yaml:"age_recipients"`
|
||||
AgeSecretKey string `yaml:"age_secret_key"`
|
||||
// AgeSecretKeySource names where AgeSecretKey was configured
|
||||
// ("VAULTIK_AGE_SECRET_KEY" or "age_secret_key") so a later parse
|
||||
// failure can name the source without echoing the secret value. It is
|
||||
// set by Load and never read from or written to the config file.
|
||||
AgeSecretKeySource string `yaml:"-"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
||||
ChunkSize Size `yaml:"chunk_size"`
|
||||
// Exclude holds global excludes applied to all snapshots.
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Hostname string `yaml:"hostname"`
|
||||
@@ -279,7 +243,10 @@ func Load(path string) (*Config, error) {
|
||||
cfg.IndexPath = expandTilde(envIndexPath)
|
||||
}
|
||||
|
||||
cfg.setAgeSecretKey()
|
||||
// Check for environment variable override for AgeSecretKey
|
||||
if envAgeSecretKey := os.Getenv("VAULTIK_AGE_SECRET_KEY"); envAgeSecretKey != "" {
|
||||
cfg.AgeSecretKey = extractAgeSecretKey(envAgeSecretKey)
|
||||
}
|
||||
|
||||
// Get hostname if not set
|
||||
if cfg.Hostname == "" {
|
||||
@@ -323,9 +290,7 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
// Validate checks if the configuration is valid and complete.
|
||||
// It ensures all required fields are present and have valid values:
|
||||
// - At least one age recipient must be specified, and every recipient must
|
||||
// parse as an X25519 age1... public key (so a bad entry fails at load, not
|
||||
// mid-backup); errors name the position, never the value
|
||||
// - At least one age recipient must be specified
|
||||
// - At least one snapshot must be configured with at least one path
|
||||
// - Storage must be configured (either storage_url or s3.* fields)
|
||||
// - Chunk size must be at least 1MB
|
||||
@@ -340,13 +305,6 @@ func (c *Config) Validate() error {
|
||||
return errNoAgeRecipients
|
||||
}
|
||||
|
||||
for i, recipient := range c.AgeRecipients {
|
||||
err := validateAgeRecipient(recipient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("age_recipients[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Snapshots) == 0 {
|
||||
return errNoSnapshots
|
||||
}
|
||||
@@ -384,38 +342,6 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgeRecipient parses one age_recipients entry with the age library
|
||||
// and returns a value-free error on failure. A recipient string can be
|
||||
// sensitive (an operator may paste a secret key by mistake), so neither the
|
||||
// entry nor age's own error (which quotes its input) is ever included.
|
||||
func validateAgeRecipient(recipient string) error {
|
||||
if strings.HasPrefix(strings.ToUpper(recipient), secretKeyPrefix) {
|
||||
return errRecipientIsSecretKey
|
||||
}
|
||||
|
||||
_, err := age.ParseX25519Recipient(recipient)
|
||||
if err != nil {
|
||||
return errRecipientNotX25519
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAgeSecretKey records the age secret key and where it came from. The
|
||||
// value is stored raw and parsed only where decryption happens
|
||||
// (internal/vaultik), so backup, list and prune keep working whatever the
|
||||
// field holds. The environment variable overrides the config-file field.
|
||||
func (c *Config) setAgeSecretKey() {
|
||||
if c.AgeSecretKey != "" {
|
||||
c.AgeSecretKeySource = ageSecretKeySourceConfig
|
||||
}
|
||||
|
||||
if env := os.Getenv("VAULTIK_AGE_SECRET_KEY"); env != "" {
|
||||
c.AgeSecretKey = env
|
||||
c.AgeSecretKeySource = ageSecretKeySourceEnv
|
||||
}
|
||||
}
|
||||
|
||||
// validateStorage validates storage configuration.
|
||||
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
||||
// File URLs don't require any S3 configuration.
|
||||
@@ -472,6 +398,22 @@ func (c *Config) validateStorageURL() error {
|
||||
}
|
||||
}
|
||||
|
||||
// extractAgeSecretKey extracts the AGE-SECRET-KEY from the input using
|
||||
// the age library's parser, which handles comments and whitespace.
|
||||
func extractAgeSecretKey(input string) string {
|
||||
identities, err := age.ParseIdentities(strings.NewReader(input))
|
||||
if err != nil || len(identities) == 0 {
|
||||
// Fall back to trimmed input if parsing fails
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
// Return the string representation of the first identity
|
||||
if id, ok := identities[0].(*age.X25519Identity); ok {
|
||||
return id.String()
|
||||
}
|
||||
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
|
||||
// Module exports the config module for fx dependency injection.
|
||||
// It provides the Config type to other modules in the application.
|
||||
//
|
||||
|
||||
+39
-136
@@ -1,10 +1,9 @@
|
||||
package config //nolint:testpackage // exercises unexported source constants
|
||||
package config //nolint:testpackage // exercises unexported extractAgeSecretKey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
@@ -87,48 +86,6 @@ func TestConfigLoad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
||||
// config.example.yml carries only neutral placeholders (no real credentials,
|
||||
// private addresses, or internal host names) and still parses.
|
||||
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
examplePath := filepath.Join("..", "..", "config.example.yml")
|
||||
|
||||
cfg, err := Load(examplePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load config.example.yml: %v", err)
|
||||
}
|
||||
|
||||
if cfg.StorageURL != "rclone://myremote/path/to/backups" {
|
||||
t.Errorf("Expected neutral storage_url, got '%s'", cfg.StorageURL)
|
||||
}
|
||||
|
||||
//nolint:gosec // G304: examplePath is a fixed in-repo path, not user input
|
||||
raw, err := os.ReadFile(examplePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config.example.yml: %v", err)
|
||||
}
|
||||
|
||||
text := string(raw)
|
||||
|
||||
wantSubstrings := []string{
|
||||
"YOUR_ACCESS_KEY",
|
||||
"YOUR_SECRET_KEY",
|
||||
"endpoint: https://",
|
||||
}
|
||||
for _, want := range wantSubstrings {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("Expected config.example.yml to contain %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A raw "http://" scheme would mean a plaintext, likely private endpoint.
|
||||
if strings.Contains(text, "http://") {
|
||||
t.Error("config.example.yml should not contain an http:// endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFromEnv tests loading config path from environment variable
|
||||
func TestConfigFromEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -221,52 +178,53 @@ func TestValidateBlobSizeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
||||
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
||||
// least of all a pasted secret key — is echoed in the error.
|
||||
func TestValidateAgeRecipients(t *testing.T) {
|
||||
// TestExtractAgeSecretKey tests extraction of AGE-SECRET-KEY from various inputs
|
||||
func TestExtractAgeSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseConfig := func(recipients []string) *Config {
|
||||
return &Config{
|
||||
AgeRecipients: recipients,
|
||||
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
||||
StorageURL: "file:///tmp/vaultik-test-store",
|
||||
ChunkSize: Size(10 * 1024 * 1024),
|
||||
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
||||
CompressionLevel: 3,
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
recipients []string
|
||||
wantErr bool
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "config init placeholder is rejected",
|
||||
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
||||
wantErr: true,
|
||||
name: "plain key",
|
||||
input: testIntegrationAgePrivateKey,
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "ssh-ed25519 recipient is rejected",
|
||||
recipients: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplekeydata"},
|
||||
wantErr: true,
|
||||
name: "key with trailing newline",
|
||||
input: testIntegrationAgePrivateKey + "\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "truncated age1 string is rejected",
|
||||
recipients: []string{"age1short"},
|
||||
wantErr: true,
|
||||
name: "full age-keygen output",
|
||||
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||
"# public key: " + testIntegrationAgePublicKey + "\n" +
|
||||
testIntegrationAgePrivateKey + "\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "secret key passed as recipient is rejected",
|
||||
recipients: []string{testIntegrationAgePrivateKey},
|
||||
wantErr: true,
|
||||
name: "age-keygen output with extra blank lines",
|
||||
input: "# created: 2025-01-14T12:00:00Z\n" +
|
||||
"# public key: " + testIntegrationAgePublicKey + "\n\n" +
|
||||
testIntegrationAgePrivateKey + "\n\n",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "two valid recipients are accepted",
|
||||
recipients: []string{testSneakAgePublicKey, testIntegrationAgePublicKey},
|
||||
wantErr: false,
|
||||
name: "key with leading whitespace",
|
||||
input: " " + testIntegrationAgePrivateKey + " ",
|
||||
expected: testIntegrationAgePrivateKey,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "only comments",
|
||||
input: "# this is a comment\n# another comment",
|
||||
expected: "# this is a comment\n# another comment",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -274,65 +232,10 @@ func TestValidateAgeRecipients(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := baseConfig(tt.recipients).Validate()
|
||||
if !tt.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Validate() returned nil, want error")
|
||||
}
|
||||
|
||||
// The entry itself must never appear in the error, since a
|
||||
// recipient string can be a secret key.
|
||||
for _, recipient := range tt.recipients {
|
||||
if strings.Contains(err.Error(), recipient) {
|
||||
t.Fatalf("Validate() error echoed the recipient value: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgeSecretKeySourceName checks the name reported for the configured
|
||||
// age secret key: the recorded source when Load set one, and the
|
||||
// config-file field name for a Config built directly (as in tests).
|
||||
func TestAgeSecretKeySourceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unset defaults to config field",
|
||||
source: "",
|
||||
want: ageSecretKeySourceConfig,
|
||||
},
|
||||
{
|
||||
name: "environment source",
|
||||
source: ageSecretKeySourceEnv,
|
||||
want: ageSecretKeySourceEnv,
|
||||
},
|
||||
{
|
||||
name: "config-file source",
|
||||
source: ageSecretKeySourceConfig,
|
||||
want: ageSecretKeySourceConfig,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &Config{AgeSecretKeySource: tt.source}
|
||||
if got := cfg.AgeSecretKeySourceName(); got != tt.want {
|
||||
t.Errorf("AgeSecretKeySourceName() = %q, want %q", got, tt.want)
|
||||
result := extractAgeSecretKey(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractAgeSecretKey(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// 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")
|
||||
@@ -0,0 +1,178 @@
|
||||
package crypto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
)
|
||||
|
||||
func TestEncryptor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate a test key pair
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity: %v", err)
|
||||
}
|
||||
|
||||
publicKey := identity.Recipient().String()
|
||||
|
||||
// Create encryptor
|
||||
enc, err := crypto.NewEncryptor([]string{publicKey})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
plaintext := []byte("Hello, World! This is a test message.")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's actually encrypted (should be larger and different)
|
||||
if bytes.Equal(plaintext, ciphertext) {
|
||||
t.Error("ciphertext equals plaintext")
|
||||
}
|
||||
|
||||
// Decrypt to verify
|
||||
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decrypt: %v", err)
|
||||
}
|
||||
|
||||
var decrypted bytes.Buffer
|
||||
|
||||
_, err = decrypted.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read decrypted data: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted.Bytes()) {
|
||||
t.Error("decrypted data doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptorMultipleRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate three test key pairs
|
||||
identity1, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity1: %v", err)
|
||||
}
|
||||
|
||||
identity2, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity2: %v", err)
|
||||
}
|
||||
|
||||
identity3, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate identity3: %v", err)
|
||||
}
|
||||
|
||||
publicKeys := []string{
|
||||
identity1.Recipient().String(),
|
||||
identity2.Recipient().String(),
|
||||
identity3.Recipient().String(),
|
||||
}
|
||||
|
||||
// Create encryptor with multiple recipients
|
||||
enc, err := crypto.NewEncryptor(publicKeys)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
plaintext := []byte("Secret message for multiple recipients")
|
||||
|
||||
// Encrypt
|
||||
ciphertext, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Verify each recipient can decrypt
|
||||
identities := []age.Identity{identity1, identity2, identity3}
|
||||
for i, identity := range identities {
|
||||
r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient %d failed to decrypt: %v", i+1, err)
|
||||
}
|
||||
|
||||
var decrypted bytes.Buffer
|
||||
|
||||
_, err = decrypted.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("recipient %d failed to read decrypted data: %v", i+1, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted.Bytes()) {
|
||||
t.Errorf("recipient %d: decrypted data doesn't match original", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptorUpdateRecipients(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate two identities
|
||||
identity1, _ := age.GenerateX25519Identity()
|
||||
identity2, _ := age.GenerateX25519Identity()
|
||||
|
||||
publicKey1 := identity1.Recipient().String()
|
||||
publicKey2 := identity2.Recipient().String()
|
||||
|
||||
// Create encryptor with first key
|
||||
enc, err := crypto.NewEncryptor([]string{publicKey1})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create encryptor: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt with first key
|
||||
plaintext := []byte("test data")
|
||||
|
||||
ciphertext1, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Update to second key
|
||||
err = enc.UpdateRecipients([]string{publicKey2})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update recipients: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt with second key
|
||||
ciphertext2, err := enc.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// First ciphertext should only decrypt with first identity
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity1)
|
||||
if err != nil {
|
||||
t.Error("failed to decrypt with identity1")
|
||||
}
|
||||
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext1), identity2)
|
||||
if err == nil {
|
||||
t.Error("should not decrypt with identity2")
|
||||
}
|
||||
|
||||
// Second ciphertext should only decrypt with second identity
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity2)
|
||||
if err != nil {
|
||||
t.Error("failed to decrypt with identity2")
|
||||
}
|
||||
|
||||
_, err = age.Decrypt(bytes.NewReader(ciphertext2), identity1)
|
||||
if err == nil {
|
||||
t.Error("should not decrypt with identity1")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,44 +15,6 @@ import (
|
||||
// the index describes the backed-up file tree and must stay private.
|
||||
const indexDirPerm = 0o700
|
||||
|
||||
// indexFilePerm restricts the index file to the owning user; it lists every
|
||||
// backed-up path and chunk hash and must stay private.
|
||||
const indexFilePerm = 0o600
|
||||
|
||||
// ensureIndexFileMode makes the index file owner-only before the SQLite
|
||||
// driver opens it: it creates the file 0600 if absent, or chmods an existing
|
||||
// one to 0600. Doing this first matters because SQLite creates its -wal and
|
||||
// -shm side files with the mode of the main database file, so a private main
|
||||
// file yields private side files. The driver treats a zero-byte file as an
|
||||
// empty database, so pre-creating it here is safe.
|
||||
func ensureIndexFileMode(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
if info.Mode().Perm() == indexFilePerm {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = os.Chmod(path, indexFilePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restricting index file permissions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
//nolint:gosec // G304: the index path is operator-configured by design
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, indexFilePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating index file: %w", err)
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
default:
|
||||
return fmt.Errorf("checking index file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Module provides database dependencies
|
||||
//
|
||||
//nolint:gochecknoglobals // fx module definitions are package globals by convention
|
||||
@@ -73,11 +34,6 @@ func provideDatabase(lc fx.Lifecycle, cfg *config.Config) (*DB, error) {
|
||||
return nil, fmt.Errorf("creating index directory: %w", err)
|
||||
}
|
||||
|
||||
err = ensureIndexFileMode(cfg.IndexPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := New(context.Background(), cfg.IndexPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
)
|
||||
|
||||
// TestProvideDatabaseFreshIndexMode verifies that provideDatabase creates a
|
||||
// missing index file owner-only (0600), even under a lenient 022 umask that
|
||||
// would otherwise leave a freshly created file world-readable.
|
||||
//
|
||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||
func TestProvideDatabaseFreshIndexMode(t *testing.T) {
|
||||
restore := syscall.Umask(0o022)
|
||||
defer syscall.Umask(restore)
|
||||
|
||||
indexPath := filepath.Join(t.TempDir(), "index.sqlite")
|
||||
|
||||
openIndex(t, indexPath)
|
||||
assertPerm(t, indexPath, 0o600)
|
||||
}
|
||||
|
||||
// TestProvideDatabaseExistingIndexMode verifies that provideDatabase tightens
|
||||
// an existing world-readable index (0644) in a group/other-readable directory
|
||||
// down to owner-only (0600).
|
||||
//
|
||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||
func TestProvideDatabaseExistingIndexMode(t *testing.T) {
|
||||
restore := syscall.Umask(0o022)
|
||||
defer syscall.Umask(restore)
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
//nolint:gosec // G301: the test intentionally uses a 0755 directory
|
||||
err := os.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("creating index directory: %v", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G302: the test intentionally uses a 0755 directory
|
||||
err = os.Chmod(dir, 0o755)
|
||||
if err != nil {
|
||||
t.Fatalf("relaxing index directory permissions: %v", err)
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(dir, "index.sqlite")
|
||||
|
||||
//nolint:gosec // G306: the test intentionally starts from a 0644 index
|
||||
err = os.WriteFile(indexPath, nil, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("creating pre-existing index: %v", err)
|
||||
}
|
||||
|
||||
//nolint:gosec // G302: the test intentionally starts from a 0644 index
|
||||
err = os.Chmod(indexPath, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("relaxing pre-existing index permissions: %v", err)
|
||||
}
|
||||
|
||||
openIndex(t, indexPath)
|
||||
assertPerm(t, indexPath, 0o600)
|
||||
}
|
||||
|
||||
// openIndex runs provideDatabase against indexPath and closes the resulting
|
||||
// database before returning.
|
||||
func openIndex(t *testing.T, indexPath string) {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{IndexPath: indexPath}
|
||||
|
||||
db, err := provideDatabase(fxtest.NewLifecycle(t), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("provideDatabase: %v", err)
|
||||
}
|
||||
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("closing database: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertPerm fails the test unless path has exactly the given permission bits.
|
||||
func assertPerm(t *testing.T, path string, want os.FileMode) {
|
||||
t.Helper()
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat %s: %v", path, err)
|
||||
}
|
||||
|
||||
got := info.Mode().Perm()
|
||||
if got != want {
|
||||
t.Fatalf("permissions of %s = %#o, want %#o", path, got, want)
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// Sentinel errors for the single-snapshot invariant that an exported
|
||||
// per-snapshot metadata database must satisfy.
|
||||
var (
|
||||
// ErrNoSnapshotInDatabase means the metadata database has no snapshot
|
||||
// row at all.
|
||||
ErrNoSnapshotInDatabase = errors.New("database contains no snapshot")
|
||||
// ErrMultipleSnapshotsInDatabase means the metadata database holds
|
||||
// more than the single snapshot an export is supposed to contain.
|
||||
ErrMultipleSnapshotsInDatabase = errors.New(
|
||||
"database contains more than one snapshot")
|
||||
)
|
||||
|
||||
// SnapshotRepository provides access to the snapshots table and its
|
||||
// snapshot_files / snapshot_blobs association tables.
|
||||
type SnapshotRepository struct {
|
||||
@@ -218,48 +206,6 @@ func (r *SnapshotRepository) GetByID(
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
// GetOnlySnapshot returns the sole snapshot in an exported per-snapshot
|
||||
// metadata database. The backup path writes each snapshot's database with
|
||||
// exactly one snapshot row (see cleanSnapshotDB), so restore and deep
|
||||
// verify expect exactly one. Zero rows return ErrNoSnapshotInDatabase and
|
||||
// more than one returns ErrMultipleSnapshotsInDatabase; callers treat
|
||||
// either as a failed identity check on the downloaded database.
|
||||
func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, error) {
|
||||
query := `
|
||||
SELECT id, hostname, vaultik_version, vaultik_git_revision,
|
||||
started_at, completed_at, file_count, chunk_count, blob_count,
|
||||
total_size, blob_size, compression_ratio
|
||||
FROM snapshots
|
||||
LIMIT 2
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying snapshots: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err := rows.Close()
|
||||
if err != nil {
|
||||
Fatalf("failed to close rows: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
snapshots, err := r.scanSnapshotRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch len(snapshots) {
|
||||
case 1:
|
||||
return snapshots[0], nil
|
||||
case 0:
|
||||
return nil, ErrNoSnapshotInDatabase
|
||||
default:
|
||||
return nil, ErrMultipleSnapshotsInDatabase
|
||||
}
|
||||
}
|
||||
|
||||
// ListRecent returns up to limit snapshots, most recently started first.
|
||||
func (r *SnapshotRepository) ListRecent(
|
||||
ctx context.Context, limit int,
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package log_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// TestTTYHandlerEscapesControlCharacters logs a message and an attribute
|
||||
// value that each carry an ESC and a newline — the shape a crafted path or
|
||||
// storage error from the destination would take — and checks neither raw
|
||||
// byte reaches the output. The handler's own colour codes (ESC ... m) are
|
||||
// stripped first; any ESC left after that came from the untrusted value.
|
||||
func TestTTYHandlerEscapesControlCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
|
||||
logger.Info("start\x1b[31mZAP\nend", "target", "a\x1b[31mZAP\nb")
|
||||
|
||||
out := buf.String()
|
||||
|
||||
// The only newline is the line terminator; the injected ones were escaped.
|
||||
require.Equal(t, 1, strings.Count(out, "\n"),
|
||||
"a newline in the message or a value must be escaped, not emitted raw")
|
||||
|
||||
// After the handler's own colour codes are removed, no ESC survives.
|
||||
stripped := ansiEscape.ReplaceAllString(out, "")
|
||||
require.NotContains(t, stripped, "\x1b",
|
||||
"a raw ESC from the message or a value must not reach the terminal")
|
||||
|
||||
// The escaped form is what appears instead.
|
||||
require.Contains(t, out, `\x1b`)
|
||||
}
|
||||
@@ -5,11 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// groupSeparator joins an open group path to an attribute key. This
|
||||
@@ -118,14 +116,11 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
levelColor = colorReset
|
||||
}
|
||||
|
||||
// Print main message. The message is escaped before the colour codes
|
||||
// are written around it: it can carry text from an untrusted source
|
||||
// (a storage error, for one), and a raw control character would
|
||||
// otherwise reach the terminal.
|
||||
// Print main message
|
||||
_, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s",
|
||||
colorGray, timestamp, colorReset,
|
||||
levelColor, level, colorReset,
|
||||
colorBold, sanitize(r.Message), colorReset)
|
||||
colorBold, r.Message, colorReset)
|
||||
|
||||
// Attributes carried by the handler come first, then the record's
|
||||
// own. Handler attributes were qualified when they were added; the
|
||||
@@ -265,29 +260,9 @@ func (h *TTYHandler) writeAttr(a slog.Attr) {
|
||||
// Future kinds also use the plain string form.
|
||||
}
|
||||
|
||||
// Escape the key and value before the colour codes are written around
|
||||
// them. Both can carry text from an untrusted source — a manifest
|
||||
// timestamp, a storage error, a path or symlink target read back from
|
||||
// the snapshot database — so a control character in one of them must
|
||||
// be rendered as an escape sequence rather than reaching the terminal,
|
||||
// where it could move the cursor or inject its own colours.
|
||||
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
|
||||
colorCyan, sanitize(a.Key), colorReset,
|
||||
colorBlue, sanitize(value), colorReset)
|
||||
}
|
||||
|
||||
// sanitize returns s unchanged when every rune in it is printable, and a
|
||||
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. It is
|
||||
// applied to untrusted text before any colour code is written, so a
|
||||
// control character can never reach the terminal raw.
|
||||
func sanitize(s string) string {
|
||||
for _, r := range s {
|
||||
if !unicode.IsPrint(r) {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
colorCyan, a.Key, colorReset,
|
||||
colorBlue, value, colorReset)
|
||||
}
|
||||
|
||||
// formatDuration formats a duration in a human-readable way
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//nolint:testpackage // exercises the unexported copyFile helper
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// TestCopyFileExportCopyMode verifies that the exported snapshot database
|
||||
// copy is created owner-only (0600), even under a lenient 022 umask that
|
||||
// would otherwise leave a fresh file world-readable.
|
||||
//
|
||||
//nolint:paralleltest // syscall.Umask is process-global; parallel tests would clash
|
||||
func TestCopyFileExportCopyMode(t *testing.T) {
|
||||
restore := syscall.Umask(0o022)
|
||||
defer syscall.Umask(restore)
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
src := filepath.Join(dir, "index.sqlite")
|
||||
|
||||
err := os.WriteFile(src, []byte("index data"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("creating source index: %v", err)
|
||||
}
|
||||
|
||||
dst := filepath.Join(dir, "snapshot.db")
|
||||
|
||||
sm := &SnapshotManager{fs: afero.NewOsFs()}
|
||||
|
||||
err = sm.copyFile(src, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("copyFile: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("stat export copy: %v", err)
|
||||
}
|
||||
|
||||
got := info.Mode().Perm()
|
||||
if got != 0o600 {
|
||||
t.Fatalf("export copy permissions = %#o, want %#o", got, 0o600)
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,6 @@ import (
|
||||
"io"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// Manifest size bounds. A manifest lists one small entry per blob, and
|
||||
// blobs are large (the default target is 10 GB), so even a manifest for a
|
||||
// petabyte-scale backup is a few megabytes. These caps are far above any
|
||||
// manifest the writer can emit, yet stop a crafted, highly compressible
|
||||
// manifest from expanding without limit when decoded: the manifest is
|
||||
// fetched from the store, which is not trusted, and json.Decode buffers
|
||||
// the whole value in memory.
|
||||
const (
|
||||
manifestMaxCompressed = 256 * 1024 * 1024 // 256 MiB
|
||||
manifestMaxDecompressed = 1024 * 1024 * 1024 // 1 GiB
|
||||
)
|
||||
|
||||
// Manifest represents the structure of a snapshot's blob manifest
|
||||
@@ -41,31 +28,19 @@ type BlobInfo struct {
|
||||
CompressedSize int64 `json:"compressed_size"`
|
||||
}
|
||||
|
||||
// DecodeManifest decodes a manifest from a reader containing compressed
|
||||
// JSON, reading through byte limits on both the compressed input and the
|
||||
// decompressed output so an untrusted manifest cannot exhaust memory.
|
||||
// DecodeManifest decodes a manifest from a reader containing compressed JSON
|
||||
func DecodeManifest(r io.Reader) (*Manifest, error) {
|
||||
return decodeManifest(r, manifestMaxCompressed, manifestMaxDecompressed)
|
||||
}
|
||||
|
||||
// decodeManifest is DecodeManifest with explicit limits, so tests can drive
|
||||
// the bounds with small inputs instead of gigabyte-scale ones.
|
||||
func decodeManifest(
|
||||
r io.Reader, maxCompressed, maxDecompressed int64,
|
||||
) (*Manifest, error) {
|
||||
// Decompress using zstd, bounding how many compressed bytes are read.
|
||||
zr, err := zstd.NewReader(blobgen.LimitReader(r, maxCompressed))
|
||||
// Decompress using zstd
|
||||
zr, err := zstd.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating zstd reader: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
// Decode JSON manifest, bounding how far the compressed input may
|
||||
// expand: json.Decode buffers the whole value, so without this a
|
||||
// small, highly compressible manifest could expand to gigabytes.
|
||||
// Decode JSON manifest
|
||||
var manifest Manifest
|
||||
|
||||
err = json.NewDecoder(blobgen.LimitReader(zr, maxDecompressed)).Decode(&manifest)
|
||||
err = json.NewDecoder(zr).Decode(&manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding manifest: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//nolint:testpackage // exercises the unexported decodeManifest bounds
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
)
|
||||
|
||||
// testSnapshotID is a stand-in snapshot ID reused across the bound cases.
|
||||
const testSnapshotID = "host_home_2026-01-01T00:00:00Z"
|
||||
|
||||
// TestDecodeManifestRoundTrip is the baseline: with generous bounds a
|
||||
// manifest the writer produced decodes back unchanged.
|
||||
func TestDecodeManifestRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: "2026-01-01T00:00:00Z",
|
||||
BlobCount: 2,
|
||||
TotalCompressedSize: 42,
|
||||
Blobs: []BlobInfo{
|
||||
{Hash: "aa", CompressedSize: 21},
|
||||
{Hash: "bb", CompressedSize: 21},
|
||||
},
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(want, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := decodeManifest(
|
||||
bytes.NewReader(compressed), manifestMaxCompressed, manifestMaxDecompressed)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
// TestDecodeManifestBoundsDecompressedOutput feeds a valid but highly
|
||||
// compressible manifest — one whose timestamp is a megabyte of the same
|
||||
// character — through a small decompressed bound. The compressed form is
|
||||
// tiny, so only the decompressed bound stops it; decoding must fail within
|
||||
// that bound rather than expanding the value in memory.
|
||||
func TestDecodeManifestBoundsDecompressedOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bomb := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: strings.Repeat("a", 1<<20),
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(bomb, 3)
|
||||
require.NoError(t, err)
|
||||
require.Less(t, len(compressed), 4096,
|
||||
"the compressible manifest must be small compressed")
|
||||
|
||||
_, err = decodeManifest(bytes.NewReader(compressed), 1<<20, 4096)
|
||||
require.ErrorIs(t, err, blobgen.ErrOutputTooLarge)
|
||||
}
|
||||
|
||||
// TestDecodeManifestBoundsCompressedInput checks the compressed-input
|
||||
// bound fires independently: a valid manifest with a generous decompressed
|
||||
// bound but a tiny compressed bound still fails.
|
||||
func TestDecodeManifestBoundsCompressedInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manifest := &Manifest{
|
||||
SnapshotID: testSnapshotID,
|
||||
Timestamp: strings.Repeat("a", 4096),
|
||||
}
|
||||
|
||||
compressed, err := EncodeManifest(manifest, 3)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = decodeManifest(bytes.NewReader(compressed), 8, manifestMaxDecompressed)
|
||||
require.Error(t, err)
|
||||
}
|
||||
+104
-12
@@ -44,7 +44,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -295,6 +294,68 @@ func (sm *SnapshotManager) ExportSnapshotMetadata(
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupIncompleteSnapshots removes incomplete snapshots that don't have
|
||||
// metadata in S3. This is critical for data safety: incomplete snapshots
|
||||
// can cause deduplication to skip files that were never successfully
|
||||
// backed up, resulting in data loss.
|
||||
func (sm *SnapshotManager) CleanupIncompleteSnapshots(
|
||||
ctx context.Context, hostname string,
|
||||
) error {
|
||||
log.Info("Checking for incomplete snapshots", "hostname", hostname)
|
||||
|
||||
// Get all incomplete snapshots for this hostname
|
||||
incompleteSnapshots, err := sm.repos.Snapshots.GetIncompleteByHostname(ctx, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting incomplete snapshots: %w", err)
|
||||
}
|
||||
|
||||
if len(incompleteSnapshots) == 0 {
|
||||
log.Debug("No incomplete snapshots found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Info("Found incomplete snapshots", "count", len(incompleteSnapshots))
|
||||
|
||||
// Check each incomplete snapshot for metadata in storage
|
||||
for _, snapshot := range incompleteSnapshots {
|
||||
// Check if metadata exists in storage (paths use the hashed
|
||||
// remote key so we don't leak host info to the listing).
|
||||
metadataKey := fmt.Sprintf("metadata/%s/db.zst",
|
||||
RemoteSnapshotKey(snapshot.ID.String()))
|
||||
|
||||
_, err := sm.storage.Stat(ctx, metadataKey)
|
||||
if err != nil {
|
||||
// Metadata doesn't exist in S3 - this is an incomplete snapshot
|
||||
log.Info("Cleaning up incomplete snapshot record",
|
||||
"snapshot_id", snapshot.ID, "started_at", snapshot.StartedAt)
|
||||
|
||||
// Delete the snapshot and all its associations
|
||||
err := sm.deleteSnapshot(ctx, snapshot.ID.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting incomplete snapshot %s: %w",
|
||||
snapshot.ID, err)
|
||||
}
|
||||
|
||||
log.Info("Deleted incomplete snapshot record and associated data",
|
||||
"snapshot_id", snapshot.ID)
|
||||
} else {
|
||||
// Metadata exists - this snapshot was completed but database wasn't updated
|
||||
// This shouldn't happen in normal operation, but mark it complete
|
||||
log.Warn("Found snapshot with remote metadata but incomplete in database",
|
||||
"snapshot_id", snapshot.ID)
|
||||
|
||||
err := sm.repos.Snapshots.MarkComplete(ctx, nil, snapshot.ID.String())
|
||||
if err != nil {
|
||||
log.Error("Failed to mark snapshot as complete in database",
|
||||
"snapshot_id", snapshot.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupOrphanedData removes files, chunks, and blobs that are no longer
|
||||
// referenced by any snapshot. This should be called periodically to clean
|
||||
// up data from deleted or incomplete snapshots.
|
||||
@@ -697,18 +758,12 @@ func (sm *SnapshotManager) compressFile(inputPath, outputPath string) error {
|
||||
|
||||
writerClosed = true
|
||||
|
||||
log.Debug("Compression complete", "hash", hex.EncodeToString(writer.ContentID()))
|
||||
log.Debug("Compression complete", "hash", hex.EncodeToString(writer.Sum256()))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportCopyPerm restricts the exported snapshot database copy to the owning
|
||||
// user; it holds the same private index data as the local index file.
|
||||
const exportCopyPerm = 0o600
|
||||
|
||||
// copyFile copies a file from src to dst. The destination is the exported
|
||||
// snapshot database, so it is created owner-only rather than with the
|
||||
// umask-dependent default.
|
||||
// copyFile copies a file from src to dst
|
||||
func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||
log.Debug("Opening source file for copy", "path", src)
|
||||
|
||||
@@ -728,9 +783,7 @@ func (sm *SnapshotManager) copyFile(src, dst string) error {
|
||||
|
||||
log.Debug("Creating destination file", "path", dst)
|
||||
|
||||
destFile, err := sm.fs.OpenFile(
|
||||
dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, exportCopyPerm,
|
||||
)
|
||||
destFile, err := sm.fs.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -871,6 +924,45 @@ type ExtendedBackupStats struct {
|
||||
UploadDurationMs int64 // Total milliseconds spent uploading to S3
|
||||
}
|
||||
|
||||
// deleteSnapshot removes a snapshot and all its associations from the database
|
||||
func (sm *SnapshotManager) deleteSnapshot(
|
||||
ctx context.Context, snapshotID string,
|
||||
) error {
|
||||
// Delete snapshot_files entries
|
||||
err := sm.repos.Snapshots.DeleteSnapshotFiles(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot files: %w", err)
|
||||
}
|
||||
|
||||
// Delete snapshot_blobs entries
|
||||
err = sm.repos.Snapshots.DeleteSnapshotBlobs(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot blobs: %w", err)
|
||||
}
|
||||
|
||||
// Delete uploads entries (has foreign key to snapshots without CASCADE)
|
||||
err = sm.repos.Snapshots.DeleteSnapshotUploads(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot uploads: %w", err)
|
||||
}
|
||||
|
||||
// Delete the snapshot itself
|
||||
err = sm.repos.Snapshots.Delete(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Clean up orphaned data
|
||||
log.Debug("Cleaning up orphaned records in main database")
|
||||
|
||||
err = sm.CleanupOrphanedData(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cleaning up orphaned data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteOtherSnapshots deletes all snapshots except the current one
|
||||
func (sm *SnapshotManager) deleteOtherSnapshots(
|
||||
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
|
||||
|
||||
+59
-14
@@ -1,7 +1,7 @@
|
||||
// Package types provides custom types for better type safety across the
|
||||
// vaultik codebase. Using distinct types for IDs, hashes, and paths prevents
|
||||
// accidental mixing of semantically different values that happen to share the
|
||||
// same underlying type.
|
||||
// vaultik codebase. Using distinct types for IDs, hashes, paths, and
|
||||
// credentials prevents accidental mixing of semantically different values
|
||||
// that happen to share the same underlying type.
|
||||
package types //nolint:revive,nolintlint // rename decision tracked in #76
|
||||
|
||||
import (
|
||||
@@ -157,6 +157,34 @@ type FilePath string
|
||||
// Used during restore to strip the source prefix from paths.
|
||||
type SourcePath string
|
||||
|
||||
// AgeRecipient is an age public key used for encryption.
|
||||
// Format: age1... (Bech32-encoded X25519 public key)
|
||||
type AgeRecipient string
|
||||
|
||||
// AgeSecretKey is an age private key used for decryption.
|
||||
// Format: AGE-SECRET-KEY-... (Bech32-encoded X25519 private key)
|
||||
// This type should never be logged or serialized in plaintext.
|
||||
type AgeSecretKey string
|
||||
|
||||
// S3Endpoint is the URL of an S3-compatible storage endpoint.
|
||||
type S3Endpoint string
|
||||
|
||||
// BucketName is the name of an S3 bucket.
|
||||
type BucketName string
|
||||
|
||||
// S3Prefix is the path prefix within an S3 bucket.
|
||||
type S3Prefix string
|
||||
|
||||
// AWSRegion is an AWS region identifier (e.g., "us-east-1").
|
||||
type AWSRegion string
|
||||
|
||||
// AWSAccessKeyID is an AWS access key ID for authentication.
|
||||
type AWSAccessKeyID string
|
||||
|
||||
// AWSSecretAccessKey is an AWS secret access key for authentication.
|
||||
// This type should never be logged or serialized in plaintext.
|
||||
type AWSSecretAccessKey string
|
||||
|
||||
// Hostname identifies a host machine.
|
||||
type Hostname string
|
||||
|
||||
@@ -171,14 +199,31 @@ type GlobPattern string
|
||||
|
||||
// String methods for Stringer interface
|
||||
|
||||
func (id FileID) String() string { return uuid.UUID(id).String() }
|
||||
func (id BlobID) String() string { return uuid.UUID(id).String() }
|
||||
func (id SnapshotID) String() string { return string(id) }
|
||||
func (h ChunkHash) String() string { return string(h) }
|
||||
func (h BlobHash) String() string { return string(h) }
|
||||
func (p FilePath) String() string { return string(p) }
|
||||
func (p SourcePath) String() string { return string(p) }
|
||||
func (h Hostname) String() string { return string(h) }
|
||||
func (v Version) String() string { return string(v) }
|
||||
func (r GitRevision) String() string { return string(r) }
|
||||
func (p GlobPattern) String() string { return string(p) }
|
||||
func (id FileID) String() string { return uuid.UUID(id).String() }
|
||||
func (id BlobID) String() string { return uuid.UUID(id).String() }
|
||||
func (id SnapshotID) String() string { return string(id) }
|
||||
func (h ChunkHash) String() string { return string(h) }
|
||||
func (h BlobHash) String() string { return string(h) }
|
||||
func (p FilePath) String() string { return string(p) }
|
||||
func (p SourcePath) String() string { return string(p) }
|
||||
func (r AgeRecipient) String() string { return string(r) }
|
||||
func (e S3Endpoint) String() string { return string(e) }
|
||||
func (b BucketName) String() string { return string(b) }
|
||||
func (p S3Prefix) String() string { return string(p) }
|
||||
func (r AWSRegion) String() string { return string(r) }
|
||||
func (k AWSAccessKeyID) String() string { return string(k) }
|
||||
func (h Hostname) String() string { return string(h) }
|
||||
func (v Version) String() string { return string(v) }
|
||||
func (r GitRevision) String() string { return string(r) }
|
||||
func (p GlobPattern) String() string { return string(p) }
|
||||
|
||||
// Redacted String methods for sensitive types - prevents accidental logging
|
||||
|
||||
func (k AgeSecretKey) String() string { return "[REDACTED]" }
|
||||
func (k AWSSecretAccessKey) String() string { return "[REDACTED]" }
|
||||
|
||||
// Raw returns the actual value for sensitive types when explicitly needed.
|
||||
func (k AgeSecretKey) Raw() string { return string(k) }
|
||||
|
||||
// Raw returns the actual value for sensitive types when explicitly needed.
|
||||
func (k AWSSecretAccessKey) Raw() string { return string(k) }
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package types_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// scannableID is the shared behaviour of the UUID-backed id types. A pointer
|
||||
// to FileID or BlobID satisfies it, so both are tested through one set of
|
||||
// cases.
|
||||
type scannableID interface {
|
||||
driver.Valuer
|
||||
sql.Scanner
|
||||
fmt.Stringer
|
||||
IsZero() bool
|
||||
}
|
||||
|
||||
// idKind adapts one id type to the generic tests below.
|
||||
type idKind struct {
|
||||
name string
|
||||
newZero func() scannableID
|
||||
newRandom func() scannableID
|
||||
parse func(string) (scannableID, error)
|
||||
}
|
||||
|
||||
func idKinds() []idKind {
|
||||
return []idKind{
|
||||
{
|
||||
name: "FileID",
|
||||
newZero: func() scannableID { return &types.FileID{} },
|
||||
newRandom: func() scannableID {
|
||||
id := types.NewFileID()
|
||||
|
||||
return &id
|
||||
},
|
||||
parse: func(s string) (scannableID, error) {
|
||||
id, err := types.ParseFileID(s)
|
||||
|
||||
return &id, err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BlobID",
|
||||
newZero: func() scannableID { return &types.BlobID{} },
|
||||
newRandom: func() scannableID {
|
||||
id := types.NewBlobID()
|
||||
|
||||
return &id
|
||||
},
|
||||
parse: func(s string) (scannableID, error) {
|
||||
id, err := types.ParseBlobID(s)
|
||||
|
||||
return &id, err
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDValueScan checks that Value then Scan round trips from both a string
|
||||
// and a []byte, that a NULL scans to the zero id, and that a non-string type
|
||||
// and malformed text are rejected.
|
||||
func TestIDValueScan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orig := k.newRandom()
|
||||
v, err := orig.Value()
|
||||
require.NoError(t, err)
|
||||
|
||||
s, ok := v.(string)
|
||||
require.True(t, ok, "Value must yield a string")
|
||||
|
||||
fromString := k.newZero()
|
||||
require.NoError(t, fromString.Scan(s))
|
||||
assert.Equal(t, orig.String(), fromString.String())
|
||||
assert.False(t, fromString.IsZero())
|
||||
|
||||
fromBytes := k.newZero()
|
||||
require.NoError(t, fromBytes.Scan([]byte(s)))
|
||||
assert.Equal(t, orig.String(), fromBytes.String())
|
||||
|
||||
nulled := k.newRandom()
|
||||
require.NoError(t, nulled.Scan(nil))
|
||||
assert.True(t, nulled.IsZero(), "NULL scans to the zero id")
|
||||
|
||||
require.Error(t, k.newZero().Scan(42),
|
||||
"a non-string type must be rejected")
|
||||
assert.Error(t, k.newZero().Scan("not-a-uuid"),
|
||||
"malformed text must be rejected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDParse checks that the Parse function accepts a canonical id and
|
||||
// rejects malformed text.
|
||||
func TestIDParse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
canonical := k.newRandom().String()
|
||||
|
||||
parsed, err := k.parse(canonical)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, canonical, parsed.String())
|
||||
|
||||
_, err = k.parse("not-a-uuid")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIDIsZero checks IsZero on the zero and on a freshly generated id.
|
||||
func TestIDIsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range idKinds() {
|
||||
t.Run(k.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.True(t, k.newZero().IsZero())
|
||||
assert.False(t, k.newRandom().IsZero())
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-22
@@ -23,9 +23,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"golang.org/x/term"
|
||||
@@ -227,17 +225,17 @@ func (w *Writer) Hex(s string) string {
|
||||
short = s[:hexAbbrevLen] + "..."
|
||||
}
|
||||
|
||||
return w.paint(ansiCyan, sanitize(short))
|
||||
return w.paint(ansiCyan, short)
|
||||
}
|
||||
|
||||
// Snapshot colorizes a snapshot ID (full, no abbreviation).
|
||||
func (w *Writer) Snapshot(id string) string {
|
||||
return w.paint(ansiCyan+ansiBold, sanitize(id))
|
||||
return w.paint(ansiCyan+ansiBold, id)
|
||||
}
|
||||
|
||||
// Path colorizes a filesystem path.
|
||||
func (w *Writer) Path(p string) string {
|
||||
return w.paint(ansiBlue, sanitize(p))
|
||||
return w.paint(ansiBlue, p)
|
||||
}
|
||||
|
||||
// Size colorizes a byte count using humanize.Bytes.
|
||||
@@ -312,23 +310,6 @@ func (w *Writer) paint(color, s string) string {
|
||||
return color + s + ansiReset
|
||||
}
|
||||
|
||||
// sanitize returns s unchanged when every rune in it is printable, and a
|
||||
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
|
||||
// string value formatters escape their argument through this before
|
||||
// painting: identifiers, paths and symlink targets they render come from
|
||||
// the snapshot database, which is not trusted, and escaping must happen
|
||||
// before colour is applied — the painted result already contains the
|
||||
// escape codes the raw text would otherwise be indistinguishable from.
|
||||
func sanitize(s string) string {
|
||||
for _, r := range s {
|
||||
if !unicode.IsPrint(r) {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// emit writes "<prefix> <body>\n" with the prefix painted in prefixColor
|
||||
// and the body optionally painted in bodyColor (empty = no body color).
|
||||
func (w *Writer) emit(prefixColor, prefix, bodyColor, format string, args []any) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package ui_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValueFormattersEscapeControlCharacters checks that a path carrying an
|
||||
// ESC and a newline — the shape a symlink target read back from the
|
||||
// snapshot database could take — is escaped before it reaches the output.
|
||||
// Colour is off here, so the only way a control byte could appear is from
|
||||
// the value itself.
|
||||
func TestValueFormattersEscapeControlCharacters(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w, buf := newTestWriter(false)
|
||||
w.Infof("restoring %s", w.Path("a\x1b[31mZAP\nb"))
|
||||
|
||||
out := buf.String()
|
||||
|
||||
if strings.ContainsRune(out, '\x1b') {
|
||||
t.Fatalf("raw ESC from a value survived in output: %q", out)
|
||||
}
|
||||
|
||||
if strings.Count(out, "\n") != 1 {
|
||||
t.Fatalf("a newline in a value must be escaped, not emitted raw: %q", out)
|
||||
}
|
||||
|
||||
if !strings.Contains(out, `\x1b`) {
|
||||
t.Fatalf("expected the escaped form of ESC in output: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package vaultik
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// errBlobHashMismatch is returned when a fetched blob's content hash does
|
||||
@@ -28,14 +31,13 @@ var errBlobNotFullyRead = errors.New(
|
||||
// redundant SHA-256 computation.
|
||||
type hashVerifyReader struct {
|
||||
reader *blobgen.Reader // underlying decrypted blob reader (has internal hasher)
|
||||
limited io.Reader // reader bounded to the blob's recorded plaintext size
|
||||
fetcher io.ReadCloser // raw fetched stream (closed on Close)
|
||||
blobHash string // expected double-SHA-256 hex
|
||||
done bool // EOF reached
|
||||
}
|
||||
|
||||
func (h *hashVerifyReader) Read(p []byte) (int, error) {
|
||||
n, err := h.limited.Read(p)
|
||||
n, err := h.reader.Read(p)
|
||||
if errors.Is(err, io.EOF) {
|
||||
h.done = true
|
||||
}
|
||||
@@ -55,10 +57,14 @@ func (h *hashVerifyReader) Close() error {
|
||||
return errBlobNotFullyRead
|
||||
}
|
||||
|
||||
actualHashHex := hex.EncodeToString(blobgen.DoubleSHA256(h.reader.Sum256()))
|
||||
firstHash := h.reader.Sum256()
|
||||
secondHasher := sha256.New()
|
||||
secondHasher.Write(firstHash)
|
||||
|
||||
actualHashHex := hex.EncodeToString(secondHasher.Sum(nil))
|
||||
if actualHashHex != h.blobHash {
|
||||
return fmt.Errorf("%w: expected %s, got %s",
|
||||
errBlobHashMismatch, shortHash(h.blobHash), shortHash(actualHashHex))
|
||||
errBlobHashMismatch, h.blobHash[:16], actualHashHex[:16])
|
||||
}
|
||||
|
||||
if readerErr != nil {
|
||||
@@ -72,22 +78,15 @@ func (h *hashVerifyReader) Close() error {
|
||||
// returns a streaming reader that computes the double-SHA-256 hash on the fly.
|
||||
// The hash is verified when the returned reader is closed (after fully reading).
|
||||
// This avoids buffering the entire blob in memory.
|
||||
//
|
||||
// maxPlaintextSize is the blob's uncompressed_size as recorded in the
|
||||
// snapshot database. Decompression stops with blobgen.ErrOutputTooLarge
|
||||
// once the plaintext exceeds it, so a tampered blob cannot expand without
|
||||
// limit — using the recorded size, not the restoring host's
|
||||
// blob_size_limit, since that config may differ from the backup host's.
|
||||
func (v *Vaultik) FetchAndDecryptBlob(
|
||||
ctx context.Context, blobHash string, maxPlaintextSize int64,
|
||||
identities ...age.Identity,
|
||||
ctx context.Context, blobHash string, expectedSize int64, identity age.Identity,
|
||||
) (io.ReadCloser, error) {
|
||||
rc, err := v.FetchBlob(ctx, blobHash)
|
||||
rc, _, err := v.FetchBlob(ctx, blobHash, expectedSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := blobgen.NewReader(rc, identities...)
|
||||
reader, err := blobgen.NewReader(rc, identity)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
@@ -96,29 +95,45 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
|
||||
return &hashVerifyReader{
|
||||
reader: reader,
|
||||
limited: blobgen.LimitReader(reader, maxPlaintextSize),
|
||||
fetcher: rc,
|
||||
blobHash: blobHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||
// Times the Storage.Get and Storage.Stat round-trips separately at
|
||||
// debug level so we can see whether the size-only Stat (which is an
|
||||
// extra request on every fetch) is hurting throughput.
|
||||
func (v *Vaultik) FetchBlob(
|
||||
ctx context.Context, blobHash string,
|
||||
) (io.ReadCloser, error) {
|
||||
// blobHash reaches here from the snapshot database, which is not
|
||||
// trusted. Reject a malformed hash before it is spliced into a storage
|
||||
// path (blobHash[:2]/blobHash[2:4]) or a fetch is attempted.
|
||||
if !isBlobHash(blobHash) {
|
||||
return nil, fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blobHash))
|
||||
}
|
||||
|
||||
ctx context.Context, blobHash string, expectedSize int64,
|
||||
) (io.ReadCloser, int64, error) {
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blobHash[:2], blobHash[2:4], blobHash)
|
||||
|
||||
t0 := time.Now()
|
||||
rc, err := v.Storage.Get(ctx, blobPath)
|
||||
getDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
return nil, 0, fmt.Errorf("downloading blob %s: %w", blobHash[:16], err)
|
||||
}
|
||||
|
||||
return rc, nil
|
||||
t0 = time.Now()
|
||||
info, err := v.Storage.Stat(ctx, blobPath)
|
||||
statDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
|
||||
return nil, 0, fmt.Errorf("stat blob %s: %w", blobHash[:16], err)
|
||||
}
|
||||
|
||||
log.Debug("FetchBlob round-trips",
|
||||
"hash", blobHash[:16],
|
||||
"ms_storage_get", getDur.Milliseconds(),
|
||||
"ms_storage_stat", statDur.Milliseconds(),
|
||||
"expected_size", expectedSize,
|
||||
"stat_size", info.Size,
|
||||
)
|
||||
|
||||
return rc, info.Size, nil
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestFetchAndDecryptBlobBoundsPlaintext feeds a small, highly
|
||||
// compressible blob (256 KiB of zeros) whose decompressed size far exceeds
|
||||
// the plaintext bound passed to FetchAndDecryptBlob. Decompression must
|
||||
// stop with blobgen.ErrOutputTooLarge within the bound rather than
|
||||
// expanding the whole blob into the restore cache.
|
||||
func TestFetchAndDecryptBlobBoundsPlaintext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
plaintext := make([]byte, 256*1024)
|
||||
encryptedData, correctHash := buildHashTestBlob(t, identity, plaintext)
|
||||
|
||||
mockStorage := NewMockStorer()
|
||||
blobPath := "blobs/" + correctHash[:2] + "/" +
|
||||
correctHash[2:4] + "/" + correctHash
|
||||
|
||||
mockStorage.mu.Lock()
|
||||
mockStorage.data[blobPath] = encryptedData
|
||||
mockStorage.mu.Unlock()
|
||||
|
||||
tv := vaultik.NewForTesting(mockStorage)
|
||||
|
||||
const maxPlaintext = 1024
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
context.Background(), correctHash, maxPlaintext, identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
n, copyErr := io.Copy(io.Discard, rc)
|
||||
_ = rc.Close()
|
||||
|
||||
require.ErrorIs(t, copyErr, blobgen.ErrOutputTooLarge)
|
||||
require.LessOrEqual(t, n, int64(maxPlaintext)+1,
|
||||
"decompression must stop within the recorded plaintext bound")
|
||||
}
|
||||
@@ -40,13 +40,13 @@ func buildHashTestBlob(
|
||||
}
|
||||
|
||||
// Compute the double-SHA-256 hash of the plaintext (matches
|
||||
// blobgen.Writer.ContentID).
|
||||
// blobgen.Writer.Sum256).
|
||||
firstHash := sha256.Sum256(plaintext)
|
||||
secondHash := sha256.Sum256(firstHash[:])
|
||||
correctHash := hex.EncodeToString(secondHash[:])
|
||||
|
||||
// Verify our hash matches what blobgen.Writer produces
|
||||
writerHash := hex.EncodeToString(writer.ContentID())
|
||||
writerHash := hex.EncodeToString(writer.Sum256())
|
||||
if correctHash != writerHash {
|
||||
t.Fatalf("hash computation mismatch: manual=%s, writer=%s",
|
||||
correctHash, writerHash)
|
||||
@@ -55,35 +55,6 @@ func buildHashTestBlob(
|
||||
return encBuf.Bytes(), correctHash
|
||||
}
|
||||
|
||||
// TestFetchBlobRejectsMalformedHash verifies FetchBlob refuses a blob hash
|
||||
// that is not 64 lowercase hex characters before it builds a storage path
|
||||
// or issues any request. The hash reaches FetchBlob from the snapshot
|
||||
// database, which is not trusted, so a value such as one containing "/.."
|
||||
// must never reach the store.
|
||||
func TestFetchBlobRejectsMalformedHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mockStorage := NewMockStorer()
|
||||
tv := vaultik.NewForTesting(mockStorage)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, bad := range []string{
|
||||
"aa/../../../home/u/.profile",
|
||||
"abc",
|
||||
strings.Repeat("A", 64), // uppercase hex is not accepted
|
||||
strings.Repeat("g", 64), // not hex
|
||||
} {
|
||||
_, err := tv.FetchBlob(ctx, bad)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for malformed hash %q, got nil", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if calls := mockStorage.GetCalls(); len(calls) != 0 {
|
||||
t.Fatalf("storage was accessed for a malformed hash: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchAndDecryptBlobVerifiesHash verifies that FetchAndDecryptBlob checks
|
||||
// the double-SHA-256 hash of the decrypted plaintext against the expected blob hash.
|
||||
func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
@@ -113,7 +84,7 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
ctx, correctHash, int64(len(plaintext)), identity)
|
||||
ctx, correctHash, int64(len(encryptedData)), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
@@ -145,7 +116,7 @@ func TestFetchAndDecryptBlobVerifiesHash(t *testing.T) {
|
||||
mockStorage.mu.Unlock()
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
ctx, fakeHash, int64(len(plaintext)), identity)
|
||||
ctx, fakeHash, int64(len(encryptedData)), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error opening stream: %v", err)
|
||||
}
|
||||
@@ -188,7 +159,7 @@ func TestFetchAndDecryptBlobCloseBeforeEOFFails(t *testing.T) {
|
||||
tv := vaultik.NewForTesting(mockStorage)
|
||||
|
||||
rc, err := tv.FetchAndDecryptBlob(
|
||||
context.Background(), correctHash, int64(len(plaintext)), identity)
|
||||
context.Background(), correctHash, int64(len(encryptedData)), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error opening stream: %v", err)
|
||||
}
|
||||
|
||||
@@ -6,17 +6,13 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Sentinel errors for blob cache lookups.
|
||||
var (
|
||||
errCacheKeyMissing = errors.New("key not in cache")
|
||||
errCacheReadBeyondBlob = errors.New("read beyond blob size")
|
||||
errCacheKeyHasSeparator = errors.New(
|
||||
"cache key contains a path separator")
|
||||
errCacheNegativeRead = errors.New("negative offset or length")
|
||||
errCacheKeyMissing = errors.New("key not in cache")
|
||||
errCacheReadBeyondBlob = errors.New("read beyond blob size")
|
||||
)
|
||||
|
||||
// blobCacheFileMode is the permission mode for cached blob files.
|
||||
@@ -78,11 +74,6 @@ func newBlobDiskCache(maxBytes int64) (*blobDiskCache, error) {
|
||||
// Put writes blob data to disk cache. Entries larger than maxBytes are
|
||||
// silently skipped.
|
||||
func (c *blobDiskCache) Put(key string, data []byte) error {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entrySize := int64(len(data))
|
||||
|
||||
c.mu.Lock()
|
||||
@@ -96,12 +87,11 @@ func (c *blobDiskCache) Put(key string, data []byte) error {
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.unlink(e)
|
||||
c.curBytes -= e.size
|
||||
_ = os.Remove(p)
|
||||
|
||||
_ = os.Remove(c.path(key))
|
||||
delete(c.items, key)
|
||||
}
|
||||
|
||||
err = os.WriteFile(p, data, blobCacheFileMode)
|
||||
err := os.WriteFile(c.path(key), data, blobCacheFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing blob to cache: %w", err)
|
||||
}
|
||||
@@ -129,26 +119,19 @@ func (c *blobDiskCache) Put(key string, data []byte) error {
|
||||
// disk without buffering its entire plaintext (which may be tens of GB)
|
||||
// in RAM.
|
||||
func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
// Remove any prior entry first; we'll re-link after the file is
|
||||
// written successfully.
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.unlink(e)
|
||||
c.curBytes -= e.size
|
||||
_ = os.Remove(p)
|
||||
|
||||
_ = os.Remove(c.path(key))
|
||||
delete(c.items, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
//nolint:gosec // G304: path() rejects keys with a separator
|
||||
f, err := os.OpenFile(
|
||||
p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
|
||||
c.path(key), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, blobCacheFileMode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating cache file: %w", err)
|
||||
}
|
||||
@@ -157,13 +140,13 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
closeErr := f.Close()
|
||||
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(p)
|
||||
_ = os.Remove(c.path(key))
|
||||
|
||||
return written, fmt.Errorf("streaming to cache file: %w", copyErr)
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(p)
|
||||
_ = os.Remove(c.path(key))
|
||||
|
||||
return written, fmt.Errorf("closing cache file: %w", closeErr)
|
||||
}
|
||||
@@ -175,7 +158,7 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
// floor — but the restore path passes math.MaxInt64 as maxBytes
|
||||
// so this branch is effectively unreachable there.
|
||||
if written > c.maxBytes {
|
||||
_ = os.Remove(p)
|
||||
_ = os.Remove(c.path(key))
|
||||
|
||||
return written, nil
|
||||
}
|
||||
@@ -198,11 +181,6 @@ func (c *blobDiskCache) PutFromReader(key string, r io.Reader) (int64, error) {
|
||||
|
||||
// Get reads a cached blob from disk. Returns data and true on hit.
|
||||
func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.getCalls++
|
||||
|
||||
@@ -217,8 +195,7 @@ func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
||||
c.pushFront(e)
|
||||
c.mu.Unlock()
|
||||
|
||||
//nolint:gosec // G304: path() rejects keys with a separator
|
||||
data, err := os.ReadFile(p)
|
||||
data, err := os.ReadFile(c.path(key))
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
if e2, ok2 := c.items[key]; ok2 && e2 == e {
|
||||
@@ -236,20 +213,6 @@ func (c *blobDiskCache) Get(key string) ([]byte, bool) {
|
||||
|
||||
// ReadAt reads a slice of a cached blob without loading the entire blob into memory.
|
||||
func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error) {
|
||||
p, err := c.path(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// offset and length come from a blob_chunks row read back from the
|
||||
// destination. A negative value must be rejected outright; the upper
|
||||
// bound is checked as length > size-offset (a subtraction) so a huge
|
||||
// offset+length cannot overflow int64 and slip past the check.
|
||||
if offset < 0 || length < 0 {
|
||||
return nil, fmt.Errorf("%w: offset=%d length=%d",
|
||||
errCacheNegativeRead, offset, length)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.readAtCalls++
|
||||
|
||||
@@ -260,7 +223,7 @@ func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error)
|
||||
return nil, fmt.Errorf("%w: %q", errCacheKeyMissing, key)
|
||||
}
|
||||
|
||||
if length > e.size-offset {
|
||||
if offset+length > e.size {
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil, fmt.Errorf("%w: offset=%d length=%d size=%d",
|
||||
@@ -271,7 +234,7 @@ func (c *blobDiskCache) ReadAt(key string, offset, length int64) ([]byte, error)
|
||||
c.pushFront(e)
|
||||
c.mu.Unlock()
|
||||
|
||||
f, err := os.Open(p) //nolint:gosec // G304: path() rejects keys with a separator
|
||||
f, err := os.Open(c.path(key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -313,13 +276,7 @@ func (c *blobDiskCache) Delete(key string) {
|
||||
c.unlink(e)
|
||||
delete(c.items, key)
|
||||
c.curBytes -= e.size
|
||||
|
||||
// The key is already in the map, so it passed path() when it was
|
||||
// inserted; the error cannot occur here.
|
||||
p, err := c.path(key)
|
||||
if err == nil {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
_ = os.Remove(c.path(key))
|
||||
}
|
||||
|
||||
// Keys returns a snapshot of all cached keys. Safe for iteration without
|
||||
@@ -390,18 +347,8 @@ func (c *blobDiskCache) Close() error {
|
||||
return os.RemoveAll(c.dir)
|
||||
}
|
||||
|
||||
// path returns the on-disk location of the cache file for key. The key is
|
||||
// a blob hash read back from the destination and is not trusted: a value
|
||||
// such as "aa/../../../home/u/.profile" would otherwise make filepath.Join
|
||||
// escape the cache directory, so a key containing a path separator is
|
||||
// refused rather than joined.
|
||||
func (c *blobDiskCache) path(key string) (string, error) {
|
||||
if strings.ContainsRune(key, '/') ||
|
||||
strings.ContainsRune(key, filepath.Separator) {
|
||||
return "", fmt.Errorf("%w: %q", errCacheKeyHasSeparator, key)
|
||||
}
|
||||
|
||||
return filepath.Join(c.dir, key), nil
|
||||
func (c *blobDiskCache) path(key string) string {
|
||||
return filepath.Join(c.dir, key)
|
||||
}
|
||||
|
||||
func (c *blobDiskCache) unlink(e *blobDiskCacheEntry) {
|
||||
@@ -444,10 +391,5 @@ func (c *blobDiskCache) evictLRU() {
|
||||
c.unlink(victim)
|
||||
delete(c.items, victim.key)
|
||||
c.curBytes -= victim.size
|
||||
|
||||
// victim.key was validated by path() on insertion, so this cannot err.
|
||||
p, err := c.path(victim.key)
|
||||
if err == nil {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
_ = os.Remove(c.path(victim.key))
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package vaultik
|
||||
|
||||
import "errors"
|
||||
|
||||
// blobHashHexLen is the length of a blob hash written as lowercase hex: a
|
||||
// SHA-256 digest is 32 bytes, so 64 characters. Remote snapshot keys are
|
||||
// SHA-256 hashes too and share this exact form.
|
||||
const blobHashHexLen = 64
|
||||
|
||||
// shortHashLen is how many leading characters of a hash appear in log and
|
||||
// error text.
|
||||
const shortHashLen = 16
|
||||
|
||||
// errInvalidBlobHash reports a value used as a blob hash that is not
|
||||
// exactly 64 lowercase hex characters. Restore, verify and prune read
|
||||
// these values back from the destination, which is not trusted, so each
|
||||
// one is checked before it is used to build a path or drive a read.
|
||||
var errInvalidBlobHash = errors.New(
|
||||
"blob hash is not 64 lowercase hex characters")
|
||||
|
||||
// isBlobHash reports whether s is exactly 64 lowercase hex characters.
|
||||
// Every real blob hash and remote snapshot key has this form.
|
||||
//
|
||||
// The check is a plain function, not a method on types.BlobHash: the
|
||||
// packer stores "temp-placeholder-{uuid}" as the hash of an unfinished
|
||||
// blob in the local index, so the type itself must keep accepting values
|
||||
// that are not hashes.
|
||||
func isBlobHash(s string) bool {
|
||||
if len(s) != blobHashHexLen {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range s {
|
||||
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// shortHash returns the leading part of a hash for log and error text. It
|
||||
// never panics: a string shorter than the prefix is returned whole. A hash
|
||||
// read from the destination may be malformed, and formatting one for a
|
||||
// message must not crash the command.
|
||||
func shortHash(s string) string {
|
||||
if len(s) <= shortHashLen {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:shortHashLen]
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package vaultik //nolint:testpackage // drives unexported input validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// These tests treat every hash, offset and length read back from the
|
||||
// destination as hostile. A blob hash comes from the downloaded snapshot
|
||||
// database or the store listing, neither of which is authenticated (see
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/155), so each is validated
|
||||
// before it is used to build a path or size an allocation.
|
||||
|
||||
// TestBlobCacheRejectsKeyWithSeparator proves the arbitrary-file-write
|
||||
// hole is closed: a blob hash that climbs out of the cache directory is
|
||||
// refused and nothing is written outside it. This is the exact write the
|
||||
// restore path performs, keyed by the hash from the snapshot database.
|
||||
func TestBlobCacheRejectsKeyWithSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, err := newBlobDiskCache(1 << 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
target := filepath.Join(t.TempDir(), "pwned")
|
||||
|
||||
// A hash whose relative form escapes the cache directory to target.
|
||||
key, err := filepath.Rel(cache.dir, target)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, key, "..")
|
||||
|
||||
err = cache.Put(key, []byte("secret"))
|
||||
require.ErrorIs(t, err, errCacheKeyHasSeparator)
|
||||
|
||||
_, err = cache.PutFromReader(key, strings.NewReader("secret"))
|
||||
require.ErrorIs(t, err, errCacheKeyHasSeparator)
|
||||
|
||||
_, statErr := os.Stat(target)
|
||||
require.Truef(t, os.IsNotExist(statErr),
|
||||
"cache wrote outside its directory at %s", target)
|
||||
}
|
||||
|
||||
// TestBuildBlobIndexesRejectsHostileHash proves restore refuses a snapshot
|
||||
// database whose blob_hash escapes the cache directory. buildBlobIndexes is
|
||||
// the first place restore reads these hashes back, and it fails there, before
|
||||
// any blob is fetched or written, so a hash containing /../ cannot steer a
|
||||
// later write outside the cache directory.
|
||||
func TestBuildBlobIndexesRejectsHostileHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
// A blob cache and a target file just outside it. The hostile hash is
|
||||
// the relative path from the cache to that target, so an unguarded
|
||||
// restore keyed by this hash would write there.
|
||||
cache, err := newBlobDiskCache(1 << 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
target := filepath.Join(t.TempDir(), "pwned")
|
||||
hostile, err := filepath.Rel(cache.dir, target)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, hostile, "..")
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
require.NoError(t, repos.Blobs.Create(ctx, nil, &database.Blob{
|
||||
ID: types.NewBlobID(),
|
||||
Hash: types.BlobHash(hostile),
|
||||
CreatedTS: time.Now().UTC(),
|
||||
}))
|
||||
|
||||
v := NewForTesting(nil)
|
||||
v.SetContext(ctx)
|
||||
|
||||
_, _, err = v.buildBlobIndexes(repos)
|
||||
require.ErrorIs(t, err, errInvalidBlobHash)
|
||||
|
||||
_, statErr := os.Stat(target)
|
||||
require.Truef(t, os.IsNotExist(statErr),
|
||||
"restore wrote outside the cache directory at %s", target)
|
||||
}
|
||||
|
||||
// TestBlobCacheReadAtRejectsBadBounds proves a blob_chunks row cannot
|
||||
// drive an out-of-range or negative read. offset/length reach ReadAt
|
||||
// straight from the database.
|
||||
func TestBlobCacheReadAtRejectsBadBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, err := newBlobDiskCache(1 << 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
require.NoError(t, cache.Put("blob", make([]byte, 100)))
|
||||
|
||||
_, err = cache.ReadAt("blob", -1, 10)
|
||||
require.ErrorIs(t, err, errCacheNegativeRead)
|
||||
|
||||
_, err = cache.ReadAt("blob", 0, -1)
|
||||
require.ErrorIs(t, err, errCacheNegativeRead)
|
||||
|
||||
// A length past the end is rejected via the subtraction bound, so a
|
||||
// huge offset+length cannot overflow past the check.
|
||||
_, err = cache.ReadAt("blob", 50, 60)
|
||||
require.ErrorIs(t, err, errCacheReadBeyondBlob)
|
||||
}
|
||||
|
||||
// TestListAllRemoteBlobsSkipsNonConformingName proves a bogus object name
|
||||
// under blobs/ (here a three-character name) is skipped rather than
|
||||
// entering the blob map, so prune's later hash[:2]/hash[2:4] path build
|
||||
// cannot panic on it.
|
||||
func TestListAllRemoteBlobsSkipsNonConformingName(t *testing.T) {
|
||||
// Initialize the global logger before t.Parallel() so the write lands
|
||||
// in the serial phase and cannot race other parallel tests reading it.
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
good := strings.Repeat("a", blobHashHexLen)
|
||||
store := &stubLister{objects: []storage.ObjectInfo{
|
||||
{Key: "blobs/" + good[:2] + "/" + good[2:4] + "/" + good, Size: 10},
|
||||
{Key: "blobs/a/b/c", Size: 3},
|
||||
}}
|
||||
|
||||
v := &Vaultik{Storage: store}
|
||||
v.SetContext(context.Background())
|
||||
|
||||
blobs, err := v.listAllRemoteBlobs()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, blobs, good)
|
||||
require.NotContains(t, blobs, "c")
|
||||
require.Len(t, blobs, 1)
|
||||
}
|
||||
|
||||
// TestVerifyManifestBlobsRejectsShortHash proves a manifest (which is not
|
||||
// authenticated) with a short blob hash fails cleanly instead of panicking
|
||||
// on blob.Hash[:2].
|
||||
func TestVerifyManifestBlobsRejectsShortHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &Vaultik{Stdout: io.Discard}
|
||||
manifest := &snapshot.Manifest{
|
||||
Blobs: []snapshot.BlobInfo{{Hash: "abc", CompressedSize: 1}},
|
||||
}
|
||||
|
||||
verified, missing, mismatched, missingSize, err :=
|
||||
v.verifyManifestBlobs(manifest, &VerifyOptions{JSON: true})
|
||||
require.ErrorIs(t, err, errInvalidBlobHash)
|
||||
require.Zero(t, verified)
|
||||
require.Zero(t, missing)
|
||||
require.Zero(t, mismatched)
|
||||
require.Zero(t, missingSize)
|
||||
}
|
||||
|
||||
// TestVerifyBlobChunksRejectsNegativeLength proves a blob_chunks row with a
|
||||
// negative length returns an error rather than reaching make([]byte,
|
||||
// length) or streaming an untrusted size.
|
||||
func TestVerifyBlobChunksRejectsNegativeLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := database.New(ctx, filepath.Join(t.TempDir(), "index.sqlite"))
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
blobHash := strings.Repeat("b", blobHashHexLen)
|
||||
blob := &database.Blob{
|
||||
ID: types.NewBlobID(),
|
||||
Hash: types.BlobHash(blobHash),
|
||||
CreatedTS: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, repos.Blobs.Create(ctx, nil, blob))
|
||||
|
||||
chunkHash := strings.Repeat("c", blobHashHexLen)
|
||||
require.NoError(t, repos.Chunks.Create(ctx, nil,
|
||||
&database.Chunk{ChunkHash: types.ChunkHash(chunkHash), Size: 1024}))
|
||||
require.NoError(t, repos.BlobChunks.Create(ctx, nil, &database.BlobChunk{
|
||||
BlobID: blob.ID,
|
||||
ChunkHash: types.ChunkHash(chunkHash),
|
||||
Offset: 0,
|
||||
Length: -1,
|
||||
}))
|
||||
|
||||
v := NewForTesting(nil)
|
||||
|
||||
_, err = v.verifyBlobChunks(db.Conn(), blobHash, strings.NewReader(""))
|
||||
require.ErrorIs(t, err, errNegativeChunkLength)
|
||||
}
|
||||
|
||||
// errStubUnused marks a stubLister method a test never exercises.
|
||||
var errStubUnused = errors.New("stubLister method not used in test")
|
||||
|
||||
// stubLister is a storage.Storer whose ListStream yields a fixed set of
|
||||
// objects; every other method is unused by the tests here.
|
||||
type stubLister struct {
|
||||
objects []storage.ObjectInfo
|
||||
}
|
||||
|
||||
func (s *stubLister) ListStream(
|
||||
_ context.Context, prefix string,
|
||||
) <-chan storage.ObjectInfo {
|
||||
ch := make(chan storage.ObjectInfo, len(s.objects))
|
||||
for _, o := range s.objects {
|
||||
if strings.HasPrefix(o.Key, prefix) {
|
||||
ch <- o
|
||||
}
|
||||
}
|
||||
|
||||
close(ch)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (s *stubLister) Put(_ context.Context, _ string, _ io.Reader) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) PutWithProgress(
|
||||
_ context.Context, _ string, _ io.Reader, _ int64, _ storage.ProgressCallback,
|
||||
) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Get(_ context.Context, _ string) (io.ReadCloser, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Stat(_ context.Context, _ string) (*storage.ObjectInfo, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Delete(_ context.Context, _ string) error {
|
||||
return errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) List(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, errStubUnused
|
||||
}
|
||||
|
||||
func (s *stubLister) Info() storage.Info {
|
||||
return storage.Info{}
|
||||
}
|
||||
@@ -247,22 +247,9 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
||||
}
|
||||
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) != blobKeyParts || parts[0] != "blobs" {
|
||||
continue
|
||||
if len(parts) == blobKeyParts && parts[0] == "blobs" {
|
||||
allBlobs[parts[3]] = object.Size
|
||||
}
|
||||
|
||||
// The object name is read from the destination store and is not
|
||||
// trusted. A name that is not a blob hash (e.g. a short or
|
||||
// non-hex string) would panic the later hash[:2]/hash[2:4] path
|
||||
// build, so skip it with a warning rather than delete it.
|
||||
if !isBlobHash(parts[3]) {
|
||||
log.Warn("Skipping non-conforming object under blobs/",
|
||||
"key", object.Key)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
allBlobs[parts[3]] = object.Size
|
||||
}
|
||||
|
||||
log.Info("Found blobs in storage", "count", len(allBlobs))
|
||||
|
||||
+49
-140
@@ -1,6 +1,7 @@
|
||||
package vaultik
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -18,7 +19,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
@@ -29,13 +29,8 @@ var (
|
||||
errDecryptionKeyRequired = errors.New(
|
||||
"decryption key required for restore\n\n" +
|
||||
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
||||
"age private key file:\n" +
|
||||
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
||||
// errInvalidAgeSecretKey is returned when the configured key does not
|
||||
// parse as any age identity. It names the source but never the value,
|
||||
// which is secret, so the message is safe to print and log.
|
||||
errInvalidAgeSecretKey = errors.New(
|
||||
"configured age secret key holds no usable age identity")
|
||||
"age private key:\n" +
|
||||
" export VAULTIK_AGE_SECRET_KEY='AGE-SECRET-KEY-...'")
|
||||
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
|
||||
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
|
||||
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
|
||||
@@ -46,12 +41,6 @@ var (
|
||||
"restored file has trailing data after its last chunk")
|
||||
errRestoreIncomplete = errors.New(
|
||||
"restore loop ended with files still pending")
|
||||
errSnapshotDBMismatch = errors.New(
|
||||
"decrypted database is not the requested snapshot")
|
||||
// errEmptySnapshotDB is returned when the decrypted metadata database has
|
||||
// zero length, which happens when the object was truncated or replaced
|
||||
// with an empty payload. Rejected before any schema is built on it.
|
||||
errEmptySnapshotDB = errors.New("decrypted snapshot database is empty")
|
||||
)
|
||||
|
||||
// snapshotDBFilename is the name the decrypted snapshot database is
|
||||
@@ -105,7 +94,7 @@ type RestoreResult struct {
|
||||
func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
startTime := time.Now()
|
||||
|
||||
identities, err := v.restoreIdentities()
|
||||
identity, err := v.prepareRestoreIdentity()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -119,7 +108,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
// Step 1: Download and decrypt the snapshot metadata database
|
||||
log.Info("Downloading snapshot metadata...")
|
||||
|
||||
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identities)
|
||||
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||
}
|
||||
@@ -168,7 +157,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
|
||||
}
|
||||
|
||||
// Step 5: Restore files
|
||||
result, err := v.restoreAllFiles(files, repos, opts, identities, chunkToBlobMap)
|
||||
result, err := v.restoreAllFiles(files, repos, opts, identity, chunkToBlobMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -229,28 +218,21 @@ func (v *Vaultik) finishRestore(
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreIdentities parses the configured age secret key once into every
|
||||
// identity it contains. The value may be a single key line or a whole
|
||||
// age-keygen file with several identities; all of them are returned so
|
||||
// blobgen (via age.Decrypt) can read a blob encrypted to any of their
|
||||
// recipients. This is the first step of both restore and deep verify, so
|
||||
// a missing or unparseable key fails before anything is downloaded. The
|
||||
// error names the configuration source but never the key value.
|
||||
func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
|
||||
// prepareRestoreIdentity validates that an age secret key is configured
|
||||
// and parses it.
|
||||
//
|
||||
//nolint:ireturn // age.Identity is the decryption abstraction by design
|
||||
func (v *Vaultik) prepareRestoreIdentity() (age.Identity, error) {
|
||||
if v.Config.AgeSecretKey == "" {
|
||||
return nil, errDecryptionKeyRequired
|
||||
}
|
||||
|
||||
// age.ParseIdentities skips comment and blank lines and rejects a
|
||||
// malformed key. Its error can quote the offending line, so it is not
|
||||
// wrapped here — that would leak the secret into the message.
|
||||
identities, err := age.ParseIdentities(strings.NewReader(v.Config.AgeSecretKey))
|
||||
identity, err := age.ParseX25519Identity(v.Config.AgeSecretKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w (source: %s)",
|
||||
errInvalidAgeSecretKey, v.Config.AgeSecretKeySourceName())
|
||||
return nil, fmt.Errorf("parsing age secret key: %w", err)
|
||||
}
|
||||
|
||||
return identities, nil
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// restoreAllFiles processes files in blob-locality order: drain every
|
||||
@@ -263,7 +245,7 @@ func (v *Vaultik) restoreAllFiles(
|
||||
files []*database.File,
|
||||
repos *database.Repositories,
|
||||
opts *RestoreOptions,
|
||||
identities []age.Identity,
|
||||
identity age.Identity,
|
||||
chunkToBlobMap map[string]*database.BlobChunk,
|
||||
) (*RestoreResult, error) {
|
||||
result := &RestoreResult{}
|
||||
@@ -317,7 +299,7 @@ func (v *Vaultik) restoreAllFiles(
|
||||
ctx: v.ctx,
|
||||
repos: repos,
|
||||
opts: opts,
|
||||
identities: identities,
|
||||
identity: identity,
|
||||
chunkToBlobMap: chunkToBlobMap,
|
||||
blobByHash: blobByHash,
|
||||
blobIDToHash: blobIDToHash,
|
||||
@@ -420,20 +402,14 @@ func (s *restoreSession) downloadNextBlobSet(plan *restorePlan) (bool, error) {
|
||||
}
|
||||
|
||||
for _, hash := range plan.blobsNeeded(next) {
|
||||
// Stop between blobs on cancel so an interrupt ends the download
|
||||
// phase promptly rather than fetching the rest of the set.
|
||||
if s.ctx.Err() != nil {
|
||||
return false, s.ctx.Err()
|
||||
}
|
||||
|
||||
blob, ok := s.blobByHash[hash]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%w: %s", errBlobMissingFromIndex, shortHash(hash))
|
||||
return false, fmt.Errorf("%w: %s", errBlobMissingFromIndex, hash[:16])
|
||||
}
|
||||
|
||||
err := s.downloadBlobToCache(hash, blob.CompressedSize, blob.UncompressedSize)
|
||||
err := s.downloadBlobToCache(hash, blob.CompressedSize)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("downloading blob %s: %w", shortHash(hash), err)
|
||||
return false, fmt.Errorf("downloading blob %s: %w", hash[:16], err)
|
||||
}
|
||||
|
||||
s.result.BlobsDownloaded++
|
||||
@@ -477,16 +453,6 @@ func (v *Vaultik) buildBlobIndexes(
|
||||
blobByHash := make(map[string]*database.Blob, len(blobsByID))
|
||||
for id, blob := range blobsByID {
|
||||
hash := blob.Hash.String()
|
||||
|
||||
// The snapshot database is untrusted. A hash that is not 64
|
||||
// lowercase hex characters could steer a later fetch to a path
|
||||
// outside the cache directory, so reject it here, before any
|
||||
// blob is downloaded.
|
||||
if !isBlobHash(hash) {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"%w: %s", errInvalidBlobHash, shortHash(hash))
|
||||
}
|
||||
|
||||
blobIDToHash[id] = hash
|
||||
blobByHash[hash] = blob
|
||||
}
|
||||
@@ -641,7 +607,7 @@ func (v *Vaultik) handleRestoreVerification(
|
||||
// for a remote-only snapshot) is used as-is, so a host with no local
|
||||
// index can restore the snapshots it can only see on the store.
|
||||
func (v *Vaultik) downloadSnapshotDB(
|
||||
snapshotID string, identities []age.Identity,
|
||||
snapshotID string, identity age.Identity,
|
||||
) (*database.DB, string, error) {
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
@@ -658,75 +624,41 @@ func (v *Vaultik) downloadSnapshotDB(
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
// Decrypt and decompress straight from the storage stream, then stream
|
||||
// the plaintext to a temp file. Neither the encrypted bytes nor the
|
||||
// decrypted database is ever held whole in memory; a snapshot database
|
||||
// can be large.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
// Read all data
|
||||
encryptedData, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("reading encrypted data: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Downloaded encrypted database",
|
||||
"size", ubytes(int64(len(encryptedData))))
|
||||
|
||||
// Decrypt and decompress using blobgen.Reader
|
||||
blobReader, err := blobgen.NewReader(bytes.NewReader(encryptedData), identity)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
|
||||
db, tempDir, err := v.materializeSnapshotDB(blobReader)
|
||||
// Read the binary SQLite database
|
||||
dbData, err := io.ReadAll(blobReader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", fmt.Errorf("decrypting and decompressing: %w", err)
|
||||
}
|
||||
|
||||
// Confirm the decrypted database really is the snapshot named by
|
||||
// remoteKey before any files are read from it. On mismatch, close the
|
||||
// database and remove its private directory so nothing is left behind.
|
||||
err = v.verifySnapshotDBIdentity(db, snapshotID, remoteKey)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
_ = v.Fs.RemoveAll(tempDir)
|
||||
log.Debug("Decrypted database", "size", ubytes(int64(len(dbData))))
|
||||
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return db, tempDir, nil
|
||||
return v.materializeSnapshotDB(dbData)
|
||||
}
|
||||
|
||||
// verifySnapshotDBIdentity confirms the decrypted metadata database really
|
||||
// is the snapshot named by remoteKey. age decryption proves the database
|
||||
// is readable, not that the object served at
|
||||
// metadata/<remoteKey>/db.zst.age is the snapshot that was requested: an
|
||||
// attacker who swaps in another valid db.zst.age (which needs no key
|
||||
// material) would otherwise redirect restore and deep verify to a
|
||||
// different snapshot's contents. The exported per-snapshot database holds
|
||||
// exactly one snapshot row, and a snapshot's remote key is derived from
|
||||
// that row's ID, so the database is the requested one exactly when its
|
||||
// sole snapshot hashes back to remoteKey. Comparing the requested
|
||||
// identifier directly would not do: it may be a remote-key prefix a
|
||||
// recovery host uses in place of a human snapshot ID it cannot know.
|
||||
func (v *Vaultik) verifySnapshotDBIdentity(
|
||||
db *database.DB, requested, remoteKey string,
|
||||
) error {
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
snap, err := repos.Snapshots.GetOnlySnapshot(v.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking identity of database for %s: %w", requested, err)
|
||||
}
|
||||
|
||||
if snapshot.RemoteSnapshotKey(snap.ID.String()) != remoteKey {
|
||||
return fmt.Errorf("%w: requested %s but the database is snapshot %s",
|
||||
errSnapshotDBMismatch, requested, snap.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// materializeSnapshotDB streams the decrypted snapshot database into a
|
||||
// fresh private (0700) temp directory and opens the file read-only. The
|
||||
// database is copied through an io.Copy buffer rather than read whole into
|
||||
// memory. On any failure it removes the directory before returning, so no
|
||||
// decrypted metadata is left on disk when the copy is interrupted or the
|
||||
// payload is damaged. On success the returned directory is the caller's to
|
||||
// remove.
|
||||
// materializeSnapshotDB writes the decrypted snapshot database bytes into
|
||||
// a fresh private (0700) temp directory and opens the file read-only. On
|
||||
// any failure it removes the directory before returning, so no decrypted
|
||||
// metadata is left on disk when the open is interrupted or the payload is
|
||||
// damaged. On success the returned directory is the caller's to remove.
|
||||
func (v *Vaultik) materializeSnapshotDB(
|
||||
dbReader io.Reader,
|
||||
dbData []byte,
|
||||
) (*database.DB, string, error) {
|
||||
tempDir, err := afero.TempDir(v.Fs, "", "vaultik-restore-")
|
||||
if err != nil {
|
||||
@@ -743,29 +675,12 @@ func (v *Vaultik) materializeSnapshotDB(
|
||||
|
||||
dbPath := filepath.Join(tempDir, snapshotDBFilename)
|
||||
|
||||
dbFile, err := v.Fs.OpenFile(
|
||||
dbPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, restoreFileMode)
|
||||
err = afero.WriteFile(v.Fs, dbPath, dbData, restoreFileMode)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("creating database file: %w", err)
|
||||
return nil, "", fmt.Errorf("writing database file: %w", err)
|
||||
}
|
||||
|
||||
written, copyErr := io.Copy(dbFile, dbReader)
|
||||
closeErr := dbFile.Close()
|
||||
|
||||
if copyErr != nil {
|
||||
return nil, "", fmt.Errorf("writing database file: %w", copyErr)
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
return nil, "", fmt.Errorf("closing database file: %w", closeErr)
|
||||
}
|
||||
|
||||
log.Debug("Created restore database", "path", dbPath, "size", ubytes(written))
|
||||
|
||||
// Reject an empty database before OpenReadOnly builds a schema on it.
|
||||
if written == 0 {
|
||||
return nil, "", errEmptySnapshotDB
|
||||
}
|
||||
log.Debug("Created restore database", "path", dbPath)
|
||||
|
||||
db, err := database.OpenReadOnly(v.ctx, dbPath)
|
||||
if err != nil {
|
||||
@@ -863,7 +778,7 @@ type restoreSession struct {
|
||||
ctx context.Context //nolint:containedctx // per-restore state by design
|
||||
repos *database.Repositories
|
||||
opts *RestoreOptions
|
||||
identities []age.Identity
|
||||
identity age.Identity
|
||||
chunkToBlobMap map[string]*database.BlobChunk
|
||||
blobByHash map[string]*database.Blob
|
||||
blobIDToHash map[string]string
|
||||
@@ -1162,12 +1077,6 @@ func (s *restoreSession) writeFileChunks(
|
||||
)
|
||||
|
||||
for _, fc := range fileChunks {
|
||||
// Stop between chunks on cancel so an interrupt does not keep
|
||||
// writing a large file after the operation has been told to stop.
|
||||
if s.ctx.Err() != nil {
|
||||
return bytesWritten, timings, s.ctx.Err()
|
||||
}
|
||||
|
||||
chunkHashStr := fc.ChunkHash.String()
|
||||
|
||||
blobChunk, ok := s.chunkToBlobMap[chunkHashStr]
|
||||
@@ -1220,12 +1129,12 @@ func (s *restoreSession) writeFileChunks(
|
||||
// size, which is what makes multi-GB blobs tractable on machines with
|
||||
// less RAM than the blob.
|
||||
func (s *restoreSession) downloadBlobToCache(
|
||||
blobHash string, compressedSize, uncompressedSize int64,
|
||||
blobHash string, expectedSize int64,
|
||||
) error {
|
||||
start := time.Now()
|
||||
|
||||
t0 := time.Now()
|
||||
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, uncompressedSize, s.identities...)
|
||||
rc, err := s.v.FetchAndDecryptBlob(s.ctx, blobHash, expectedSize, s.identity)
|
||||
fetchSetupDur := time.Since(t0)
|
||||
|
||||
if err != nil {
|
||||
@@ -1255,7 +1164,7 @@ func (s *restoreSession) downloadBlobToCache(
|
||||
|
||||
log.Debug("Streamed blob into disk cache",
|
||||
"hash", blobHash[:16],
|
||||
"compressed_bytes", compressedSize,
|
||||
"compressed_bytes", expectedSize,
|
||||
"plaintext_bytes", written,
|
||||
"ms_total", time.Since(start).Milliseconds(),
|
||||
"ms_fetch_setup", fetchSetupDur.Milliseconds(),
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package vaultik //nolint:testpackage // exercises unexported restoreIdentities
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
)
|
||||
|
||||
// encryptBlobTo returns a blobgen blob of plaintext encrypted to exactly
|
||||
// one recipient, so a decryptor succeeds only if it holds that recipient's
|
||||
// identity.
|
||||
func encryptBlobTo(t *testing.T, recipient string, plaintext []byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
writer, err := blobgen.NewWriter(&buf, 1, []string{recipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = writer.Write(plaintext)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// decryptBlobWith reads a blob back through the identities and returns its
|
||||
// plaintext.
|
||||
func decryptBlobWith(t *testing.T, blob []byte, identities []age.Identity) []byte {
|
||||
t.Helper()
|
||||
|
||||
reader, err := blobgen.NewReader(bytes.NewReader(blob), identities...)
|
||||
require.NoError(t, err)
|
||||
|
||||
plaintext, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, reader.Close())
|
||||
|
||||
return plaintext
|
||||
}
|
||||
|
||||
// TestRestoreIdentitiesAcceptsEveryIdentity proves a key file holding two
|
||||
// identities yields both, so a blob encrypted only to the second
|
||||
// recipient — the one the previous single-identity parse dropped — still
|
||||
// decrypts.
|
||||
func TestRestoreIdentitiesAcceptsEveryIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
// A whole age-keygen-style file: comment lines plus two identity lines.
|
||||
keyFile := "# public key: " + first.Recipient().String() + "\n" +
|
||||
first.String() + "\n" +
|
||||
"# public key: " + second.Recipient().String() + "\n" +
|
||||
second.String() + "\n"
|
||||
|
||||
v := &Vaultik{Config: &config.Config{AgeSecretKey: keyFile}}
|
||||
|
||||
identities, err := v.restoreIdentities()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, identities, 2)
|
||||
|
||||
plaintext := []byte("payload encrypted only to the second identity")
|
||||
blob := encryptBlobTo(t, second.Recipient().String(), plaintext)
|
||||
|
||||
require.Equal(t, plaintext, decryptBlobWith(t, blob, identities))
|
||||
}
|
||||
|
||||
// TestRestoreIdentitiesAcceptsTrailingNewline mirrors a YAML
|
||||
// age_secret_key value that carries a trailing newline: it must still
|
||||
// parse to its one identity and decrypt a blob encrypted to it.
|
||||
func TestRestoreIdentitiesAcceptsTrailingNewline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
id, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
v := &Vaultik{Config: &config.Config{AgeSecretKey: id.String() + "\n"}}
|
||||
|
||||
identities, err := v.restoreIdentities()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, identities, 1)
|
||||
|
||||
plaintext := []byte("value with a trailing newline")
|
||||
blob := encryptBlobTo(t, id.Recipient().String(), plaintext)
|
||||
|
||||
require.Equal(t, plaintext, decryptBlobWith(t, blob, identities))
|
||||
}
|
||||
|
||||
// TestRestoreIdentitiesMissingKey reports the dedicated missing-key error
|
||||
// rather than a parse failure, so the user is told to set the key.
|
||||
func TestRestoreIdentitiesMissingKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
v := &Vaultik{Config: &config.Config{}}
|
||||
|
||||
_, err := v.restoreIdentities()
|
||||
require.ErrorIs(t, err, errDecryptionKeyRequired)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package vaultik //nolint:testpackage // sets ctx/cancel and inspects scratch files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// blockingBlobStorer wraps a Storer and blocks the first blob download
|
||||
// until its context is cancelled, so a test can catch a restore while it
|
||||
// is mid-download. Metadata reads pass straight through, so the restore
|
||||
// reaches the blob-download phase — having already written its decrypted
|
||||
// scratch files — before it blocks.
|
||||
type blockingBlobStorer struct {
|
||||
storage.Storer
|
||||
|
||||
once sync.Once
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingBlobStorer(inner storage.Storer) *blockingBlobStorer {
|
||||
return &blockingBlobStorer{Storer: inner, entered: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (b *blockingBlobStorer) Get(
|
||||
ctx context.Context, key string,
|
||||
) (io.ReadCloser, error) {
|
||||
if strings.HasPrefix(key, "blobs/") {
|
||||
b.once.Do(func() { close(b.entered) })
|
||||
<-ctx.Done()
|
||||
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
return b.Storer.Get(ctx, key)
|
||||
}
|
||||
|
||||
// TestRestoreCleansTempDirOnInterrupt drives a restore through the stop
|
||||
// path (v.StartOperation, which is what the fx OnStop hook uses) instead
|
||||
// of calling Restore directly, catches it mid-download, and asserts that
|
||||
// stopping waits for the operation to unwind and removes its decrypted
|
||||
// scratch files — the blob cache and the temporary snapshot database —
|
||||
// from the temp directory. Without the wait a SIGINT exits the process
|
||||
// before those defers run, leaving decrypted data on disk (issue #159).
|
||||
//
|
||||
// Not parallel: it points TMPDIR at a private directory (via t.Setenv)
|
||||
// so it can assert on exactly the scratch files this restore created.
|
||||
func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
root := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(root, "source")
|
||||
storeDir := filepath.Join(root, "remote")
|
||||
restoreDir := filepath.Join(root, "restored")
|
||||
dbPath := filepath.Join(root, "index.sqlite")
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
|
||||
buildLocalityFixture(t, fs, dataDir)
|
||||
|
||||
cfg, storer, snapshotID := setupLocalityBackup(
|
||||
context.Background(), t, fs, dataDir, storeDir, dbPath)
|
||||
|
||||
// Point the "" temp paths (the blob cache directory and the
|
||||
// snapshot-database directory) at a private directory so the test can
|
||||
// assert on exactly the scratch this restore creates.
|
||||
scratch := filepath.Join(root, "scratch")
|
||||
require.NoError(t, fs.MkdirAll(scratch, 0o755))
|
||||
t.Setenv("TMPDIR", scratch)
|
||||
|
||||
gate := newBlockingBlobStorer(storer)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
v := &Vaultik{
|
||||
Config: cfg,
|
||||
Storage: gate,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
var (
|
||||
opReturned atomic.Bool
|
||||
restoreErr error
|
||||
)
|
||||
|
||||
stop := v.StartOperation(func() {
|
||||
defer opReturned.Store(true)
|
||||
|
||||
restoreErr = v.Restore(&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
})
|
||||
})
|
||||
|
||||
// Wait until the restore is blocked mid-download; its decrypted
|
||||
// scratch files exist by now.
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("restore never reached the blob-download phase")
|
||||
}
|
||||
|
||||
require.NotEmpty(t, scratchEntries(t, scratch),
|
||||
"expected decrypted scratch files to exist mid-restore")
|
||||
|
||||
// Stop the operation the way the fx OnStop hook does.
|
||||
stopCtx, stopCancel := context.WithTimeout(
|
||||
context.Background(), 30*time.Second)
|
||||
defer stopCancel()
|
||||
|
||||
require.True(t, stop(stopCtx),
|
||||
"stop timed out; the operation goroutine did not return")
|
||||
|
||||
// stop returns only once the operation goroutine has returned, so its
|
||||
// cleanup defers have run by the time we read these.
|
||||
require.True(t, opReturned.Load(),
|
||||
"stop returned before the operation goroutine finished")
|
||||
require.ErrorIs(t, restoreErr, context.Canceled)
|
||||
require.Empty(t, scratchEntries(t, scratch),
|
||||
"decrypted scratch files remained after the interrupt")
|
||||
}
|
||||
|
||||
// scratchEntries returns the vaultik blob-cache and snapshot-database
|
||||
// scratch entries currently present in dir.
|
||||
func scratchEntries(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
var matches []string
|
||||
|
||||
for _, pattern := range []string{
|
||||
"vaultik-blobcache-*", "vaultik-restore-*",
|
||||
} {
|
||||
found, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
require.NoError(t, err)
|
||||
|
||||
matches = append(matches, found...)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestRestoreRejectsMalformedKeyBeforeDownload verifies that a malformed
|
||||
// age secret key stops restore at the parse step: nothing is fetched from
|
||||
// the store, and the error does not echo the key value (which is secret).
|
||||
func TestRestoreRejectsMalformedKeyBeforeDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const malformed = "this-is-not-a-valid-age-key"
|
||||
|
||||
mock := NewMockStorer()
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: &config.Config{AgeSecretKey: malformed},
|
||||
Storage: mock,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(context.Background())
|
||||
|
||||
err := v.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: "any-snapshot",
|
||||
TargetDir: t.TempDir(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), malformed,
|
||||
"error must not echo the key value")
|
||||
require.Contains(t, err.Error(), "age_secret_key",
|
||||
"error should name the configuration source")
|
||||
require.Empty(t, mock.GetCalls(),
|
||||
"a malformed key must fail before anything is fetched")
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestRestoreAndDeepVerifyRejectSwappedDatabase proves that swapping two
|
||||
// snapshots' encrypted databases on the store is caught. age decryption
|
||||
// alone proves only that a database is readable; without an identity check
|
||||
// restore would happily write the wrong snapshot's files and deep verify
|
||||
// would report success. After the swap, restore and deep verify of A both
|
||||
// fail, and the error names the snapshot the database actually holds (B).
|
||||
func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Two snapshots with different content, backed up into one shared
|
||||
// store. Different names give them different remote keys, so their
|
||||
// metadata directories are distinct and can be tampered with alone.
|
||||
dataA := filepath.Join(tempDir, "srcA")
|
||||
require.NoError(t, fs.MkdirAll(dataA, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
|
||||
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
|
||||
|
||||
dataB := filepath.Join(tempDir, "srcB")
|
||||
require.NoError(t, fs.MkdirAll(dataB, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataB, "b.bin"),
|
||||
bytesPattern("beta-", int(chunkSize*2)), 0o644))
|
||||
|
||||
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
|
||||
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
|
||||
idB := backupNamedSnapshotToStore(ctx, t, fs, dataB, storer,
|
||||
filepath.Join(tempDir, "idxB.sqlite"), "beta")
|
||||
require.NotEqual(t, idA, idB)
|
||||
|
||||
keyA := snapshot.RemoteSnapshotKey(idA)
|
||||
keyB := snapshot.RemoteSnapshotKey(idB)
|
||||
require.NotEqual(t, keyA, keyB)
|
||||
|
||||
// Baseline: each snapshot verifies against its own intact metadata.
|
||||
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idA, &vaultik.VerifyOptions{Deep: true}))
|
||||
require.NoError(t, newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idB, &vaultik.VerifyOptions{Deep: true}))
|
||||
|
||||
// Swap the two snapshots' encrypted databases on the store.
|
||||
swapStoreFiles(t, fs,
|
||||
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"),
|
||||
filepath.Join(storeDir, "metadata", keyB, "db.zst.age"))
|
||||
|
||||
// Restore of A now decrypts B's database; the identity check must
|
||||
// reject it and name the snapshot it actually found.
|
||||
restoreErr := newStoreClient(ctx, t, fs, storer).Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: idA,
|
||||
TargetDir: filepath.Join(tempDir, "restoreA"),
|
||||
})
|
||||
require.Error(t, restoreErr)
|
||||
require.ErrorContains(t, restoreErr, idB)
|
||||
|
||||
// Deep verify of A must reject the swapped database for the same reason.
|
||||
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idA, &vaultik.VerifyOptions{Deep: true})
|
||||
require.Error(t, verifyErr)
|
||||
require.ErrorContains(t, verifyErr, idB)
|
||||
}
|
||||
|
||||
// TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest covers the case the
|
||||
// issue calls out: swapping in a database whose blob set is empty and
|
||||
// pairing it with an equally empty manifest. The manifest then agrees with
|
||||
// the database, so every blob-level check passes and deep verify used to
|
||||
// report success with zero blobs verified. The identity check rejects it
|
||||
// before any blob check runs.
|
||||
func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Snapshot A: real content, so its manifest lists blobs.
|
||||
dataA := filepath.Join(tempDir, "srcA")
|
||||
require.NoError(t, fs.MkdirAll(dataA, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(dataA, "a.bin"),
|
||||
bytesPattern("alpha-", int(chunkSize*2)), 0o644))
|
||||
idA := backupNamedSnapshotToStore(ctx, t, fs, dataA, storer,
|
||||
filepath.Join(tempDir, "idxA.sqlite"), "alpha")
|
||||
|
||||
// Snapshot C: a single empty file, so it references no blobs and its
|
||||
// manifest is empty. This is the database/manifest pair an attacker
|
||||
// would swap in to make the blob checks vacuously pass.
|
||||
dataC := filepath.Join(tempDir, "srcC")
|
||||
require.NoError(t, fs.MkdirAll(dataC, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataC, "empty.bin"), []byte{}, 0o644))
|
||||
idC := backupNamedSnapshotToStore(ctx, t, fs, dataC, storer,
|
||||
filepath.Join(tempDir, "idxC.sqlite"), "charlie")
|
||||
|
||||
keyA := snapshot.RemoteSnapshotKey(idA)
|
||||
keyC := snapshot.RemoteSnapshotKey(idC)
|
||||
|
||||
// Replace A's database and manifest with C's empty pair.
|
||||
copyStoreFile(t, fs,
|
||||
filepath.Join(storeDir, "metadata", keyC, "db.zst.age"),
|
||||
filepath.Join(storeDir, "metadata", keyA, "db.zst.age"))
|
||||
copyStoreFile(t, fs,
|
||||
filepath.Join(storeDir, "metadata", keyC, "manifest.json.zst"),
|
||||
filepath.Join(storeDir, "metadata", keyA, "manifest.json.zst"))
|
||||
|
||||
verifyErr := newStoreClient(ctx, t, fs, storer).RunDeepVerify(
|
||||
idA, &vaultik.VerifyOptions{Deep: true})
|
||||
require.Error(t, verifyErr)
|
||||
require.ErrorContains(t, verifyErr, idC)
|
||||
}
|
||||
|
||||
// backupNamedSnapshotToStore backs up dataDir into the shared storer under
|
||||
// the given snapshot name and returns the human snapshot ID. Two snapshots
|
||||
// backed up under different names get different remote keys, so their
|
||||
// metadata directories on the store are distinct.
|
||||
func backupNamedSnapshotToStore(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
dataDir string, storer storage.Storer, dbPath, name string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
chunkSize = int64(64 * 1024)
|
||||
maxBlobSize = int64(512 * 1024)
|
||||
)
|
||||
|
||||
cfg := &config.Config{
|
||||
AgeRecipients: []string{testAgePublicKey},
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
CompressionLevel: 3,
|
||||
Hostname: testHostname,
|
||||
}
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{
|
||||
Repos: repos,
|
||||
Storage: storer,
|
||||
Config: cfg,
|
||||
})
|
||||
sm.SetFilesystem(fs)
|
||||
|
||||
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
|
||||
FS: fs,
|
||||
Storage: storer,
|
||||
ChunkSize: chunkSize,
|
||||
MaxBlobSize: maxBlobSize,
|
||||
CompressionLevel: cfg.CompressionLevel,
|
||||
AgeRecipients: cfg.AgeRecipients,
|
||||
Repositories: repos,
|
||||
})
|
||||
|
||||
snapshotID, err := sm.CreateSnapshotWithName(
|
||||
ctx, cfg.Hostname, name, "test-version", "test-git")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = scanner.Scan(ctx, dataDir, snapshotID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
|
||||
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
return snapshotID
|
||||
}
|
||||
|
||||
// newStoreClient builds a Vaultik that reads only from the store: the
|
||||
// secret key, the storer, and a filesystem, with no local index. This is
|
||||
// what restore and deep verify need.
|
||||
func newStoreClient(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs, storer storage.Storer,
|
||||
) *vaultik.Vaultik {
|
||||
t.Helper()
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Config: &config.Config{
|
||||
AgeSecretKey: testAgeSecretKey,
|
||||
Hostname: testHostname,
|
||||
},
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// swapStoreFiles exchanges the contents of two files on the store.
|
||||
func swapStoreFiles(t *testing.T, fs afero.Fs, a, b string) {
|
||||
t.Helper()
|
||||
|
||||
dataA, err := afero.ReadFile(fs, a)
|
||||
require.NoError(t, err)
|
||||
|
||||
dataB, err := afero.ReadFile(fs, b)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, a, dataB, 0o644))
|
||||
require.NoError(t, afero.WriteFile(fs, b, dataA, 0o644))
|
||||
}
|
||||
|
||||
// copyStoreFile overwrites dst with the contents of src on the store.
|
||||
func copyStoreFile(t *testing.T, fs afero.Fs, src, dst string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := afero.ReadFile(fs, src)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, dst, data, 0o644))
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
package vaultik //nolint:testpackage // inspects unexported snapshot-db materialization
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
)
|
||||
|
||||
@@ -40,7 +37,7 @@ func TestMaterializeSnapshotDBPrivateDir(t *testing.T) {
|
||||
|
||||
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
||||
|
||||
db, dir, err := v.materializeSnapshotDB(bytes.NewReader(dbData))
|
||||
db, dir, err := v.materializeSnapshotDB(dbData)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
@@ -58,37 +55,6 @@ func TestMaterializeSnapshotDBPrivateDir(t *testing.T) {
|
||||
require.Error(t, err, "materialized snapshot database must be read-only")
|
||||
}
|
||||
|
||||
// TestMaterializeSnapshotDBRejectsCompleteEmptyStream proves the written == 0
|
||||
// guard rejects a genuinely empty but complete metadata object: a real age
|
||||
// header, nonce, and final tag encrypting zero plaintext bytes. The truncation
|
||||
// case is stopped earlier by the reader (io.ErrUnexpectedEOF) and never reaches
|
||||
// this branch, so it needs its own input. This complete stream decrypts to zero
|
||||
// bytes with a clean EOF, passes the reader, and must be refused as empty rather
|
||||
// than accepted as a valid zero-table database. Reverting the guard lets the
|
||||
// empty file open as a fresh schema and the test fails.
|
||||
func TestMaterializeSnapshotDBRejectsCompleteEmptyStream(t *testing.T) {
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
var stream bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&stream, identity.Recipient())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
blobReader, err := blobgen.NewReader(bytes.NewReader(stream.Bytes()), identity)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = blobReader.Close() })
|
||||
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
|
||||
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
||||
|
||||
_, _, err = v.materializeSnapshotDB(blobReader)
|
||||
require.ErrorIs(t, err, errEmptySnapshotDB)
|
||||
}
|
||||
|
||||
// TestMaterializeSnapshotDBRemovesDirOnOpenFailure proves a failed open
|
||||
// leaves no temp directory behind.
|
||||
func TestMaterializeSnapshotDBRemovesDirOnOpenFailure(t *testing.T) {
|
||||
@@ -98,8 +64,7 @@ func TestMaterializeSnapshotDBRemovesDirOnOpenFailure(t *testing.T) {
|
||||
|
||||
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
||||
|
||||
_, _, err := v.materializeSnapshotDB(
|
||||
bytes.NewReader([]byte("this is not a sqlite database")))
|
||||
_, _, err := v.materializeSnapshotDB([]byte("this is not a sqlite database"))
|
||||
require.Error(t, err)
|
||||
|
||||
entries, rerr := os.ReadDir(base)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestRestoreRejectsTruncatedMetadataDB backs up a real tree, then replaces
|
||||
// the snapshot's db.zst.age with a stream cut right after the age header and
|
||||
// its 16-byte nonce. age.Decrypt still accepts such an object and the zstd
|
||||
// decoder turns the truncated read into a clean EOF, so before the fix restore
|
||||
// built a fresh empty schema and reported success. Restore must now fail with
|
||||
// io.ErrUnexpectedEOF, the error the reader raises for a truncated object.
|
||||
// Asserting that specific error pins the reader fix: without it the truncation
|
||||
// yields an empty database, which the identity check rejects for an unrelated
|
||||
// reason, and this test would pass anyway.
|
||||
func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
maxBlobSize := int64(512 * 1024)
|
||||
|
||||
setupE2ESourceTree(t, fs, dataDir, chunkSize)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
// Encrypting empty plaintext to the snapshot recipient yields
|
||||
// header + nonce(16) + a single 16-byte final chunk tag. Dropping the
|
||||
// trailing tag leaves exactly the age header plus its nonce — the
|
||||
// truncation an attacker can write over metadata without any key.
|
||||
recipient, err := age.ParseX25519Recipient(testAgePublicKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
var full bytes.Buffer
|
||||
|
||||
w, err := age.Encrypt(&full, recipient)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
truncated := full.Bytes()[:full.Len()-16]
|
||||
|
||||
dbKeyPath := filepath.Join(storeDir, "metadata",
|
||||
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")
|
||||
require.NoError(t, afero.WriteFile(fs, dbKeyPath, truncated, 0o644))
|
||||
|
||||
restoreVaultik := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
restoreVaultik.SetContext(ctx)
|
||||
|
||||
err = restoreVaultik.Restore(&vaultik.RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
Verify: true,
|
||||
})
|
||||
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestShallowVerifyDetectsWrongBlobSize backs up a real snapshot, runs
|
||||
// shallow verify (which passes and reports exactly the blobs it checked),
|
||||
// then grows one stored blob so its size no longer matches the manifest.
|
||||
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
||||
// and drop it from the verified count rather than continuing to report it
|
||||
// as checked.
|
||||
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
// Enough data to span several blobs, so the mismatch count and the
|
||||
// dropped verified count are both meaningful.
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("shallow-", int(maxBlobSize*4)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
newVerifier := func(out io.Writer) *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: out,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
require.NoError(t,
|
||||
newVerifier(&out).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{JSON: true}),
|
||||
"shallow verify should pass on a healthy snapshot")
|
||||
|
||||
healthy := decodeVerifyResult(t, out.Bytes())
|
||||
require.Equal(t, "ok", healthy.Status)
|
||||
require.Positive(t, healthy.BlobCount)
|
||||
require.Equal(t, healthy.BlobCount, healthy.Verified,
|
||||
"shallow verify must report exactly the blobs it checked")
|
||||
require.Zero(t, healthy.Mismatched)
|
||||
|
||||
// Grow one stored blob so its size no longer matches the manifest.
|
||||
growOneBlob(t, fs, filepath.Join(storeDir, "blobs"))
|
||||
|
||||
out.Reset()
|
||||
err := newVerifier(&out).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{JSON: true})
|
||||
require.Error(t, err,
|
||||
"shallow verify must fail when a blob's stored size differs from the manifest")
|
||||
|
||||
bad := decodeVerifyResult(t, out.Bytes())
|
||||
require.Equal(t, "failed", bad.Status)
|
||||
require.Equal(t, 1, bad.Mismatched)
|
||||
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
||||
"the wrong-sized blob must not be counted as verified")
|
||||
}
|
||||
|
||||
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
||||
// confirms shallow verify passes, then deletes the snapshot's encrypted
|
||||
// database. Shallow verify must fail: a snapshot without its database is
|
||||
// not restorable, even when every blob is present.
|
||||
func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
maxBlobSize := int64(128 * 1024)
|
||||
|
||||
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(dataDir, "data.bin"),
|
||||
bytesPattern("shallow-db-", int(maxBlobSize*2)), 0o644))
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := runFileStorageBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, chunkSize, maxBlobSize)
|
||||
|
||||
newVerifier := func() *vaultik.Vaultik {
|
||||
v := &vaultik.Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
require.NoError(t,
|
||||
newVerifier().VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{}),
|
||||
"shallow verify should pass on a healthy snapshot")
|
||||
|
||||
// The database lives under the hashed remote key, not the human ID.
|
||||
dbObject := filepath.Join(storeDir, "metadata",
|
||||
snapshot.RemoteSnapshotKey(snapshotID), "db.zst.age")
|
||||
require.NoError(t, os.Remove(dbObject))
|
||||
|
||||
require.Error(t,
|
||||
newVerifier().VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{}),
|
||||
"shallow verify must fail when db.zst.age is absent")
|
||||
}
|
||||
|
||||
// growOneBlob appends bytes to the first blob file found under blobsDir,
|
||||
// changing its on-disk size so it no longer matches the manifest.
|
||||
func growOneBlob(t *testing.T, fs afero.Fs, blobsDir string) {
|
||||
t.Helper()
|
||||
|
||||
var blobPath string
|
||||
|
||||
err := afero.Walk(fs, blobsDir,
|
||||
func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if blobPath == "" && !info.IsDir() {
|
||||
blobPath = path
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blobPath, "expected at least one blob on disk")
|
||||
|
||||
data, err := afero.ReadFile(fs, blobPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
data = append(data, []byte("extra")...)
|
||||
require.NoError(t, afero.WriteFile(fs, blobPath, data, 0o644))
|
||||
}
|
||||
|
||||
func decodeVerifyResult(t *testing.T, b []byte) vaultik.VerifyResult {
|
||||
t.Helper()
|
||||
|
||||
var result vaultik.VerifyResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(b, &result))
|
||||
|
||||
return result
|
||||
}
|
||||
+52
-145
@@ -20,6 +20,7 @@ import (
|
||||
var (
|
||||
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||
errBlobsMissing = errors.New("blobs are missing")
|
||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||
errInvalidTableName = errors.New("invalid table name")
|
||||
@@ -55,8 +56,8 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up incomplete snapshots FIRST, before any scanning.
|
||||
// This is critical for data safety; PruneDatabase below does it.
|
||||
// Clean up incomplete snapshots FIRST, before any scanning
|
||||
// This is critical for data safety - see CleanupIncompleteSnapshots for details
|
||||
hostname := v.Config.Hostname
|
||||
if hostname == "" {
|
||||
hostname, _ = os.Hostname()
|
||||
@@ -669,24 +670,11 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
|
||||
v.printVerifyHeader(snapshotID, opts)
|
||||
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
// remote-only snapshot) is used as-is, so a host with no local index
|
||||
// can verify a snapshot it can only see on the store. The key is kept
|
||||
// so we can also check for the snapshot's encrypted database below.
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = fmt.Sprintf("resolving snapshot identifier: %v", err)
|
||||
|
||||
return v.outputVerifyJSON(result)
|
||||
}
|
||||
|
||||
return fmt.Errorf("resolving snapshot identifier: %w", err)
|
||||
}
|
||||
|
||||
manifest, err := v.downloadManifestByKey(remoteKey)
|
||||
// Resolve the identifier to the snapshot's remote key and download the
|
||||
// manifest. A human ID is hashed; a remote key (or its abbreviation,
|
||||
// as printed for a remote-only snapshot) is used as-is, so a host with
|
||||
// no local index can verify a snapshot it can only see on the store.
|
||||
manifest, err := v.resolveAndDownloadManifest(snapshotID)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
@@ -716,34 +704,14 @@ func (v *Vaultik) VerifySnapshotWithOptions(
|
||||
|
||||
v.printlnStdout()
|
||||
|
||||
// Check each blob is present with the size the manifest records.
|
||||
v.stdoutf("Checking blob presence and sizes...\n")
|
||||
// Check each blob exists
|
||||
v.stdoutf("Checking blob existence...\n")
|
||||
}
|
||||
|
||||
// A snapshot is only restorable if its encrypted database is present
|
||||
// alongside the blobs. Shallow verify checks that the object exists; it
|
||||
// does not decrypt it (that is deep verify's job).
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
result.Verified, result.Missing, result.MissingSize =
|
||||
v.verifyManifestBlobsExist(manifest, opts)
|
||||
|
||||
_, dbErr := v.Storage.Stat(v.ctx, dbPath)
|
||||
if dbErr != nil {
|
||||
result.DatabaseMissing = true
|
||||
}
|
||||
|
||||
result.Verified, result.Missing, result.Mismatched, result.MissingSize, err =
|
||||
v.verifyManifestBlobs(manifest, opts)
|
||||
if err != nil {
|
||||
if opts.JSON {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = fmt.Sprintf("verifying manifest blobs: %v", err)
|
||||
|
||||
return v.outputVerifyJSON(result)
|
||||
}
|
||||
|
||||
return fmt.Errorf("verifying manifest blobs: %w", err)
|
||||
}
|
||||
|
||||
return v.formatVerifyResult(result, opts)
|
||||
return v.formatVerifyResult(result, manifest, opts)
|
||||
}
|
||||
|
||||
// printVerifyHeader prints the snapshot ID and parsed timestamp for
|
||||
@@ -768,34 +736,25 @@ func (v *Vaultik) printVerifyHeader(snapshotID string, opts *VerifyOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// verifyManifestBlobs checks that each blob in the manifest is present in
|
||||
// storage with the size the manifest records, returning the counts of
|
||||
// blobs that were present with the right size, absent, and present but the
|
||||
// wrong size, plus the total bytes of the absent blobs. It does not read
|
||||
// blob contents; deep verification (RunDeepVerify) does that. The size
|
||||
// comparison matches the deep path (see verifyBlobExistenceFromDB).
|
||||
func (v *Vaultik) verifyManifestBlobs(
|
||||
// verifyManifestBlobsExist checks that each blob in the manifest exists
|
||||
// in storage, returning the verified count, missing count, and total
|
||||
// missing bytes.
|
||||
func (v *Vaultik) verifyManifestBlobsExist(
|
||||
manifest *snapshot.Manifest, opts *VerifyOptions,
|
||||
) (int, int, int, int64, error) {
|
||||
) (int, int, int64) {
|
||||
var (
|
||||
verified, missing, mismatched int
|
||||
missingSize int64
|
||||
verified, missing int
|
||||
missingSize int64
|
||||
)
|
||||
|
||||
for _, blob := range manifest.Blobs {
|
||||
// The manifest is unauthenticated, so its blob hashes are checked
|
||||
// before being spliced into a storage path.
|
||||
if !isBlobHash(blob.Hash) {
|
||||
return 0, 0, 0, 0, fmt.Errorf("%w: %s",
|
||||
errInvalidBlobHash, shortHash(blob.Hash))
|
||||
}
|
||||
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s",
|
||||
blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||
|
||||
stat, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
switch {
|
||||
case err != nil:
|
||||
// Shallow: check existence only (deep verification is handled
|
||||
// by RunDeepVerify).
|
||||
_, err := v.Storage.Stat(v.ctx, blobPath)
|
||||
if err != nil {
|
||||
if !opts.JSON {
|
||||
v.stdoutf(" Missing: %s (%s)\n",
|
||||
blob.Hash, ubytes(blob.CompressedSize))
|
||||
@@ -803,32 +762,23 @@ func (v *Vaultik) verifyManifestBlobs(
|
||||
|
||||
missing++
|
||||
missingSize += blob.CompressedSize
|
||||
case stat.Size != blob.CompressedSize:
|
||||
if !opts.JSON {
|
||||
v.stdoutf(" Wrong size: %s (store has %s, manifest lists %s)\n",
|
||||
blob.Hash, ubytes(stat.Size), ubytes(blob.CompressedSize))
|
||||
}
|
||||
|
||||
mismatched++
|
||||
default:
|
||||
} else {
|
||||
verified++
|
||||
}
|
||||
}
|
||||
|
||||
return verified, missing, mismatched, missingSize, nil
|
||||
return verified, missing, missingSize
|
||||
}
|
||||
|
||||
// formatVerifyResult outputs the final verification results as JSON or
|
||||
// human-readable text.
|
||||
func (v *Vaultik) formatVerifyResult(
|
||||
result *VerifyResult, opts *VerifyOptions,
|
||||
result *VerifyResult, manifest *snapshot.Manifest, opts *VerifyOptions,
|
||||
) error {
|
||||
failure := shallowVerifyFailure(result)
|
||||
|
||||
if opts.JSON {
|
||||
if failure != "" {
|
||||
if result.Missing > 0 {
|
||||
result.Status = verifyStatusFailed
|
||||
result.ErrorMessage = failure
|
||||
result.ErrorMessage = fmt.Sprintf("%d blobs are missing", result.Missing)
|
||||
} else {
|
||||
result.Status = "ok"
|
||||
}
|
||||
@@ -837,57 +787,29 @@ func (v *Vaultik) formatVerifyResult(
|
||||
}
|
||||
|
||||
v.stdoutf("\nVerification complete:\n")
|
||||
v.stdoutf(" Present with listed size: %d blobs\n", result.Verified)
|
||||
v.stdoutf(" Verified: %d blobs (%s)\n", result.Verified,
|
||||
ubytes(manifest.TotalCompressedSize-result.MissingSize))
|
||||
|
||||
if result.Missing > 0 {
|
||||
v.stdoutf(" Missing: %d blobs (%s)\n",
|
||||
v.stdoutf(" Missing: %d blobs (%s)\n",
|
||||
result.Missing, ubytes(result.MissingSize))
|
||||
}
|
||||
|
||||
if result.Mismatched > 0 {
|
||||
v.stdoutf(" Wrong size: %d blobs\n", result.Mismatched)
|
||||
}
|
||||
|
||||
if result.DatabaseMissing {
|
||||
v.stdoutf(" Encrypted database: missing\n")
|
||||
} else {
|
||||
v.stdoutf(" Missing: 0 blobs\n")
|
||||
}
|
||||
|
||||
v.stdoutf(" Status: ")
|
||||
|
||||
if failure != "" {
|
||||
v.stdoutf("FAILED - %s\n", failure)
|
||||
if result.Missing > 0 {
|
||||
v.stdoutf("FAILED - %d blobs are missing\n", result.Missing)
|
||||
|
||||
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure)
|
||||
return fmt.Errorf("%d %w", result.Missing, errBlobsMissing)
|
||||
}
|
||||
|
||||
// Report only what was actually checked: presence and size, not contents.
|
||||
v.stdoutf("OK - all %d blobs listed in the manifest are present with the "+
|
||||
"listed size; contents not checked (use --deep)\n", result.Verified)
|
||||
v.stdoutf("OK - All blobs verified\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shallowVerifyFailure returns a human-readable description of everything
|
||||
// that failed shallow verification, or the empty string if it passed.
|
||||
func shallowVerifyFailure(result *VerifyResult) string {
|
||||
var parts []string
|
||||
|
||||
if result.Missing > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d blobs are missing", result.Missing))
|
||||
}
|
||||
|
||||
if result.Mismatched > 0 {
|
||||
parts = append(parts,
|
||||
fmt.Sprintf("%d blobs have the wrong size", result.Mismatched))
|
||||
}
|
||||
|
||||
if result.DatabaseMissing {
|
||||
parts = append(parts, "the encrypted database is missing")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// outputVerifyJSON outputs the verification result as JSON
|
||||
func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
|
||||
encoder := json.NewEncoder(v.Stdout)
|
||||
@@ -1330,36 +1252,21 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
}
|
||||
|
||||
parts := strings.Split(object.Key, "/")
|
||||
if len(parts) < minSnapshotIDParts ||
|
||||
parts[0] != metadataDirName || parts[1] == "" {
|
||||
continue
|
||||
}
|
||||
if len(parts) >= minSnapshotIDParts &&
|
||||
parts[0] == metadataDirName && parts[1] != "" {
|
||||
// Skip macOS resource fork files (._*) and other hidden files
|
||||
if strings.HasPrefix(parts[1], ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip macOS resource fork files (._*) and other hidden files
|
||||
if strings.HasPrefix(parts[1], ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(object.Key, "/") &&
|
||||
!strings.Contains(object.Key, "/manifest.json.zst") {
|
||||
continue
|
||||
}
|
||||
|
||||
key := parts[1]
|
||||
|
||||
// A remote snapshot key is a SHA-256 hash: 64 lowercase hex
|
||||
// characters. The listing comes from the untrusted destination,
|
||||
// so accept a key only in that form.
|
||||
if !isBlobHash(key) {
|
||||
log.Warn("Skipping non-conforming key under metadata/",
|
||||
"key", object.Key)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
if strings.HasSuffix(object.Key, "/") ||
|
||||
strings.Contains(object.Key, "/manifest.json.zst") {
|
||||
key := parts[1]
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,20 @@ func (v *Vaultik) resolveSnapshotRemoteKey(identifier string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAndDownloadManifest resolves a snapshot identifier to its remote
|
||||
// key (see resolveSnapshotRemoteKey) and downloads that snapshot's
|
||||
// manifest.
|
||||
func (v *Vaultik) resolveAndDownloadManifest(
|
||||
identifier string,
|
||||
) (*snapshot.Manifest, error) {
|
||||
remoteKey, err := v.resolveSnapshotRemoteKey(identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return v.downloadManifestByKey(remoteKey)
|
||||
}
|
||||
|
||||
// isRemoteKeyOrPrefix reports whether s is a full remote key or the
|
||||
// leading part of one: 1 to 64 lowercase hex characters. A human snapshot
|
||||
// ID is never all hex, so this shape test is enough to tell the two apart.
|
||||
|
||||
+28
-34
@@ -5,6 +5,7 @@ package vaultik
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/globals"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
@@ -19,6 +21,12 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// Sentinel errors for misconfigured encryption settings.
|
||||
var (
|
||||
errNoAgeRecipients = errors.New("no age recipients configured")
|
||||
errNoAgeSecretKey = errors.New("no age secret key configured")
|
||||
)
|
||||
|
||||
// Vaultik contains all dependencies needed for vaultik operations
|
||||
type Vaultik struct {
|
||||
Globals *globals.Globals
|
||||
@@ -128,45 +136,31 @@ func (v *Vaultik) Cancel() {
|
||||
v.cancel()
|
||||
}
|
||||
|
||||
// StartOperation runs fn in its own goroutine and returns a stop
|
||||
// function. fn is the command being run (a restore, verify, prune, and
|
||||
// so on); it observes cancellation through the Vaultik context and
|
||||
// removes its decrypted scratch files (the blob cache and the temporary
|
||||
// snapshot database) from the temp directory as it unwinds.
|
||||
//
|
||||
// Calling stop cancels the Vaultik context and then blocks until fn has
|
||||
// returned — so that unwinding, and the cleanup it does, completes
|
||||
// before the caller proceeds — or until the passed context is done,
|
||||
// whichever comes first. It reports whether fn returned before that
|
||||
// deadline. A signal-driven shutdown must call stop before the process
|
||||
// exits; otherwise the process can exit mid-operation and leave
|
||||
// decrypted data behind.
|
||||
func (v *Vaultik) StartOperation(fn func()) func(context.Context) bool {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
fn()
|
||||
}()
|
||||
|
||||
return func(ctx context.Context) bool {
|
||||
v.Cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CanDecrypt returns true if this Vaultik instance has decryption capabilities
|
||||
func (v *Vaultik) CanDecrypt() bool {
|
||||
return v.Config.AgeSecretKey != ""
|
||||
}
|
||||
|
||||
// GetEncryptor creates a new Encryptor instance based on the configured age recipients
|
||||
// Returns an error if no recipients are configured
|
||||
func (v *Vaultik) GetEncryptor() (*crypto.Encryptor, error) {
|
||||
if len(v.Config.AgeRecipients) == 0 {
|
||||
return nil, errNoAgeRecipients
|
||||
}
|
||||
|
||||
return crypto.NewEncryptor(v.Config.AgeRecipients)
|
||||
}
|
||||
|
||||
// GetDecryptor creates a new Decryptor instance based on the configured age secret key
|
||||
// Returns an error if no secret key is configured
|
||||
func (v *Vaultik) GetDecryptor() (*crypto.Decryptor, error) {
|
||||
if v.Config.AgeSecretKey == "" {
|
||||
return nil, errNoAgeSecretKey
|
||||
}
|
||||
|
||||
return crypto.NewDecryptor(v.Config.AgeSecretKey)
|
||||
}
|
||||
|
||||
// GetFilesystem returns the filesystem instance used by Vaultik
|
||||
//
|
||||
//nolint:ireturn // afero.Fs is the filesystem abstraction by design
|
||||
|
||||
+95
-126
@@ -6,14 +6,14 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/blobgen"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
@@ -24,10 +24,9 @@ var (
|
||||
errVerificationFailed = errors.New("verification failed")
|
||||
errSecretKeyRequired = errors.New(
|
||||
"VAULTIK_AGE_SECRET_KEY not set; required for deep verification")
|
||||
errChunksOutOfOrder = errors.New("chunks out of order")
|
||||
errChunkHashMismatch = errors.New("chunk hash mismatch")
|
||||
errNegativeChunkLength = errors.New("chunk length is negative")
|
||||
errTrailingBlobData = errors.New(
|
||||
errChunksOutOfOrder = errors.New("chunks out of order")
|
||||
errChunkHashMismatch = errors.New("chunk hash mismatch")
|
||||
errTrailingBlobData = errors.New(
|
||||
"blob has unexpected trailing bytes not covered by chunk list")
|
||||
errManifestExtraBlob = errors.New("manifest contains blob not in database")
|
||||
errManifestMissingBlob = errors.New(
|
||||
@@ -48,20 +47,15 @@ type VerifyOptions struct {
|
||||
//
|
||||
//nolint:tagliatelle // snake_case is the established JSON output format
|
||||
type VerifyResult struct {
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Status string `json:"status"` // "ok" or "failed"
|
||||
Mode string `json:"mode"` // "shallow" or "deep"
|
||||
BlobCount int `json:"blob_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
Verified int `json:"verified"`
|
||||
Missing int `json:"missing"`
|
||||
MissingSize int64 `json:"missing_size,omitempty"`
|
||||
Mismatched int `json:"mismatched,omitempty"`
|
||||
// DatabaseMissing is set by shallow verify when the snapshot's
|
||||
// encrypted database (metadata/<key>/db.zst.age) is absent, which
|
||||
// makes the snapshot unrestorable regardless of the blobs.
|
||||
DatabaseMissing bool `json:"database_missing,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
Status string `json:"status"` // "ok" or "failed"
|
||||
Mode string `json:"mode"` // "shallow" or "deep"
|
||||
BlobCount int `json:"blob_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
Verified int `json:"verified"`
|
||||
Missing int `json:"missing"`
|
||||
MissingSize int64 `json:"missing_size,omitempty"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// deepVerifyFailure records a failure in the result and returns it appropriately
|
||||
@@ -94,21 +88,13 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
errSecretKeyRequired.Error(), errSecretKeyRequired)
|
||||
}
|
||||
|
||||
// Parse the age secret key once, the same way restore does, and reuse
|
||||
// the identities for the database and every blob.
|
||||
identities, err := v.restoreIdentities()
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
|
||||
log.Info("Starting snapshot verification", "snapshot_id", snapshotID, "mode", "deep")
|
||||
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Deep verification of snapshot: %s\n\n", snapshotID)
|
||||
}
|
||||
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(
|
||||
snapshotID, opts, result, identities)
|
||||
manifest, tempDB, dbBlobs, err := v.loadVerificationData(snapshotID, opts, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -128,8 +114,7 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
|
||||
result.TotalSize = totalSize
|
||||
|
||||
err = v.runVerificationSteps(
|
||||
manifest, dbBlobs, tempDB, opts, result, totalSize, identities)
|
||||
err = v.runVerificationSteps(manifest, dbBlobs, tempDB, opts, result, totalSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -154,7 +139,6 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
|
||||
// loadVerificationData downloads manifest, database, and blob list for verification
|
||||
func (v *Vaultik) loadVerificationData(
|
||||
snapshotID string, opts *VerifyOptions, result *VerifyResult,
|
||||
identities []age.Identity,
|
||||
) (*snapshot.Manifest, *tempDB, []snapshot.BlobInfo, error) {
|
||||
// Resolve the identifier to the snapshot's remote key. A human ID is
|
||||
// hashed; a remote key (or its abbreviation, as printed for a
|
||||
@@ -191,10 +175,24 @@ func (v *Vaultik) loadVerificationData(
|
||||
v.stdoutf("Downloading and decrypting database...\n")
|
||||
}
|
||||
|
||||
tdb, err := v.downloadVerifiedSnapshotDB(
|
||||
snapshotID, remoteKey, opts, result, identities)
|
||||
// Download and decrypt database
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
log.Info("Downloading encrypted database", "path", dbPath)
|
||||
|
||||
dbReader, err := v.Storage.Get(v.ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to download database: %v", err),
|
||||
fmt.Errorf("failed to download database: %w", err))
|
||||
}
|
||||
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader)
|
||||
if err != nil {
|
||||
return nil, nil, nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||
fmt.Errorf("failed to decrypt database: %w", err))
|
||||
}
|
||||
|
||||
dbBlobs, err := v.getBlobsFromDatabase(tdb.db.Conn())
|
||||
@@ -223,45 +221,6 @@ func (v *Vaultik) loadVerificationData(
|
||||
return manifest, tdb, dbBlobs, nil
|
||||
}
|
||||
|
||||
// downloadVerifiedSnapshotDB downloads and decrypts the snapshot metadata
|
||||
// database and confirms it really is the snapshot named by remoteKey
|
||||
// before any of its rows are trusted (see verifySnapshotDBIdentity). On
|
||||
// any failure it records the failure in result and returns the error the
|
||||
// caller should propagate; the temp database is closed on a rejected
|
||||
// identity so nothing is left on disk.
|
||||
func (v *Vaultik) downloadVerifiedSnapshotDB(
|
||||
snapshotID, remoteKey string, opts *VerifyOptions, result *VerifyResult,
|
||||
identities []age.Identity,
|
||||
) (*tempDB, error) {
|
||||
dbPath := fmt.Sprintf("metadata/%s/db.zst.age", remoteKey)
|
||||
log.Info("Downloading encrypted database", "path", dbPath)
|
||||
|
||||
dbReader, err := v.Storage.Get(v.ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to download database: %v", err),
|
||||
fmt.Errorf("failed to download database: %w", err))
|
||||
}
|
||||
|
||||
defer func() { _ = dbReader.Close() }()
|
||||
|
||||
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
|
||||
if err != nil {
|
||||
return nil, v.deepVerifyFailure(result, opts,
|
||||
fmt.Sprintf("failed to decrypt database: %v", err),
|
||||
fmt.Errorf("failed to decrypt database: %w", err))
|
||||
}
|
||||
|
||||
err = v.verifySnapshotDBIdentity(tdb.db, snapshotID, remoteKey)
|
||||
if err != nil {
|
||||
_ = tdb.Close()
|
||||
|
||||
return nil, v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
|
||||
return tdb, nil
|
||||
}
|
||||
|
||||
// runVerificationSteps executes manifest verification, blob existence
|
||||
// check, and deep content verification.
|
||||
func (v *Vaultik) runVerificationSteps(
|
||||
@@ -271,7 +230,6 @@ func (v *Vaultik) runVerificationSteps(
|
||||
opts *VerifyOptions,
|
||||
result *VerifyResult,
|
||||
totalSize int64,
|
||||
identities []age.Identity,
|
||||
) error {
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Verifying manifest against database...\n")
|
||||
@@ -298,7 +256,7 @@ func (v *Vaultik) runVerificationSteps(
|
||||
len(dbBlobs), ubytes(totalSize))
|
||||
}
|
||||
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts, identities)
|
||||
err = v.performDeepVerificationFromDB(dbBlobs, tdb.db.Conn(), opts)
|
||||
if err != nil {
|
||||
return v.deepVerifyFailure(result, opts, err.Error(), err)
|
||||
}
|
||||
@@ -323,18 +281,26 @@ func (t *tempDB) Close() error {
|
||||
}
|
||||
|
||||
// decryptAndLoadDatabase decrypts and loads the binary SQLite database
|
||||
// from the encrypted stream. It reads through the same blobgen reader restore
|
||||
// uses, streaming the decrypted, decompressed database to a temp file.
|
||||
func (v *Vaultik) decryptAndLoadDatabase(
|
||||
reader io.ReadCloser, identities []age.Identity,
|
||||
) (*tempDB, error) {
|
||||
// Decrypt and decompress through the shared blobgen reader.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
// from the encrypted stream.
|
||||
func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser) (*tempDB, error) {
|
||||
// Get decryptor
|
||||
decryptor, err := v.GetDecryptor()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decryption reader: %w", err)
|
||||
return nil, fmt.Errorf("failed to get decryptor: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
// Decrypt the stream
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt database: %w", err)
|
||||
}
|
||||
|
||||
// Decompress the binary database
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decompressor: %w", err)
|
||||
}
|
||||
defer decompressor.Close()
|
||||
|
||||
// Materialize the decrypted database inside a private (0700) temp
|
||||
// directory so it is never world-readable, and remove the whole
|
||||
@@ -364,7 +330,7 @@ func (v *Vaultik) decryptAndLoadDatabase(
|
||||
// Stream decompress directly to file
|
||||
log.Info("Decompressing database...")
|
||||
|
||||
written, err := io.Copy(tempFile, blobReader)
|
||||
written, err := io.Copy(tempFile, decompressor)
|
||||
if err != nil {
|
||||
_ = tempFile.Close()
|
||||
|
||||
@@ -389,40 +355,53 @@ func (v *Vaultik) decryptAndLoadDatabase(
|
||||
}
|
||||
|
||||
// verifyBlob downloads and verifies a single blob
|
||||
func (v *Vaultik) verifyBlob(
|
||||
blobInfo snapshot.BlobInfo, db *sql.DB, identities []age.Identity,
|
||||
) error {
|
||||
func (v *Vaultik) verifyBlob(blobInfo snapshot.BlobInfo, db *sql.DB) error {
|
||||
// Download blob using shared fetch method
|
||||
reader, err := v.FetchBlob(v.ctx, blobInfo.Hash)
|
||||
reader, _, err := v.FetchBlob(v.ctx, blobInfo.Hash, blobInfo.CompressedSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
// Decrypt and decompress through the shared blobgen reader, which hashes
|
||||
// the plaintext as it is read. A blob's hash — its remote name — is the
|
||||
// double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the
|
||||
// encrypted bytes.
|
||||
blobReader, err := blobgen.NewReader(reader, identities...)
|
||||
// Get decryptor
|
||||
decryptor, err := v.GetDecryptor()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create blob reader: %w", err)
|
||||
return fmt.Errorf("failed to get decryptor: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = blobReader.Close() }()
|
||||
// Decrypt blob
|
||||
decryptedReader, err := decryptor.DecryptStream(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, blobReader)
|
||||
// Decompress blob
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decompress: %w", err)
|
||||
}
|
||||
defer decompressor.Close()
|
||||
|
||||
// A blob's hash — its remote name — is the double SHA256 of its
|
||||
// decompressed plaintext (see blobgen.Writer.Sum256), not of the
|
||||
// encrypted bytes. Hash the plaintext as chunk verification streams
|
||||
// it, then compare on completion.
|
||||
plaintextHasher := sha256.New()
|
||||
hashedStream := io.TeeReader(decompressor, plaintextHasher)
|
||||
|
||||
chunkCount, err := v.verifyBlobChunks(db, blobInfo.Hash, hashedStream)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.verifyBlobFinalIntegrity(blobReader, blobInfo.Hash)
|
||||
err = v.verifyBlobFinalIntegrity(hashedStream, plaintextHasher, blobInfo.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Blob verified",
|
||||
"hash", shortHash(blobInfo.Hash)+"...",
|
||||
"hash", blobInfo.Hash[:16]+"...",
|
||||
"chunks", chunkCount,
|
||||
"size", ubytes(blobInfo.CompressedSize),
|
||||
)
|
||||
@@ -488,24 +467,21 @@ func (v *Vaultik) verifyBlobChunks(
|
||||
totalRead = offset
|
||||
}
|
||||
|
||||
// length comes from an untrusted blob_chunks row: reject a
|
||||
// negative value, and hash by streaming exactly length bytes
|
||||
// rather than allocating a database-supplied size up front.
|
||||
if length < 0 {
|
||||
return 0, fmt.Errorf("%w: offset %d length %d",
|
||||
errNegativeChunkLength, offset, length)
|
||||
}
|
||||
// Read chunk data
|
||||
chunkData := make([]byte, length)
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
n, err := io.CopyN(hasher, decompressor, length)
|
||||
_, err = io.ReadFull(decompressor, chunkData)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read chunk at offset %d: %w", offset, err)
|
||||
}
|
||||
|
||||
totalRead += n
|
||||
totalRead += length
|
||||
|
||||
// Verify chunk hash
|
||||
hasher := sha256.New()
|
||||
hasher.Write(chunkData)
|
||||
calculatedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
if calculatedHash != chunkHash {
|
||||
return 0, fmt.Errorf("%w at offset %d: calculated %s, expected %s",
|
||||
errChunkHashMismatch, offset, calculatedHash, chunkHash)
|
||||
@@ -525,12 +501,11 @@ func (v *Vaultik) verifyBlobChunks(
|
||||
// verifyBlobFinalIntegrity checks that no trailing data exists in the
|
||||
// decompressed stream and that the blob hash matches the expected value.
|
||||
func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||
blobReader *blobgen.Reader, expectedHash string,
|
||||
plaintext io.Reader, plaintextHasher hash.Hash, expectedHash string,
|
||||
) error {
|
||||
// Verify no remaining data in blob - if the chunk list is accurate,
|
||||
// the blob should be fully consumed. Draining to EOF also completes the
|
||||
// reader's plaintext hash.
|
||||
remaining, err := io.Copy(io.Discard, blobReader)
|
||||
// the blob should be fully consumed.
|
||||
remaining, err := io.Copy(io.Discard, plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for remaining blob data: %w", err)
|
||||
}
|
||||
@@ -539,9 +514,10 @@ func (v *Vaultik) verifyBlobFinalIntegrity(
|
||||
return fmt.Errorf("%w: %d bytes", errTrailingBlobData, remaining)
|
||||
}
|
||||
|
||||
// The blob hash is the double SHA-256 of its plaintext content.
|
||||
calculatedBlobHash := hex.EncodeToString(
|
||||
blobgen.DoubleSHA256(blobReader.Sum256()))
|
||||
// The blob hash is the double SHA256 of its plaintext content.
|
||||
firstHash := plaintextHasher.Sum(nil)
|
||||
secondHash := sha256.Sum256(firstHash)
|
||||
calculatedBlobHash := hex.EncodeToString(secondHash[:])
|
||||
|
||||
if calculatedBlobHash != expectedHash {
|
||||
return fmt.Errorf("%w: calculated %s, expected %s",
|
||||
@@ -655,12 +631,6 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||
|
||||
for i, blob := range blobs {
|
||||
// The hash is read from the snapshot database, which is not
|
||||
// trusted; check it before it is spliced into a storage path.
|
||||
if !isBlobHash(blob.Hash) {
|
||||
return fmt.Errorf("%w: %s", errInvalidBlobHash, shortHash(blob.Hash))
|
||||
}
|
||||
|
||||
// Construct blob path
|
||||
blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash)
|
||||
|
||||
@@ -697,7 +667,6 @@ func (v *Vaultik) verifyBlobExistenceFromDB(blobs []snapshot.BlobInfo) error {
|
||||
// each blob using the database as source.
|
||||
func (v *Vaultik) performDeepVerificationFromDB(
|
||||
blobs []snapshot.BlobInfo, db *sql.DB, opts *VerifyOptions,
|
||||
identities []age.Identity,
|
||||
) error {
|
||||
// Calculate total bytes for ETA
|
||||
var totalBytesExpected int64
|
||||
@@ -715,7 +684,7 @@ func (v *Vaultik) performDeepVerificationFromDB(
|
||||
|
||||
for i, blobInfo := range blobs {
|
||||
// Verify individual blob
|
||||
err := v.verifyBlob(blobInfo, db, identities)
|
||||
err := v.verifyBlob(blobInfo, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("blob %s verification failed: %w", blobInfo.Hash, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/crypto"
|
||||
)
|
||||
|
||||
// TestTeeReaderWithDecryption tests that TeeReader correctly hashes all encrypted
|
||||
// bytes when streaming through age decryption and zstd decompression.
|
||||
// This validates the verification path: hash encrypted blob -> decrypt -> decompress.
|
||||
func TestTeeReaderWithDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test data - use random data that doesn't compress well (5MB)
|
||||
testData := make([]byte, 5*1024*1024)
|
||||
_, err := rand.Read(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compress the data
|
||||
var compressedBuf bytes.Buffer
|
||||
|
||||
compressor, err := zstd.NewWriter(&compressedBuf,
|
||||
zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||
require.NoError(t, err)
|
||||
_, err = compressor.Write(testData)
|
||||
require.NoError(t, err)
|
||||
err = compressor.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encrypt the compressed data
|
||||
testRecipient := "age1cplgrwj77ta54dnmydvvmzn64ltk83ankxl5sww04mrt" +
|
||||
"mu62kv3s89gmvv"
|
||||
testSecretKey := "AGE-SECRET-KEY-1C77PYNTHXSHNNC6EYR2W52UWYXACXA5J" +
|
||||
"T00J9CCW9986M3XY87PSGP89AQ"
|
||||
|
||||
encryptor, err := crypto.NewEncryptor([]string{testRecipient})
|
||||
require.NoError(t, err)
|
||||
|
||||
var encryptedBuf bytes.Buffer
|
||||
|
||||
err = encryptor.EncryptStream(&encryptedBuf, bytes.NewReader(compressedBuf.Bytes()))
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedData := encryptedBuf.Bytes()
|
||||
|
||||
// Calculate the expected hash of the encrypted data directly
|
||||
expectedHash := sha256.Sum256(encryptedData)
|
||||
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
||||
|
||||
t.Logf("Encrypted data size: %d bytes", len(encryptedData))
|
||||
t.Logf("Expected hash: %s", expectedHashStr)
|
||||
|
||||
// Now simulate what verifyBlob does: use TeeReader to hash while decrypting
|
||||
decryptor, err := crypto.NewDecryptor(testSecretKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create hasher and tee reader
|
||||
hasher := sha256.New()
|
||||
reader := bytes.NewReader(encryptedData)
|
||||
teeReader := io.TeeReader(reader, hasher)
|
||||
|
||||
// Decrypt through the tee reader
|
||||
decryptedReader, err := decryptor.DecryptStream(teeReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decompress
|
||||
decompressor, err := zstd.NewReader(decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer decompressor.Close()
|
||||
|
||||
// Read all decompressed data (simulating chunk verification)
|
||||
decompressedData, err := io.ReadAll(decompressor)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify we got the original data back
|
||||
assert.Equal(t, testData, decompressedData, "Decompressed data should match original")
|
||||
|
||||
// Drain remaining decompressed data (should be 0)
|
||||
remaining, err := io.Copy(io.Discard, decompressor)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), remaining, "No remaining decompressed data")
|
||||
|
||||
// Calculate hash from tee reader
|
||||
calculatedHashStr := hex.EncodeToString(hasher.Sum(nil))
|
||||
t.Logf("Calculated hash (before drain): %s", calculatedHashStr)
|
||||
|
||||
// Verify the hash matches the direct hash of encrypted data
|
||||
assert.Equal(t, expectedHashStr, calculatedHashStr,
|
||||
"Hash calculated via TeeReader should match direct hash of encrypted data")
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
age_recipients:
|
||||
- age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj # sneak's long term age key
|
||||
- age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg # add additional recipients as needed
|
||||
- age1otherpubkey... # add additional recipients as needed
|
||||
snapshots:
|
||||
test:
|
||||
paths:
|
||||
|
||||
Reference in New Issue
Block a user