Step 17: Encryption core — passphrase-only, selective per-file.
Manifest schema: Entry gains Encrypted, PlaintextHash fields. Manifest gains Encryption section with KekSlots map (passphrase slot with Argon2id params, salt, and wrapped DEK as base64). garden/encrypt.go: EncryptInit (generate DEK, wrap with passphrase KEK), UnlockDEK (derive KEK, unwrap), encryptBlob/decryptBlob using XChaCha20-Poly1305 with random 24-byte nonces. Modified operations: - Add: optional encrypt flag, stores encrypted blob + plaintext_hash - Checkpoint: detects changes via plaintext_hash, re-encrypts - Restore: decrypts encrypted blobs before writing - Diff: decrypts stored blob before comparing - Status: compares against plaintext_hash for encrypted entries 10 tests covering init, persistence, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK guard. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**Phase:** Phase 2 complete. Phase 3 (Encryption) planned, ready for Step 17.
|
**Phase:** Phase 3 in progress. Step 17 complete, ready for Step 18.
|
||||||
|
|
||||||
**Last updated:** 2026-03-24
|
**Last updated:** 2026-03-24
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Phase 3: Encryption. Step 17 (passphrase-only core) is next.
|
Step 18: FIDO2 support.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## Known Issues / Decisions Deferred
|
||||||
|
|
||||||
@@ -78,3 +78,4 @@ Phase 3: Encryption. Step 17 (passphrase-only core) is next.
|
|||||||
| 2026-03-24 | 16 | Polish: updated all docs, flake.nix (sgardd + vendorHash), goreleaser (both binaries), e2e push/pull test with auth. |
|
| 2026-03-24 | 16 | Polish: updated all docs, flake.nix (sgardd + vendorHash), goreleaser (both binaries), e2e push/pull test with auth. |
|
||||||
| 2026-03-24 | — | JWT token auth implemented (transparent auto-renewal, XDG token cache, ReauthChallenge fast path). |
|
| 2026-03-24 | — | JWT token auth implemented (transparent auto-renewal, XDG token cache, ReauthChallenge fast path). |
|
||||||
| 2026-03-24 | — | Phase 3 encryption design: selective per-file encryption, KEK slots (passphrase + fido2/label), manifest-embedded config. |
|
| 2026-03-24 | — | Phase 3 encryption design: selective per-file encryption, KEK slots (passphrase + fido2/label), manifest-embedded config. |
|
||||||
|
| 2026-03-24 | 17 | Encryption core: Argon2id KEK, XChaCha20 DEK wrap/unwrap, selective per-file encrypt in Add/Checkpoint/Restore/Diff/Status. 10 tests. |
|
||||||
|
|||||||
@@ -181,17 +181,17 @@ Depends on Steps 13, 14.
|
|||||||
|
|
||||||
### Step 17: Encryption Core (Passphrase Only)
|
### Step 17: Encryption Core (Passphrase Only)
|
||||||
|
|
||||||
- [ ] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest
|
- [x] `manifest/manifest.go`: add `Encrypted`, `PlaintextHash` fields to Entry; add `Encryption` section with `KekSlots` map to Manifest
|
||||||
- [ ] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
|
- [x] `garden/encrypt.go`: `EncryptInit(passphrase string) error` — generate DEK, derive KEK via Argon2id, wrap DEK, store in manifest encryption section
|
||||||
- [ ] `garden/encrypt.go`: `UnlockDEK() ([]byte, error)` — read slots, try passphrase, unwrap DEK; cache in memory for command duration
|
- [x] `garden/encrypt.go`: `UnlockDEK(prompt) error` — read slots, try passphrase, unwrap DEK; cache in memory for command duration
|
||||||
- [ ] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
|
- [x] `garden/encrypt.go`: encrypt/decrypt helpers using XChaCha20-Poly1305 (nonce + seal/open)
|
||||||
- [ ] `garden/garden.go`: modify Add to accept `--encrypt` flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry
|
- [x] `garden/garden.go`: modify Add to accept encrypt flag — encrypt blob before storing, set `encrypted: true` and `plaintext_hash` on entry
|
||||||
- [ ] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries
|
- [x] `garden/garden.go`: modify Checkpoint to re-encrypt changed encrypted entries (compares plaintext_hash)
|
||||||
- [ ] `garden/restore.go`: modify Restore to decrypt encrypted blobs before writing
|
- [x] `garden/garden.go`: modify Restore to decrypt encrypted blobs before writing
|
||||||
- [ ] `garden/diff.go`: modify Diff to decrypt stored blob before diffing
|
- [x] `garden/diff.go`: modify Diff to decrypt stored blob before diffing
|
||||||
- [ ] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries
|
- [x] `garden/garden.go`: modify Status to use `plaintext_hash` for encrypted entries
|
||||||
- [ ] Tests: round-trip add-encrypted → checkpoint → restore, verify decrypted content matches; status on encrypted entry; diff on encrypted entry
|
- [x] Tests: 10 encryption tests (init, persist, unlock, add-encrypted, restore round-trip, checkpoint, status, diff, requires-DEK)
|
||||||
- [ ] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
|
- [x] Verify: `go test ./... && go vet ./... && golangci-lint run ./...`
|
||||||
|
|
||||||
### Step 18: FIDO2 Support
|
### Step 18: FIDO2 Support
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ func (g *Garden) Diff(path string) (string, error) {
|
|||||||
return "", fmt.Errorf("reading stored blob: %w", err)
|
return "", fmt.Errorf("reading stored blob: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.Encrypted {
|
||||||
|
if g.dek == nil {
|
||||||
|
return "", fmt.Errorf("DEK not unlocked; cannot diff encrypted file %s", tilded)
|
||||||
|
}
|
||||||
|
stored, err = g.decryptBlob(stored)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypting stored blob: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
current, err := os.ReadFile(abs)
|
current, err := os.ReadFile(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("reading current file: %w", err)
|
return "", fmt.Errorf("reading current file: %w", err)
|
||||||
|
|||||||
229
garden/encrypt.go
Normal file
229
garden/encrypt.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package garden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/manifest"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"golang.org/x/crypto/chacha20poly1305"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dekSize = 32 // 256-bit DEK
|
||||||
|
saltSize = 16
|
||||||
|
algorithmName = "xchacha20-poly1305"
|
||||||
|
|
||||||
|
defaultArgon2Time = 3
|
||||||
|
defaultArgon2Memory = 64 * 1024 // 64 MiB in KiB
|
||||||
|
defaultArgon2Threads = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncryptInit sets up encryption on the repo by generating a DEK and
|
||||||
|
// wrapping it with a passphrase-derived KEK. The encryption config is
|
||||||
|
// stored in the manifest.
|
||||||
|
func (g *Garden) EncryptInit(passphrase string) error {
|
||||||
|
if g.manifest.Encryption != nil {
|
||||||
|
return fmt.Errorf("encryption already initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate DEK.
|
||||||
|
dek := make([]byte, dekSize)
|
||||||
|
if _, err := rand.Read(dek); err != nil {
|
||||||
|
return fmt.Errorf("generating DEK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate salt for passphrase KEK.
|
||||||
|
salt := make([]byte, saltSize)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return fmt.Errorf("generating salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive KEK from passphrase.
|
||||||
|
kek := derivePassphraseKEK(passphrase, salt, defaultArgon2Time, defaultArgon2Memory, defaultArgon2Threads)
|
||||||
|
|
||||||
|
// Wrap DEK.
|
||||||
|
wrappedDEK, err := wrapDEK(dek, kek)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wrapping DEK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.manifest.Encryption = &manifest.Encryption{
|
||||||
|
Algorithm: algorithmName,
|
||||||
|
KekSlots: map[string]*manifest.KekSlot{
|
||||||
|
"passphrase": {
|
||||||
|
Type: "passphrase",
|
||||||
|
Argon2Time: defaultArgon2Time,
|
||||||
|
Argon2Memory: defaultArgon2Memory,
|
||||||
|
Argon2Threads: defaultArgon2Threads,
|
||||||
|
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||||
|
WrappedDEK: base64.StdEncoding.EncodeToString(wrappedDEK),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
g.dek = dek
|
||||||
|
|
||||||
|
if err := g.manifest.Save(g.manifestPath); err != nil {
|
||||||
|
return fmt.Errorf("saving manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockDEK attempts to unwrap the DEK using available KEK slots.
|
||||||
|
// Tries passphrase slots (prompting via the provided function).
|
||||||
|
// The DEK is cached on the Garden for the duration of the command.
|
||||||
|
func (g *Garden) UnlockDEK(promptPassphrase func() (string, error)) error {
|
||||||
|
if g.dek != nil {
|
||||||
|
return nil // already unlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := g.manifest.Encryption
|
||||||
|
if enc == nil {
|
||||||
|
return fmt.Errorf("encryption not initialized; run sgard encrypt init")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try passphrase slot.
|
||||||
|
if slot, ok := enc.KekSlots["passphrase"]; ok {
|
||||||
|
if promptPassphrase == nil {
|
||||||
|
return fmt.Errorf("passphrase required but no prompt available")
|
||||||
|
}
|
||||||
|
|
||||||
|
passphrase, err := promptPassphrase()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading passphrase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decoding salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kek := derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
|
||||||
|
|
||||||
|
wrappedDEK, err := base64.StdEncoding.DecodeString(slot.WrappedDEK)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decoding wrapped DEK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dek, err := unwrapDEK(wrappedDEK, kek)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wrong passphrase or corrupted DEK: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.dek = dek
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("no usable KEK slot found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEncryption reports whether the repo has encryption configured.
|
||||||
|
func (g *Garden) HasEncryption() bool {
|
||||||
|
return g.manifest.Encryption != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsDEK reports whether any of the given entries are encrypted.
|
||||||
|
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Encrypted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptBlob encrypts plaintext with the DEK and returns the ciphertext.
|
||||||
|
func (g *Garden) encryptBlob(plaintext []byte) ([]byte, error) {
|
||||||
|
if g.dek == nil {
|
||||||
|
return nil, fmt.Errorf("DEK not unlocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
aead, err := chacha20poly1305.NewX(g.dek)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("generating nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptBlob decrypts ciphertext with the DEK and returns the plaintext.
|
||||||
|
func (g *Garden) decryptBlob(ciphertext []byte) ([]byte, error) {
|
||||||
|
if g.dek == nil {
|
||||||
|
return nil, fmt.Errorf("DEK not unlocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
aead, err := chacha20poly1305.NewX(g.dek)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := aead.NonceSize()
|
||||||
|
if len(ciphertext) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := ciphertext[:nonceSize]
|
||||||
|
ct := ciphertext[nonceSize:]
|
||||||
|
|
||||||
|
plaintext, err := aead.Open(nil, nonce, ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decryption failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// plaintextHash computes the SHA-256 hash of plaintext data.
|
||||||
|
func plaintextHash(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// derivePassphraseKEK derives a KEK from a passphrase using Argon2id.
|
||||||
|
func derivePassphraseKEK(passphrase string, salt []byte, time, memory, threads int) []byte {
|
||||||
|
return argon2.IDKey([]byte(passphrase), salt, uint32(time), uint32(memory), uint8(threads), dekSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapDEK encrypts the DEK with the KEK using XChaCha20-Poly1305.
|
||||||
|
func wrapDEK(dek, kek []byte) ([]byte, error) {
|
||||||
|
aead, err := chacha20poly1305.NewX(kek)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return aead.Seal(nonce, nonce, dek, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrapDEK decrypts the DEK with the KEK.
|
||||||
|
func unwrapDEK(wrapped, kek []byte) ([]byte, error) {
|
||||||
|
aead, err := chacha20poly1305.NewX(kek)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := aead.NonceSize()
|
||||||
|
if len(wrapped) < nonceSize {
|
||||||
|
return nil, fmt.Errorf("wrapped DEK too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := wrapped[:nonceSize]
|
||||||
|
ct := wrapped[nonceSize:]
|
||||||
|
|
||||||
|
return aead.Open(nil, nonce, ct, nil)
|
||||||
|
}
|
||||||
379
garden/encrypt_test.go
Normal file
379
garden/encrypt_test.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
package garden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryptInit(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("test-passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.manifest.Encryption == nil {
|
||||||
|
t.Fatal("encryption section should be present")
|
||||||
|
}
|
||||||
|
if g.manifest.Encryption.Algorithm != "xchacha20-poly1305" {
|
||||||
|
t.Errorf("algorithm = %s, want xchacha20-poly1305", g.manifest.Encryption.Algorithm)
|
||||||
|
}
|
||||||
|
slot, ok := g.manifest.Encryption.KekSlots["passphrase"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("passphrase slot should exist")
|
||||||
|
}
|
||||||
|
if slot.Type != "passphrase" {
|
||||||
|
t.Errorf("slot type = %s, want passphrase", slot.Type)
|
||||||
|
}
|
||||||
|
if slot.Salt == "" || slot.WrappedDEK == "" {
|
||||||
|
t.Error("slot should have salt and wrapped DEK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEK should be cached.
|
||||||
|
if g.dek == nil {
|
||||||
|
t.Error("DEK should be cached after EncryptInit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double init should fail.
|
||||||
|
if err := g.EncryptInit("other"); err == nil {
|
||||||
|
t.Fatal("double EncryptInit should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptInitPersists(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("test-passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-open and verify encryption section persisted.
|
||||||
|
g2, err := Open(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
if g2.manifest.Encryption == nil {
|
||||||
|
t.Fatal("encryption section should persist after re-open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlockDEK(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("correct-passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-open (DEK not cached).
|
||||||
|
g2, err := Open(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock with correct passphrase.
|
||||||
|
err = g2.UnlockDEK(func() (string, error) { return "correct-passphrase", nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnlockDEK with correct passphrase: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-open and try wrong passphrase.
|
||||||
|
g3, err := Open(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = g3.UnlockDEK(func() (string, error) { return "wrong-passphrase", nil })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("UnlockDEK with wrong passphrase should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEncrypted(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an encrypted file.
|
||||||
|
secretFile := filepath.Join(root, "secret")
|
||||||
|
if err := os.WriteFile(secretFile, []byte("secret data\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("writing secret file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{secretFile}, true); err != nil {
|
||||||
|
t.Fatalf("Add encrypted: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a plaintext file.
|
||||||
|
plainFile := filepath.Join(root, "plain")
|
||||||
|
if err := os.WriteFile(plainFile, []byte("plain data\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing plain file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{plainFile}); err != nil {
|
||||||
|
t.Fatalf("Add plaintext: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.manifest.Files) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(g.manifest.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check encrypted entry.
|
||||||
|
var secretEntry, plainEntry *manifest.Entry
|
||||||
|
for i := range g.manifest.Files {
|
||||||
|
if g.manifest.Files[i].Encrypted {
|
||||||
|
secretEntry = &g.manifest.Files[i]
|
||||||
|
} else {
|
||||||
|
plainEntry = &g.manifest.Files[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if secretEntry == nil {
|
||||||
|
t.Fatal("expected an encrypted entry")
|
||||||
|
}
|
||||||
|
if secretEntry.PlaintextHash == "" {
|
||||||
|
t.Error("encrypted entry should have plaintext_hash")
|
||||||
|
}
|
||||||
|
if secretEntry.Hash == "" {
|
||||||
|
t.Error("encrypted entry should have hash (of ciphertext)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if plainEntry == nil {
|
||||||
|
t.Fatal("expected a plaintext entry")
|
||||||
|
}
|
||||||
|
if plainEntry.PlaintextHash != "" {
|
||||||
|
t.Error("plaintext entry should not have plaintext_hash")
|
||||||
|
}
|
||||||
|
if plainEntry.Encrypted {
|
||||||
|
t.Error("plaintext entry should not be encrypted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored blob for the encrypted file should NOT be the plaintext.
|
||||||
|
storedData, err := g.ReadBlob(secretEntry.Hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadBlob: %v", err)
|
||||||
|
}
|
||||||
|
if string(storedData) == "secret data\n" {
|
||||||
|
t.Error("stored blob should be encrypted, not plaintext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptedRestoreRoundTrip(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := []byte("sensitive config data\n")
|
||||||
|
secretFile := filepath.Join(root, "secret")
|
||||||
|
if err := os.WriteFile(secretFile, content, 0o600); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{secretFile}, true); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete and restore.
|
||||||
|
_ = os.Remove(secretFile)
|
||||||
|
|
||||||
|
if err := g.Restore(nil, true, nil); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := os.ReadFile(secretFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading restored file: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(content) {
|
||||||
|
t.Errorf("restored content = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptedCheckpoint(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretFile := filepath.Join(root, "secret")
|
||||||
|
if err := os.WriteFile(secretFile, []byte("original"), 0o600); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{secretFile}, true); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origHash := g.manifest.Files[0].Hash
|
||||||
|
origPtHash := g.manifest.Files[0].PlaintextHash
|
||||||
|
|
||||||
|
// Modify file.
|
||||||
|
if err := os.WriteFile(secretFile, []byte("modified"), 0o600); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Checkpoint(""); err != nil {
|
||||||
|
t.Fatalf("Checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.manifest.Files[0].Hash == origHash {
|
||||||
|
t.Error("encrypted hash should change after modification")
|
||||||
|
}
|
||||||
|
if g.manifest.Files[0].PlaintextHash == origPtHash {
|
||||||
|
t.Error("plaintext hash should change after modification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptedStatus(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretFile := filepath.Join(root, "secret")
|
||||||
|
if err := os.WriteFile(secretFile, []byte("data"), 0o600); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{secretFile}, true); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged — should be ok.
|
||||||
|
statuses, err := g.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Status: %v", err)
|
||||||
|
}
|
||||||
|
if len(statuses) != 1 || statuses[0].State != "ok" {
|
||||||
|
t.Errorf("expected ok, got %v", statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify — should be modified.
|
||||||
|
if err := os.WriteFile(secretFile, []byte("changed"), 0o600); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err = g.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Status: %v", err)
|
||||||
|
}
|
||||||
|
if len(statuses) != 1 || statuses[0].State != "modified" {
|
||||||
|
t.Errorf("expected modified, got %v", statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptedDiff(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.EncryptInit("passphrase"); err != nil {
|
||||||
|
t.Fatalf("EncryptInit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretFile := filepath.Join(root, "secret")
|
||||||
|
if err := os.WriteFile(secretFile, []byte("original\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{secretFile}, true); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged — empty diff.
|
||||||
|
d, err := g.Diff(secretFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Diff: %v", err)
|
||||||
|
}
|
||||||
|
if d != "" {
|
||||||
|
t.Errorf("expected empty diff for unchanged encrypted file, got:\n%s", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify.
|
||||||
|
if err := os.WriteFile(secretFile, []byte("modified\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err = g.Diff(secretFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Diff: %v", err)
|
||||||
|
}
|
||||||
|
if d == "" {
|
||||||
|
t.Fatal("expected non-empty diff for modified encrypted file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEncryptedRequiresDEK(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No encryption initialized.
|
||||||
|
testFile := filepath.Join(root, "file")
|
||||||
|
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = g.Add([]string{testFile}, true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Add --encrypt without DEK should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type Garden struct {
|
|||||||
root string // repository root directory
|
root string // repository root directory
|
||||||
manifestPath string // path to manifest.yaml
|
manifestPath string // path to manifest.yaml
|
||||||
clock clockwork.Clock
|
clock clockwork.Clock
|
||||||
|
dek []byte // unlocked data encryption key (nil if not unlocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init creates a new sgard repository at root. It creates the directory
|
// Init creates a new sgard repository at root. It creates the directory
|
||||||
@@ -140,7 +141,8 @@ func (g *Garden) DeleteBlob(hash string) error {
|
|||||||
// addEntry adds a single file or symlink to the manifest. The abs path must
|
// addEntry adds a single file or symlink to the manifest. The abs path must
|
||||||
// already be resolved and info must come from os.Lstat. If skipDup is true,
|
// already be resolved and info must come from os.Lstat. If skipDup is true,
|
||||||
// already-tracked paths are silently skipped instead of returning an error.
|
// already-tracked paths are silently skipped instead of returning an error.
|
||||||
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool) error {
|
// If encrypt is true, the file blob is encrypted before storing.
|
||||||
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt bool) error {
|
||||||
tilded := toTildePath(abs)
|
tilded := toTildePath(abs)
|
||||||
|
|
||||||
if g.findEntry(tilded) != nil {
|
if g.findEntry(tilded) != nil {
|
||||||
@@ -170,6 +172,20 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup b
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading file %s: %w", abs, err)
|
return fmt.Errorf("reading file %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if encrypt {
|
||||||
|
if g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
|
||||||
|
}
|
||||||
|
entry.PlaintextHash = plaintextHash(data)
|
||||||
|
ct, err := g.encryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
data = ct
|
||||||
|
entry.Encrypted = true
|
||||||
|
}
|
||||||
|
|
||||||
hash, err := g.store.Write(data)
|
hash, err := g.store.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
@@ -186,7 +202,14 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup b
|
|||||||
// to an absolute path, inspected for its type, and added to the manifest.
|
// to an absolute path, inspected for its type, and added to the manifest.
|
||||||
// Regular files are hashed and stored in the blob store. Directories are
|
// Regular files are hashed and stored in the blob store. Directories are
|
||||||
// recursively walked and all leaf files and symlinks are added individually.
|
// recursively walked and all leaf files and symlinks are added individually.
|
||||||
func (g *Garden) Add(paths []string) error {
|
// If encrypt is true, file blobs are encrypted before storing (requires
|
||||||
|
// the DEK to be unlocked).
|
||||||
|
func (g *Garden) Add(paths []string, encrypt ...bool) error {
|
||||||
|
enc := len(encrypt) > 0 && encrypt[0]
|
||||||
|
if enc && g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
|
||||||
|
}
|
||||||
|
|
||||||
now := g.clock.Now().UTC()
|
now := g.clock.Now().UTC()
|
||||||
|
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
@@ -201,25 +224,24 @@ func (g *Garden) Add(paths []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
// Recursively walk the directory, adding all files and symlinks.
|
|
||||||
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
return nil // skip directory entries themselves
|
return nil
|
||||||
}
|
}
|
||||||
fi, err := os.Lstat(path)
|
fi, err := os.Lstat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stat %s: %w", path, err)
|
return fmt.Errorf("stat %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return g.addEntry(path, fi, now, true)
|
return g.addEntry(path, fi, now, true, enc)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := g.addEntry(abs, info, now, false); err != nil {
|
if err := g.addEntry(abs, info, now, false, enc); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,13 +290,35 @@ func (g *Garden) Checkpoint(message string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading %s: %w", abs, err)
|
return fmt.Errorf("reading %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
hash, err := g.store.Write(data)
|
|
||||||
if err != nil {
|
if entry.Encrypted {
|
||||||
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
// For encrypted entries, check plaintext hash to detect changes.
|
||||||
}
|
ptHash := plaintextHash(data)
|
||||||
if hash != entry.Hash {
|
if ptHash != entry.PlaintextHash {
|
||||||
entry.Hash = hash
|
if g.dek == nil {
|
||||||
entry.Updated = now
|
return fmt.Errorf("DEK not unlocked; cannot re-encrypt %s", abs)
|
||||||
|
}
|
||||||
|
ct, err := g.encryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
hash, err := g.store.Write(ct)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Hash = hash
|
||||||
|
entry.PlaintextHash = ptHash
|
||||||
|
entry.Updated = now
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hash, err := g.store.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
if hash != entry.Hash {
|
||||||
|
entry.Hash = hash
|
||||||
|
entry.Updated = now
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "link":
|
case "link":
|
||||||
@@ -329,7 +373,12 @@ func (g *Garden) Status() ([]FileStatus, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("hashing %s: %w", abs, err)
|
return nil, fmt.Errorf("hashing %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
if hash != entry.Hash {
|
// For encrypted entries, compare against plaintext hash.
|
||||||
|
compareHash := entry.Hash
|
||||||
|
if entry.Encrypted && entry.PlaintextHash != "" {
|
||||||
|
compareHash = entry.PlaintextHash
|
||||||
|
}
|
||||||
|
if hash != compareHash {
|
||||||
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
||||||
} else {
|
} else {
|
||||||
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
||||||
@@ -429,6 +478,16 @@ func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error {
|
|||||||
return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
|
return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if entry.Encrypted {
|
||||||
|
if g.dek == nil {
|
||||||
|
return fmt.Errorf("DEK not unlocked; cannot decrypt %s", entry.Path)
|
||||||
|
}
|
||||||
|
data, err = g.decryptBlob(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mode, err := parseMode(entry.Mode)
|
mode, err := parseMode(entry.Mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (g *Garden) MirrorUp(paths []string) error {
|
|||||||
if lstatErr != nil {
|
if lstatErr != nil {
|
||||||
return fmt.Errorf("stat %s: %w", path, lstatErr)
|
return fmt.Errorf("stat %s: %w", path, lstatErr)
|
||||||
}
|
}
|
||||||
return g.addEntry(path, fi, now, true)
|
return g.addEntry(path, fi, now, true, false)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
|||||||
@@ -11,21 +11,41 @@ import (
|
|||||||
|
|
||||||
// Entry represents a single tracked file, directory, or symlink.
|
// Entry represents a single tracked file, directory, or symlink.
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Hash string `yaml:"hash,omitempty"`
|
Hash string `yaml:"hash,omitempty"`
|
||||||
Type string `yaml:"type"`
|
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
||||||
Mode string `yaml:"mode,omitempty"`
|
Encrypted bool `yaml:"encrypted,omitempty"`
|
||||||
Target string `yaml:"target,omitempty"`
|
Type string `yaml:"type"`
|
||||||
Updated time.Time `yaml:"updated"`
|
Mode string `yaml:"mode,omitempty"`
|
||||||
|
Target string `yaml:"target,omitempty"`
|
||||||
|
Updated time.Time `yaml:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KekSlot describes a single KEK source that can unwrap the DEK.
|
||||||
|
type KekSlot struct {
|
||||||
|
Type string `yaml:"type"` // "passphrase" or "fido2"
|
||||||
|
Argon2Time int `yaml:"argon2_time,omitempty"` // passphrase only
|
||||||
|
Argon2Memory int `yaml:"argon2_memory,omitempty"` // passphrase only (KiB)
|
||||||
|
Argon2Threads int `yaml:"argon2_threads,omitempty"` // passphrase only
|
||||||
|
CredentialID string `yaml:"credential_id,omitempty"` // fido2 only (base64)
|
||||||
|
Salt string `yaml:"salt"` // base64-encoded
|
||||||
|
WrappedDEK string `yaml:"wrapped_dek"` // base64-encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encryption holds the encryption configuration embedded in the manifest.
|
||||||
|
type Encryption struct {
|
||||||
|
Algorithm string `yaml:"algorithm"`
|
||||||
|
KekSlots map[string]*KekSlot `yaml:"kek_slots"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is the top-level manifest describing all tracked entries.
|
// Manifest is the top-level manifest describing all tracked entries.
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
Version int `yaml:"version"`
|
Version int `yaml:"version"`
|
||||||
Created time.Time `yaml:"created"`
|
Created time.Time `yaml:"created"`
|
||||||
Updated time.Time `yaml:"updated"`
|
Updated time.Time `yaml:"updated"`
|
||||||
Message string `yaml:"message,omitempty"`
|
Message string `yaml:"message,omitempty"`
|
||||||
Files []Entry `yaml:"files"`
|
Files []Entry `yaml:"files"`
|
||||||
|
Encryption *Encryption `yaml:"encryption,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new empty manifest with Version 1 and timestamps set to now.
|
// New creates a new empty manifest with Version 1 and timestamps set to now.
|
||||||
|
|||||||
Reference in New Issue
Block a user