1 Commits
Author SHA1 Message Date
sneak 46c295acf3 Open the downloaded snapshot database read-only, on a private temp dir (closes #162)
check / check (pull_request) Successful in 1m21s
Restore and deep verify used to open the decrypted snapshot database
read-write through the local-index constructor, which applied migrations
against whatever the file carried, and left the decrypted file in the
shared temp directory. A forged file could redefine what the restore
queries return, and an interrupted open left decrypted metadata on disk.

Add database.OpenReadOnly: opens the file read-only (mode=ro) with
query_only and trusted_schema=OFF, never applies schema files, and
refuses any file whose schema carries a trigger, view or virtual table
or lacks an expected table. Restore and deep verify now both use it.

Each command materializes the database inside its own private (0700)
temp directory and removes the whole directory on every return path, so
the decrypted file and any SQLite side files are always cleaned up.
Deep verify now also checks the error from closing the temp file.

pickNextDownload returns (FileID, bool), so a genuine file carrying the
nil UUID is no longer mistaken for "nothing left"; runRestoreLoop fails
with an error if any file is still pending when it can make no progress.

Model: opus-4-8
2026-09-22 10:29:51 +00:00
60 changed files with 1317 additions and 3671 deletions
+2 -1
View File
@@ -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
+9 -18
View File
@@ -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
-23
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
}
+89
View File
@@ -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
}
+80
View File
@@ -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)
}
-119
View File
@@ -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
}
-49
View File
@@ -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
}
-43
View File
@@ -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")
}
-190
View File
@@ -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")
}
+4 -25
View File
@@ -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)
}
-54
View File
@@ -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)
}
}
-132
View File
@@ -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)
})
}
}
-70
View File
@@ -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
View File
@@ -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
+9 -27
View File
@@ -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
View File
@@ -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
},
-97
View File
@@ -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")
}
+2 -5
View 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).",
+2 -6
View File
@@ -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
-37
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
})
}
+224
View File
@@ -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")
+178
View File
@@ -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")
}
}
-44
View File
@@ -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)
-100
View File
@@ -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)
}
}
-54
View File
@@ -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,
-39
View File
@@ -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`)
}
+4 -29
View File
@@ -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
-49
View File
@@ -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)
}
}
+5 -30
View File
@@ -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)
}
-79
View File
@@ -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
View File
@@ -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
View File
@@ -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) }
-136
View File
@@ -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
View File
@@ -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) {
-32
View File
@@ -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)
}
}
+41 -26
View File
@@ -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
}
-50
View File
@@ -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")
}
+5 -34
View File
@@ -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)
}
+16 -74
View File
@@ -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))
}
-52
View File
@@ -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{}
}
+2 -15
View File
@@ -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
View File
@@ -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(),
-108
View File
@@ -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)
}
-159
View File
@@ -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))
}
+2 -37
View File
@@ -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)
}
-191
View File
@@ -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
View File
@@ -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)
}
}
}
}
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+100
View File
@@ -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
View File
@@ -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: