9 Commits

Author SHA1 Message Date
c8281398d1 Step 27: Phase 4 polish.
E2e integration test covering TLS + encryption + locked files in a
push/pull cycle (integration/phase4_test.go). Final doc updates.
Phase 4 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:18:42 -07:00
3cac9a3530 Step 26: Test cleanup.
Tightened lint config (added copyloopvar, durationcheck, makezero,
nilerr, bodyclose). Added 3 combo tests: encrypted+locked files,
dir-only+locked entries, lock/unlock toggle on encrypted entries.
Fixed stale API signatures in ARCHITECTURE.md. All tests already
used t.TempDir() and AddOptions{} consistently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:45:29 -07:00
490db0599c Step 25: Real FIDO2 hardware key support.
HardwareFIDO2 implements FIDO2Device via go-libfido2 (CGo bindings to
Yubico's libfido2). Gated behind //go:build fido2 tag to keep default
builds CGo-free. Nix flake adds sgard-fido2 package variant.

CLI changes: --fido2-pin flag, unlockDEK helper tries FIDO2 first,
add-fido2/encrypt init --fido2 use real hardware, auto-unlock added
to restore/checkpoint/diff for encrypted entries.

Tested manually: add-fido2, add --encrypt, restore, checkpoint, diff
all work with hardware FIDO2 key (touch-to-unlock, no passphrase).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:40:46 -07:00
5529fff649 Step 24: DEK rotation.
RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
re-wraps with all existing KEK slots (passphrase + FIDO2). CLI wired
as `sgard encrypt rotate-dek`. 4 tests covering rotation, persistence,
FIDO2 re-wrap, and requires-unlock guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:01:57 -07:00
3fabd86150 Step 23: TLS transport for sgardd and sgard client.
Server: --tls-cert/--tls-key flags enable TLS (min TLS 1.2).
Client: --tls enables TLS transport, --tls-ca for custom CA certs.
Two integration tests: push/pull over TLS, reject untrusted client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:57:03 -07:00
c00d9c65c3 Step 22: Shell completion docs for bash, zsh, fish.
Cobra provides built-in sgard completion subcommand — no additional
code needed. README updated with installation instructions for each
shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:10:28 -07:00
d2bba75365 Step 21: Lock/unlock toggle commands.
garden/lock.go: Lock() and Unlock() toggle the locked flag on
existing tracked entries. Errors on untracked paths. Persists
to manifest.

cmd/sgard/lock.go: sgard lock <path>..., sgard unlock <path>...

6 tests: lock/unlock existing entry, persistence, error on untracked,
checkpoint behavior changes after lock, status changes between
drifted and modified after unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:07:40 -07:00
0cf81ab6a1 Add Phase 4-6 roadmap to ARCHITECTURE.md.
Phase 4: TLS transport, DEK rotation.
Phase 5: Multi-repo + per-machine inclusion.
Phase 6: Manifest signing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:00:13 -07:00
1eb801fe63 Plan Phase 4: lock/unlock, shell completion, TLS, DEK rotation, FIDO2 hardware, test cleanup.
Steps 21-27. Phase 5 (multi-repo + per-machine) and Phase 6
(manifest signing) noted as future.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:57:05 -07:00
27 changed files with 1940 additions and 38 deletions

View File

@@ -8,6 +8,11 @@ linters:
- unused - unused
- errorlint - errorlint
- staticcheck - staticcheck
- copyloopvar
- durationcheck
- makezero
- nilerr
- bodyclose
linters-settings: linters-settings:
errcheck: errcheck:

View File

@@ -563,7 +563,63 @@ new machine, the user runs `sgard encrypt add-fido2` which:
On next push, the new slot propagates to the server and other machines. On next push, the new slot propagates to the server and other machines.
Each machine accumulates its own FIDO2 slot over time. Each machine accumulates its own FIDO2 slot over time.
### Future: Manifest Signing ### TLS Transport
sgardd supports optional TLS via `--tls-cert` and `--tls-key` flags.
When provided, the server uses `credentials.NewTLS()` with a minimum
of TLS 1.2. Without them, it runs insecure (for local/trusted networks).
The client gains `--tls` and `--tls-ca` flags:
- `--tls` — enables TLS transport (uses system CA pool by default)
- `--tls-ca <path>` — custom CA certificate for self-signed server certs
Both flags must be specified together on the server side; on the client
side `--tls` alone uses the system trust store, and `--tls-ca` adds a
custom root.
### FIDO2 Hardware Support
Real FIDO2 hardware support uses `go-libfido2` (CGo bindings to
Yubico's libfido2 C library). It is gated behind the `fido2` build
tag to avoid requiring CGo and libfido2 for users who don't need it:
- `go build ./...` — default build, no FIDO2 hardware support
- `go build -tags fido2 ./...` — links against libfido2 for real keys
The implementation (`garden/fido2_hardware.go`) wraps
`libfido2.Device.MakeCredential` and `Assertion` with the
`HMACSecretExtension` to derive 32-byte HMAC secrets from hardware
keys. A `--fido2-pin` flag is available for PIN-protected devices.
The Nix flake provides two packages: `sgard` (default, no CGo) and
`sgard-fido2` (links libfido2).
### DEK Rotation
`sgard encrypt rotate-dek` generates a new DEK, re-encrypts all
encrypted blobs with the new key, and re-wraps the new DEK with all
existing KEK slots. Required when the DEK is suspected compromised
(re-wrapping alone is insufficient since the old DEK could decrypt
the existing blobs).
The rotation process:
1. Generate a new random 256-bit DEK
2. For each encrypted entry: decrypt with old DEK, re-encrypt with new DEK,
write new blob to store, update manifest hash (plaintext hash unchanged)
3. Re-derive each KEK (passphrase via Argon2id, FIDO2 via device) and
re-wrap the new DEK. FIDO2 slots without a matching connected device
are dropped during rotation.
4. Save updated manifest
Plaintext entries are untouched.
### Planned: Multi-Repo + Per-Machine Inclusion (Phase 5)
Support for multiple repos on a single server, and per-machine
inclusion rules (e.g., "this file only applies to Linux machines" or
"this directory is only for the workstation"). Design TBD.
### Future: Manifest Signing (Phase 6)
Manifest signing (to detect tampering) is deferred. The challenge is Manifest signing (to detect tampering) is deferred. The challenge is
the trust model: which key signs, and how does a pulling client verify the trust model: which key signs, and how does a pulling client verify
@@ -575,19 +631,21 @@ the same server? This requires a proper trust/key-authority design.
``` ```
sgard/ sgard/
cmd/sgard/ # CLI entry point — one file per command cmd/sgard/ # CLI entry point — one file per command
main.go # cobra root command, --repo/--remote/--ssh-key flags main.go # cobra root command, --repo/--remote/--ssh-key/--tls/--tls-ca flags
encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase encrypt.go # sgard encrypt init/add-fido2/remove-slot/list-slots/change-passphrase
push.go pull.go prune.go mirror.go push.go pull.go prune.go mirror.go
init.go add.go remove.go checkpoint.go init.go add.go remove.go checkpoint.go
restore.go status.go verify.go list.go diff.go version.go restore.go status.go verify.go list.go diff.go version.go
cmd/sgardd/ # gRPC server daemon cmd/sgardd/ # gRPC server daemon
main.go # --listen, --repo, --authorized-keys flags main.go # --listen, --repo, --authorized-keys, --tls-cert, --tls-key flags
garden/ # Core business logic — one file per operation garden/ # Core business logic — one file per operation
garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors garden.go # Garden struct, Init, Open, Add, Checkpoint, Status, accessors
encrypt.go # EncryptInit, UnlockDEK, encrypt/decrypt blobs, slot management encrypt.go # EncryptInit, UnlockDEK, RotateDEK, encrypt/decrypt blobs, slot mgmt
encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution encrypt_fido2.go # FIDO2Device interface, AddFIDO2Slot, unlock resolution
fido2_hardware.go # Real FIDO2 via go-libfido2 (//go:build fido2)
fido2_nohardware.go # Stub returning nil (//go:build !fido2)
restore.go mirror.go prune.go remove.go verify.go list.go diff.go restore.go mirror.go prune.go remove.go verify.go list.go diff.go
hasher.go # SHA-256 file hashing hasher.go # SHA-256 file hashing
@@ -629,7 +687,7 @@ type Garden struct {
} }
// Local operations // Local operations
func (g *Garden) Add(paths []string, encrypt ...bool) error func (g *Garden) Add(paths []string, opts ...AddOptions) error
func (g *Garden) Remove(paths []string) error func (g *Garden) Remove(paths []string) error
func (g *Garden) Checkpoint(message string) error func (g *Garden) Checkpoint(message string) error
func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error func (g *Garden) Restore(paths []string, force bool, confirm func(string) bool) error
@@ -640,10 +698,15 @@ func (g *Garden) Diff(path string) (string, error)
func (g *Garden) Prune() (int, error) func (g *Garden) Prune() (int, error)
func (g *Garden) MirrorUp(paths []string) error func (g *Garden) MirrorUp(paths []string) error
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error
func (g *Garden) Lock(paths []string) error
func (g *Garden) Unlock(paths []string) error
// Encryption // Encryption
func (g *Garden) EncryptInit(passphrase string) error func (g *Garden) EncryptInit(passphrase string) error
func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error func (g *Garden) UnlockDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
func (g *Garden) HasEncryption() bool
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool
func (g *Garden) RotateDEK(prompt func() (string, error), fido2 ...FIDO2Device) error
func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error func (g *Garden) AddFIDO2Slot(device FIDO2Device, label string) error
func (g *Garden) RemoveSlot(name string) error func (g *Garden) RemoveSlot(name string) error
func (g *Garden) ListSlots() map[string]string func (g *Garden) ListSlots() map[string]string

View File

@@ -22,11 +22,13 @@ Module: `github.com/kisom/sgard`. Author: K. Isom <kyle@imap.cc>.
```bash ```bash
go build ./... # both sgard and sgardd go build ./... # both sgard and sgardd
go build -tags fido2 ./... # with real FIDO2 hardware support (requires libfido2)
``` ```
Nix: Nix:
```bash ```bash
nix build .#sgard # builds both binaries nix build .#sgard # builds both binaries (no CGo)
nix build .#sgard-fido2 # with FIDO2 hardware support (links libfido2)
``` ```
Run tests: Run tests:
@@ -53,13 +55,14 @@ make proto
- `google.golang.org/protobuf` — protobuf runtime - `google.golang.org/protobuf` — protobuf runtime
- `golang.org/x/crypto` — SSH key auth (ssh, ssh/agent), Argon2id, XChaCha20-Poly1305 - `golang.org/x/crypto` — SSH key auth (ssh, ssh/agent), Argon2id, XChaCha20-Poly1305
- `github.com/golang-jwt/jwt/v5` — JWT token auth - `github.com/golang-jwt/jwt/v5` — JWT token auth
- `github.com/keys-pub/go-libfido2` — FIDO2 hardware key support (build tag `fido2`, requires libfido2)
## Package Structure ## Package Structure
``` ```
cmd/sgard/ CLI entry point (cobra commands, pure wiring) cmd/sgard/ CLI entry point (cobra commands, pure wiring)
cmd/sgardd/ gRPC server daemon cmd/sgardd/ gRPC server daemon
garden/ Core business logic (Garden struct, encryption via encrypt.go/encrypt_fido2.go) garden/ Core business logic (Garden struct, encryption, FIDO2 hardware via build tags)
manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save) manifest/ YAML manifest parsing (Manifest/Entry structs, Load/Save)
store/ Content-addressable blob storage (SHA-256 keyed) store/ Content-addressable blob storage (SHA-256 keyed)
server/ gRPC server (RPC handlers, JWT/SSH auth interceptor, proto conversion) server/ gRPC server (RPC handlers, JWT/SSH auth interceptor, proto conversion)

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Phase 3 complete (Steps 1720). Encryption fully implemented. **Phase:** Phase 4 complete. All 7 steps done (2127).
**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 complete. Future: TLS transport, shell completions, manifest signing, real FIDO2 hardware binding. Phase 5: Multi-Repo + Per-Machine Inclusion (to be planned).
## Known Issues / Decisions Deferred ## Known Issues / Decisions Deferred
@@ -82,3 +82,12 @@ Phase 3 complete. Future: TLS transport, shell completions, manifest signing, re
| 2026-03-24 | 18 | FIDO2: FIDO2Device interface, AddFIDO2Slot, unlock resolution (fido2 first → passphrase fallback), mock device, 6 tests. | | 2026-03-24 | 18 | FIDO2: FIDO2Device interface, AddFIDO2Slot, unlock resolution (fido2 first → passphrase fallback), mock device, 6 tests. |
| 2026-03-24 | 19 | Encryption CLI: encrypt init/add-fido2/remove-slot/list-slots/change-passphrase, --encrypt on add, proto + convert updates. | | 2026-03-24 | 19 | Encryption CLI: encrypt init/add-fido2/remove-slot/list-slots/change-passphrase, --encrypt on add, proto + convert updates. |
| 2026-03-24 | 20 | Polish: encryption e2e test, all docs updated, flake vendorHash updated. | | 2026-03-24 | 20 | Polish: encryption e2e test, all docs updated, flake vendorHash updated. |
| 2026-03-24 | — | Locked files + dir-only entries. v2.0.0 released. |
| 2026-03-24 | — | Phase 4 planned (Steps 2127): lock/unlock, shell completion, TLS, DEK rotation, real FIDO2, test cleanup. |
| 2026-03-24 | 21 | Lock/unlock toggle commands. garden/lock.go, cmd/sgard/lock.go, 6 tests. |
| 2026-03-24 | 22 | Shell completion: cobra built-in, README docs for bash/zsh/fish. |
| 2026-03-24 | 23 | TLS transport: sgardd --tls-cert/--tls-key, sgard --tls/--tls-ca, 2 integration tests. |
| 2026-03-24 | 24 | DEK rotation: RotateDEK re-encrypts all blobs, re-wraps all slots, CLI command, 4 tests. |
| 2026-03-24 | 25 | Real FIDO2: go-libfido2 bindings, build tag gating, CLI wiring, nix sgard-fido2 package. |
| 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. |
| 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. |

View File

@@ -222,8 +222,64 @@ Depends on Steps 17, 18.
## Future Steps (Not Phase 3) ## Future Steps (Not Phase 3)
- Shell completion via cobra ## Phase 4: Hardening + Completeness
- TLS transport (optional --tls-cert/--tls-key on sgardd)
- Multiple repo support on server ### Step 21: Lock/Unlock Toggle Commands
- Manifest signing (requires trust model design)
- DEK rotation (`sgard encrypt rotate-dek` — re-encrypt all blobs) - [x] `garden/lock.go`: `Lock(paths)`, `Unlock(paths)` — toggle locked flag on existing entries
- [x] `cmd/sgard/lock.go`: `sgard lock <path>...`, `sgard unlock <path>...`
- [x] Tests: lock/unlock existing entry, persist, error on untracked, checkpoint/status behavior changes (6 tests)
### Step 22: Shell Completion
- [x] Cobra provides built-in `sgard completion` for bash, zsh, fish, powershell — no code needed
- [x] README updated with shell completion installation instructions
### Step 23: TLS Transport for sgardd
- [x] `cmd/sgardd/main.go`: add `--tls-cert`, `--tls-key` flags
- [x] Server uses `credentials.NewTLS()` when cert/key provided, insecure otherwise
- [x] Client: add `--tls` flag and `--tls-ca` for custom CA
- [x] Update `cmd/sgard/main.go` and `dialRemote()` for TLS
- [x] Tests: TLS connection with self-signed cert (push/pull cycle, reject untrusted client)
- [x] Update ARCHITECTURE.md and README.md
### Step 24: DEK Rotation
- [x] `garden/encrypt.go`: `RotateDEK(promptPassphrase, fido2Device)` — generate new DEK, re-encrypt all encrypted blobs, re-wrap with all existing KEK slots
- [x] `cmd/sgard/encrypt.go`: `sgard encrypt rotate-dek`
- [x] Tests: rotate DEK, verify decryption, verify plaintext untouched, FIDO2 re-wrap, requires-unlock (4 tests)
### Step 25: Real FIDO2 Hardware Binding
- [x] Evaluate approach: go-libfido2 CGo bindings (keys-pub/go-libfido2 v1.5.3)
- [x] `garden/fido2_hardware.go`: HardwareFIDO2 implementing FIDO2Device via libfido2 (`//go:build fido2`)
- [x] `garden/fido2_nohardware.go`: stub returning nil (`//go:build !fido2`)
- [x] `cmd/sgard/fido2.go`: unlockDEK helper, --fido2-pin flag
- [x] `cmd/sgard/encrypt.go`: add-fido2 uses real hardware, encrypt init --fido2 registers slot, all unlock calls use FIDO2-first resolution
- [x] `flake.nix`: sgard-fido2 package variant, libfido2+pkg-config in devShell
- [x] Tests: existing mock-based tests still pass; hardware tests require manual testing with a FIDO2 key
### Step 26: Test Cleanup
- [x] Standardize all test calls — already use `AddOptions{}` struct consistently (no legacy variadic patterns found)
- [x] Ensure all tests use `t.TempDir()` consistently (audited, no `os.MkdirTemp`/`ioutil.Temp` usage)
- [x] Review lint config — added copyloopvar, durationcheck, makezero, nilerr, bodyclose linters
- [x] Verify test coverage — added 3 tests: encrypted+locked, dir-only+locked, lock/unlock toggle on encrypted
- [x] Fix stale API signatures in ARCHITECTURE.md (Add, Lock, Unlock, RotateDEK, HasEncryption, NeedsDEK)
### Step 27: Phase 4 Polish + Release
- [x] Update all docs (ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md)
- [x] Update flake.nix vendorHash (done in Step 25)
- [x] .goreleaser.yaml — no changes needed (CGO_ENABLED=0 is correct for release binaries)
- [x] E2e test: integration/phase4_test.go covering TLS + encryption + locked files + push/pull
- [x] Verify: all tests pass, lint clean, both binaries compile
## Phase 5: Multi-Repo + Per-Machine Inclusion
(To be planned)
## Phase 6: Manifest Signing
(To be planned — requires trust model design)

View File

@@ -41,6 +41,21 @@ in your packages.
Binaries are also available on the Binaries are also available on the
[releases page](https://github.com/kisom/sgard/releases). [releases page](https://github.com/kisom/sgard/releases).
### Shell completion
```sh
# Bash (add to ~/.bashrc)
source <(sgard completion bash)
# Zsh (add to ~/.zshrc)
source <(sgard completion zsh)
# Fish
sgard completion fish | source
# To load on startup:
sgard completion fish > ~/.config/fish/completions/sgard.fish
```
## Quick start ## Quick start
```sh ```sh
@@ -135,6 +150,7 @@ but doesn't touch its contents.
| `encrypt remove-slot <name>` | Remove a KEK slot | | `encrypt remove-slot <name>` | Remove a KEK slot |
| `encrypt list-slots` | List all KEK slots | | `encrypt list-slots` | List all KEK slots |
| `encrypt change-passphrase` | Change the passphrase | | `encrypt change-passphrase` | Change the passphrase |
| `encrypt rotate-dek` | Generate new DEK and re-encrypt all encrypted blobs |
| `add --encrypt <path>...` | Track files with encryption | | `add --encrypt <path>...` | Track files with encryption |
### Remote sync ### Remote sync
@@ -170,6 +186,24 @@ sgard pull --remote myserver:9473
Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`, Authentication uses your existing SSH keys (ssh-agent, `~/.ssh/id_ed25519`,
or `--ssh-key`). No passwords or certificates to manage. or `--ssh-key`). No passwords or certificates to manage.
### TLS
To encrypt the connection with TLS:
```sh
# Server: provide cert and key
sgardd --tls-cert server.crt --tls-key server.key --authorized-keys ~/.ssh/authorized_keys
# Client: enable TLS (uses system CA pool)
sgard push --remote myserver:9473 --tls
# Client: with a custom/self-signed CA
sgard push --remote myserver:9473 --tls --tls-ca ca.crt
```
Without `--tls-cert`/`--tls-key`, sgardd runs without TLS (suitable for
localhost or trusted networks).
## Encryption ## Encryption
Sensitive files can be encrypted individually: Sensitive files can be encrypted individually:
@@ -190,6 +224,22 @@ is wrapped by a passphrase-derived key (Argon2id). FIDO2 hardware keys
are also supported as an alternative KEK source — sgard tries FIDO2 are also supported as an alternative KEK source — sgard tries FIDO2
first and falls back to passphrase automatically. first and falls back to passphrase automatically.
### FIDO2 hardware keys
Build with `-tags fido2` (requires libfido2) to enable real hardware
key support, or use `nix build .#sgard-fido2`:
```sh
# Register a FIDO2 key (touch required)
sgard encrypt add-fido2
# With a PIN-protected device
sgard encrypt add-fido2 --fido2-pin 1234
# Unlock is automatic — FIDO2 is tried first, passphrase as fallback
sgard restore # touch your key when prompted
```
The encryption config (wrapped DEKs, salts) lives in the manifest, so The encryption config (wrapped DEKs, salts) lives in the manifest, so
it syncs with push/pull. The server never has the DEK. it syncs with push/pull. The server never has the DEK.

View File

@@ -30,7 +30,7 @@ var addCmd = &cobra.Command{
if !g.HasEncryption() { if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized; run sgard encrypt init first") return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
} }
if err := g.UnlockDEK(promptPassphrase); err != nil { if err := unlockDEK(g); err != nil {
return err return err
} }
} }

View File

@@ -18,6 +18,12 @@ var checkpointCmd = &cobra.Command{
return err return err
} }
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
if err := g.Checkpoint(checkpointMessage); err != nil { if err := g.Checkpoint(checkpointMessage); err != nil {
return err return err
} }

View File

@@ -17,6 +17,12 @@ var diffCmd = &cobra.Command{
return err return err
} }
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
d, err := g.Diff(args[0]) d, err := g.Diff(args[0])
if err != nil { if err != nil {
return err return err

View File

@@ -36,8 +36,16 @@ var encryptInitCmd = &cobra.Command{
fmt.Println("Encryption initialized with passphrase slot.") fmt.Println("Encryption initialized with passphrase slot.")
if fido2InitFlag { if fido2InitFlag {
fmt.Println("FIDO2 support requires a hardware device implementation.") device := garden.DetectHardwareFIDO2(fido2PinFlag)
fmt.Println("Run 'sgard encrypt add-fido2' when a FIDO2 device is available.") if device == nil {
fmt.Println("No FIDO2 device detected. Run 'sgard encrypt add-fido2' when one is connected.")
} else {
fmt.Println("Touch your FIDO2 device to register...")
if err := g.AddFIDO2Slot(device, fido2LabelFlag); err != nil {
return fmt.Errorf("adding FIDO2 slot: %w", err)
}
fmt.Println("FIDO2 slot added.")
}
} }
return nil return nil
@@ -59,13 +67,22 @@ var addFido2Cmd = &cobra.Command{
return fmt.Errorf("encryption not initialized; run sgard encrypt init first") return fmt.Errorf("encryption not initialized; run sgard encrypt init first")
} }
if err := g.UnlockDEK(promptPassphrase); err != nil { if err := unlockDEK(g); err != nil {
return err return err
} }
// Real FIDO2 device implementation would go here. device := garden.DetectHardwareFIDO2(fido2PinFlag)
// For now, this is a placeholder that explains the requirement. if device == nil {
return fmt.Errorf("FIDO2 hardware support not yet implemented; requires libfido2 binding") return fmt.Errorf("no FIDO2 device detected; connect a FIDO2 key and try again")
}
fmt.Println("Touch your FIDO2 device to register...")
if err := g.AddFIDO2Slot(device, fido2LabelFlag); err != nil {
return err
}
fmt.Println("FIDO2 slot added.")
return nil
}, },
} }
@@ -130,9 +147,8 @@ var changePassphraseCmd = &cobra.Command{
return fmt.Errorf("encryption not initialized") return fmt.Errorf("encryption not initialized")
} }
// Unlock with current passphrase. // Unlock with current credentials.
fmt.Println("Enter current passphrase:") if err := unlockDEK(g); err != nil {
if err := g.UnlockDEK(promptPassphrase); err != nil {
return err return err
} }
@@ -152,8 +168,39 @@ var changePassphraseCmd = &cobra.Command{
}, },
} }
var rotateDEKCmd = &cobra.Command{
Use: "rotate-dek",
Short: "Generate a new DEK and re-encrypt all encrypted blobs",
Long: "Generates a new data encryption key, re-encrypts all encrypted blobs, and re-wraps the DEK with all KEK slots. Required when the DEK is suspected compromised.",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if !g.HasEncryption() {
return fmt.Errorf("encryption not initialized")
}
// Unlock with current credentials.
if err := unlockDEK(g); err != nil {
return err
}
// Rotate — re-prompts for passphrase to re-wrap slot.
fmt.Println("Enter passphrase to re-wrap DEK:")
device := garden.DetectHardwareFIDO2(fido2PinFlag)
if err := g.RotateDEK(promptPassphrase, device); err != nil {
return err
}
fmt.Println("DEK rotated. All encrypted blobs re-encrypted.")
return nil
},
}
func init() { func init() {
encryptInitCmd.Flags().BoolVar(&fido2InitFlag, "fido2", false, "also set up FIDO2 (placeholder)") encryptInitCmd.Flags().BoolVar(&fido2InitFlag, "fido2", false, "also register a FIDO2 hardware key")
addFido2Cmd.Flags().StringVar(&fido2LabelFlag, "label", "", "slot label (default: fido2/<hostname>)") addFido2Cmd.Flags().StringVar(&fido2LabelFlag, "label", "", "slot label (default: fido2/<hostname>)")
encryptCmd.AddCommand(encryptInitCmd) encryptCmd.AddCommand(encryptInitCmd)
@@ -161,6 +208,7 @@ func init() {
encryptCmd.AddCommand(removeSlotCmd) encryptCmd.AddCommand(removeSlotCmd)
encryptCmd.AddCommand(listSlotsCmd) encryptCmd.AddCommand(listSlotsCmd)
encryptCmd.AddCommand(changePassphraseCmd) encryptCmd.AddCommand(changePassphraseCmd)
encryptCmd.AddCommand(rotateDEKCmd)
rootCmd.AddCommand(encryptCmd) rootCmd.AddCommand(encryptCmd)
} }

12
cmd/sgard/fido2.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import "github.com/kisom/sgard/garden"
var fido2PinFlag string
// unlockDEK attempts to unlock the DEK, trying FIDO2 hardware first
// (if available) and falling back to passphrase.
func unlockDEK(g *garden.Garden) error {
device := garden.DetectHardwareFIDO2(fido2PinFlag)
return g.UnlockDEK(promptPassphrase, device)
}

51
cmd/sgard/lock.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"fmt"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var lockCmd = &cobra.Command{
Use: "lock <path>...",
Short: "Mark tracked files as locked (repo-authoritative)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.Lock(args); err != nil {
return err
}
fmt.Printf("Locked %d path(s).\n", len(args))
return nil
},
}
var unlockCmd = &cobra.Command{
Use: "unlock <path>...",
Short: "Remove locked flag from tracked files",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
if err := g.Unlock(args); err != nil {
return err
}
fmt.Printf("Unlocked %d path(s).\n", len(args))
return nil
},
}
func init() {
rootCmd.AddCommand(lockCmd)
rootCmd.AddCommand(unlockCmd)
}

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"crypto/tls"
"crypto/x509"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -10,6 +12,7 @@ import (
"github.com/kisom/sgard/client" "github.com/kisom/sgard/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
) )
@@ -17,6 +20,8 @@ var (
repoFlag string repoFlag string
remoteFlag string remoteFlag string
sshKeyFlag string sshKeyFlag string
tlsFlag bool
tlsCAFlag string
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -66,8 +71,27 @@ func dialRemote(ctx context.Context) (*client.Client, func(), error) {
cachedToken := client.LoadCachedToken() cachedToken := client.LoadCachedToken()
creds := client.NewTokenCredentials(cachedToken) creds := client.NewTokenCredentials(cachedToken)
var transportCreds grpc.DialOption
if tlsFlag {
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
if tlsCAFlag != "" {
caPEM, err := os.ReadFile(tlsCAFlag)
if err != nil {
return nil, nil, fmt.Errorf("reading CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, nil, fmt.Errorf("failed to parse CA cert %s", tlsCAFlag)
}
tlsCfg.RootCAs = pool
}
transportCreds = grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))
} else {
transportCreds = grpc.WithTransportCredentials(insecure.NewCredentials())
}
conn, err := grpc.NewClient(addr, conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()), transportCreds,
grpc.WithPerRPCCredentials(creds), grpc.WithPerRPCCredentials(creds),
) )
if err != nil { if err != nil {
@@ -90,6 +114,9 @@ func main() {
rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository") rootCmd.PersistentFlags().StringVar(&repoFlag, "repo", defaultRepo(), "path to sgard repository")
rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)") rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "gRPC server address (host:port)")
rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key") rootCmd.PersistentFlags().StringVar(&sshKeyFlag, "ssh-key", "", "path to SSH private key")
rootCmd.PersistentFlags().BoolVar(&tlsFlag, "tls", false, "use TLS for remote connection")
rootCmd.PersistentFlags().StringVar(&tlsCAFlag, "tls-ca", "", "path to CA certificate for TLS verification")
rootCmd.PersistentFlags().StringVar(&fido2PinFlag, "fido2-pin", "", "PIN for FIDO2 device (if PIN-protected)")
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)

View File

@@ -21,6 +21,12 @@ var restoreCmd = &cobra.Command{
return err return err
} }
if g.HasEncryption() && g.NeedsDEK(g.List()) {
if err := unlockDEK(g); err != nil {
return err
}
}
confirm := func(path string) bool { confirm := func(path string) bool {
fmt.Printf("Overwrite %s? [y/N] ", path) fmt.Printf("Overwrite %s? [y/N] ", path)
scanner := bufio.NewScanner(os.Stdin) scanner := bufio.NewScanner(os.Stdin)

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"crypto/tls"
"fmt" "fmt"
"net" "net"
"os" "os"
@@ -10,12 +11,15 @@ import (
"github.com/kisom/sgard/sgardpb" "github.com/kisom/sgard/sgardpb"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials"
) )
var ( var (
listenAddr string listenAddr string
repoPath string repoPath string
authKeysPath string authKeysPath string
tlsCertPath string
tlsKeyPath string
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -28,6 +32,21 @@ var rootCmd = &cobra.Command{
} }
var opts []grpc.ServerOption var opts []grpc.ServerOption
if tlsCertPath != "" && tlsKeyPath != "" {
cert, err := tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath)
if err != nil {
return fmt.Errorf("loading TLS cert/key: %w", err)
}
opts = append(opts, grpc.Creds(credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})))
fmt.Println("TLS enabled")
} else if tlsCertPath != "" || tlsKeyPath != "" {
return fmt.Errorf("both --tls-cert and --tls-key must be specified together")
}
var srvInstance *server.Server var srvInstance *server.Server
if authKeysPath != "" { if authKeysPath != "" {
@@ -63,6 +82,8 @@ func main() {
rootCmd.Flags().StringVar(&listenAddr, "listen", ":9473", "gRPC listen address") rootCmd.Flags().StringVar(&listenAddr, "listen", ":9473", "gRPC listen address")
rootCmd.Flags().StringVar(&repoPath, "repo", "/srv/sgard", "path to sgard repository") rootCmd.Flags().StringVar(&repoPath, "repo", "/srv/sgard", "path to sgard repository")
rootCmd.Flags().StringVar(&authKeysPath, "authorized-keys", "", "path to authorized SSH public keys file") rootCmd.Flags().StringVar(&authKeysPath, "authorized-keys", "", "path to authorized SSH public keys file")
rootCmd.Flags().StringVar(&tlsCertPath, "tls-cert", "", "path to TLS certificate file")
rootCmd.Flags().StringVar(&tlsKeyPath, "tls-key", "", "path to TLS private key file")
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)

View File

@@ -15,11 +15,11 @@
packages = { packages = {
sgard = pkgs.buildGoModule { sgard = pkgs.buildGoModule {
pname = "sgard"; pname = "sgard";
version = "2.0.0"; version = "2.1.0";
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ]; subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-0YpP1YfpAIAgY8k+7DlWosYN6MT5a2KLtNhQFvKT7pM="; vendorHash = "sha256-0aGo5EbvPWt9Oflq+GTq8nEBUWZj3O5Ni4Qwd5EBa7Y=";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
@@ -29,6 +29,26 @@
}; };
}; };
sgard-fido2 = pkgs.buildGoModule {
pname = "sgard-fido2";
version = "2.1.0";
src = pkgs.lib.cleanSource ./.;
subPackages = [ "cmd/sgard" "cmd/sgardd" ];
vendorHash = "sha256-LSz15iFsP4N3Cif1PFHEKg3udeqH/9WQQbZ50sxtWTk=";
buildInputs = [ pkgs.libfido2 ];
nativeBuildInputs = [ pkgs.pkg-config ];
tags = [ "fido2" ];
ldflags = [ "-s" "-w" ];
meta = {
description = "Shimmering Clarity Gardener: dotfile management (with FIDO2 hardware support)";
mainProgram = "sgard";
};
};
default = self.packages.${system}.sgard; default = self.packages.${system}.sgard;
}; };
@@ -39,6 +59,8 @@
protobuf protobuf
protoc-gen-go protoc-gen-go
protoc-gen-go-grpc protoc-gen-go-grpc
libfido2
pkg-config
]; ];
}; };
} }

View File

@@ -213,6 +213,140 @@ func (g *Garden) ChangePassphrase(newPassphrase string) error {
return nil return nil
} }
// RotateDEK generates a new DEK, re-encrypts all encrypted blobs, and
// re-wraps the new DEK with all existing KEK slots. The old DEK must
// already be unlocked. A passphrase prompt is required to re-derive
// the KEK for the passphrase slot. An optional FIDO2 device re-wraps
// FIDO2 slots; FIDO2 slots without a matching device are dropped.
func (g *Garden) RotateDEK(promptPassphrase func() (string, error), fido2Device ...FIDO2Device) error {
if g.dek == nil {
return fmt.Errorf("DEK not unlocked")
}
enc := g.manifest.Encryption
if enc == nil {
return fmt.Errorf("encryption not initialized")
}
oldDEK := g.dek
// Generate new DEK.
newDEK := make([]byte, dekSize)
if _, err := rand.Read(newDEK); err != nil {
return fmt.Errorf("generating new DEK: %w", err)
}
// Re-encrypt all encrypted blobs.
for i := range g.manifest.Files {
entry := &g.manifest.Files[i]
if !entry.Encrypted || entry.Hash == "" {
continue
}
// Read encrypted blob.
ciphertext, err := g.store.Read(entry.Hash)
if err != nil {
return fmt.Errorf("reading blob %s for %s: %w", entry.Hash, entry.Path, err)
}
// Decrypt with old DEK.
g.dek = oldDEK
plaintext, err := g.decryptBlob(ciphertext)
if err != nil {
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
}
// Re-encrypt with new DEK.
g.dek = newDEK
newCiphertext, err := g.encryptBlob(plaintext)
if err != nil {
return fmt.Errorf("re-encrypting %s: %w", entry.Path, err)
}
// Write new blob.
newHash, err := g.store.Write(newCiphertext)
if err != nil {
return fmt.Errorf("writing re-encrypted blob for %s: %w", entry.Path, err)
}
entry.Hash = newHash
// PlaintextHash stays the same — the plaintext didn't change.
}
// Re-wrap new DEK with all existing KEK slots.
for name, slot := range enc.KekSlots {
var kek []byte
switch slot.Type {
case "passphrase":
if promptPassphrase == nil {
return fmt.Errorf("passphrase required to re-wrap slot %q", name)
}
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 for slot %q: %w", name, err)
}
kek = derivePassphraseKEK(passphrase, salt, slot.Argon2Time, slot.Argon2Memory, slot.Argon2Threads)
case "fido2":
var device FIDO2Device
if len(fido2Device) > 0 {
device = fido2Device[0]
}
if device == nil || !device.Available() {
// Drop FIDO2 slots without a matching device.
delete(enc.KekSlots, name)
continue
}
credID, err := base64.StdEncoding.DecodeString(slot.CredentialID)
if err != nil {
delete(enc.KekSlots, name)
continue
}
if !device.MatchesCredential(credID) {
delete(enc.KekSlots, name)
continue
}
salt, err := base64.StdEncoding.DecodeString(slot.Salt)
if err != nil {
delete(enc.KekSlots, name)
continue
}
fido2KEK, err := device.Derive(credID, salt)
if err != nil {
delete(enc.KekSlots, name)
continue
}
if len(fido2KEK) < dekSize {
delete(enc.KekSlots, name)
continue
}
kek = fido2KEK[:dekSize]
default:
return fmt.Errorf("unknown slot type %q for slot %q", slot.Type, name)
}
wrappedDEK, err := wrapDEK(newDEK, kek)
if err != nil {
return fmt.Errorf("re-wrapping DEK for slot %q: %w", name, err)
}
slot.WrappedDEK = base64.StdEncoding.EncodeToString(wrappedDEK)
}
g.dek = newDEK
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}
// NeedsDEK reports whether any of the given entries are encrypted. // NeedsDEK reports whether any of the given entries are encrypted.
func (g *Garden) NeedsDEK(entries []manifest.Entry) bool { func (g *Garden) NeedsDEK(entries []manifest.Entry) bool {
for _, e := range entries { for _, e := range entries {

View File

@@ -0,0 +1,239 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestRotateDEK(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "test-passphrase"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Add an encrypted file and a plaintext file.
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil {
t.Fatalf("writing secret: %v", err)
}
plainFile := filepath.Join(root, "plain")
if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil {
t.Fatalf("writing plain: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
if err := g.Add([]string{plainFile}); err != nil {
t.Fatalf("Add plain: %v", err)
}
// Record pre-rotation state.
var origEncHash, origEncPtHash, origPlainHash string
for _, e := range g.manifest.Files {
if e.Encrypted {
origEncHash = e.Hash
origEncPtHash = e.PlaintextHash
} else {
origPlainHash = e.Hash
}
}
oldDEK := make([]byte, len(g.dek))
copy(oldDEK, g.dek)
// Rotate.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// DEK should have changed.
if string(g.dek) == string(oldDEK) {
t.Error("DEK should change after rotation")
}
// Check manifest entries.
for _, e := range g.manifest.Files {
if e.Encrypted {
// Ciphertext hash should change (new nonce + new key).
if e.Hash == origEncHash {
t.Error("encrypted entry hash should change after rotation")
}
// Plaintext hash should NOT change.
if e.PlaintextHash != origEncPtHash {
t.Errorf("plaintext hash changed: %s → %s", origEncPtHash, e.PlaintextHash)
}
} else {
// Plaintext entry should be untouched.
if e.Hash != origPlainHash {
t.Errorf("plaintext entry hash changed: %s → %s", origPlainHash, e.Hash)
}
}
}
// Verify the new blob decrypts correctly.
_ = os.Remove(secretFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore after rotation: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading restored file: %v", err)
}
if string(got) != "secret data" {
t.Errorf("restored content = %q, want %q", got, "secret data")
}
}
func TestRotateDEK_UnlockWithNewPassphrase(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "original"
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}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Rotate with the same passphrase.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// Re-open and verify unlock still works with the same passphrase.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(prompt); err != nil {
t.Fatalf("UnlockDEK after rotation: %v", err)
}
// Verify restore works.
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore after re-open: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "data" {
t.Errorf("got %q, want %q", got, "data")
}
}
func TestRotateDEK_WithFIDO2(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
passphrase := "passphrase"
if err := g.EncryptInit(passphrase); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Add a FIDO2 slot.
device := newMockFIDO2()
if err := g.AddFIDO2Slot(device, "testkey"); err != nil {
t.Fatalf("AddFIDO2Slot: %v", err)
}
secretFile := filepath.Join(root, "secret")
if err := os.WriteFile(secretFile, []byte("fido2 data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{secretFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
// Rotate with both passphrase and FIDO2 device.
prompt := func() (string, error) { return passphrase, nil }
if err := g.RotateDEK(prompt, device); err != nil {
t.Fatalf("RotateDEK: %v", err)
}
// Both slots should still exist.
slots := g.ListSlots()
if _, ok := slots["passphrase"]; !ok {
t.Error("passphrase slot should still exist after rotation")
}
if _, ok := slots["fido2/testkey"]; !ok {
t.Error("fido2/testkey slot should still exist after rotation")
}
// Unlock via FIDO2 should work.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := g2.UnlockDEK(nil, device); err != nil {
t.Fatalf("UnlockDEK via FIDO2 after rotation: %v", err)
}
// Verify decryption.
_ = os.Remove(secretFile)
if err := g2.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(secretFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "fido2 data" {
t.Errorf("got %q, want %q", got, "fido2 data")
}
}
func TestRotateDEK_RequiresUnlock(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("pass"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
// Re-open without unlocking.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
err = g2.RotateDEK(func() (string, error) { return "pass", nil })
if err == nil {
t.Fatal("RotateDEK without unlock should fail")
}
}

156
garden/fido2_hardware.go Normal file
View File

@@ -0,0 +1,156 @@
//go:build fido2
package garden
import (
"crypto/sha256"
"fmt"
libfido2 "github.com/keys-pub/go-libfido2"
)
const rpID = "sgard"
// HardwareFIDO2 implements FIDO2Device using a real hardware authenticator
// via libfido2.
type HardwareFIDO2 struct {
pin string // device PIN (empty if no PIN set)
}
// NewHardwareFIDO2 creates a HardwareFIDO2 device. The PIN is needed for
// operations on PIN-protected authenticators.
func NewHardwareFIDO2(pin string) *HardwareFIDO2 {
return &HardwareFIDO2{pin: pin}
}
// Available reports whether a FIDO2 device is connected.
func (h *HardwareFIDO2) Available() bool {
locs, err := libfido2.DeviceLocations()
if err != nil {
return false
}
return len(locs) > 0
}
// Register creates a new credential with the hmac-secret extension.
// Returns the credential ID and the HMAC-secret output for the given salt.
func (h *HardwareFIDO2) Register(salt []byte) ([]byte, []byte, error) {
dev, err := h.deviceForPath()
if err != nil {
return nil, nil, err
}
cdh := sha256.Sum256(salt)
// CTAP2 hmac-secret extension requires a 32-byte salt.
hmacSalt := fido2Salt(salt)
userID := sha256.Sum256([]byte("sgard-user"))
attest, err := dev.MakeCredential(
cdh[:],
libfido2.RelyingParty{ID: rpID, Name: "sgard"},
libfido2.User{ID: userID[:], Name: "sgard"},
libfido2.ES256,
h.pin,
&libfido2.MakeCredentialOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
RK: libfido2.False,
},
)
if err != nil {
return nil, nil, fmt.Errorf("fido2 make credential: %w", err)
}
// Do an assertion to get the HMAC-secret for this salt.
assertion, err := dev.Assertion(
rpID,
cdh[:],
[][]byte{attest.CredentialID},
h.pin,
&libfido2.AssertionOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
HMACSalt: hmacSalt,
UP: libfido2.True,
},
)
if err != nil {
return nil, nil, fmt.Errorf("fido2 assertion for hmac-secret: %w", err)
}
return attest.CredentialID, assertion.HMACSecret, nil
}
// Derive computes HMAC(device_secret, salt) for an existing credential.
// Requires user touch.
func (h *HardwareFIDO2) Derive(credentialID []byte, salt []byte) ([]byte, error) {
dev, err := h.deviceForPath()
if err != nil {
return nil, err
}
cdh := sha256.Sum256(salt)
hmacSalt := fido2Salt(salt)
assertion, err := dev.Assertion(
rpID,
cdh[:],
[][]byte{credentialID},
h.pin,
&libfido2.AssertionOpts{
Extensions: []libfido2.Extension{libfido2.HMACSecretExtension},
HMACSalt: hmacSalt,
UP: libfido2.True,
},
)
if err != nil {
return nil, fmt.Errorf("fido2 assertion: %w", err)
}
return assertion.HMACSecret, nil
}
// MatchesCredential reports whether the connected device might hold the
// given credential. Since probing without user presence is unreliable
// across devices, we optimistically return true and let Derive handle
// the actual verification (which requires a touch).
func (h *HardwareFIDO2) MatchesCredential(_ []byte) bool {
return h.Available()
}
// fido2Salt returns a 32-byte salt suitable for the CTAP2 hmac-secret
// extension. If the input is already 32 bytes, it is returned as-is.
// Otherwise, SHA-256 is used to derive a 32-byte value deterministically.
func fido2Salt(salt []byte) []byte {
if len(salt) == 32 {
return salt
}
h := sha256.Sum256(salt)
return h[:]
}
// deviceForPath returns a Device handle for the first connected FIDO2
// device. The library manages open/close internally per operation.
func (h *HardwareFIDO2) deviceForPath() (*libfido2.Device, error) {
locs, err := libfido2.DeviceLocations()
if err != nil {
return nil, fmt.Errorf("listing fido2 devices: %w", err)
}
if len(locs) == 0 {
return nil, fmt.Errorf("no fido2 device found")
}
dev, err := libfido2.NewDevice(locs[0].Path)
if err != nil {
return nil, fmt.Errorf("opening fido2 device %s: %w", locs[0].Path, err)
}
return dev, nil
}
// DetectHardwareFIDO2 returns a HardwareFIDO2 device if hardware is available,
// or nil if no device is connected.
func DetectHardwareFIDO2(pin string) FIDO2Device {
d := NewHardwareFIDO2(pin)
if d.Available() {
return d
}
return nil
}

View File

@@ -0,0 +1,10 @@
//go:build !fido2
package garden
// DetectHardwareFIDO2 is a stub that returns nil when built without the
// fido2 build tag. Build with -tags fido2 and link against libfido2 to
// enable real hardware support.
func DetectHardwareFIDO2(_ string) FIDO2Device {
return nil
}

39
garden/lock.go Normal file
View File

@@ -0,0 +1,39 @@
package garden
import (
"fmt"
"path/filepath"
)
// Lock marks existing tracked entries as locked (repo-authoritative).
func (g *Garden) Lock(paths []string) error {
return g.setLocked(paths, true)
}
// Unlock removes the locked flag from existing tracked entries.
func (g *Garden) Unlock(paths []string) error {
return g.setLocked(paths, false)
}
func (g *Garden) setLocked(paths []string, locked bool) error {
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
return fmt.Errorf("resolving path %s: %w", p, err)
}
tilded := toTildePath(abs)
entry := g.findEntry(tilded)
if entry == nil {
return fmt.Errorf("not tracked: %s", tilded)
}
entry.Locked = locked
}
if err := g.manifest.Save(g.manifestPath); err != nil {
return fmt.Errorf("saving manifest: %w", err)
}
return nil
}

197
garden/lock_test.go Normal file
View File

@@ -0,0 +1,197 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestLockExistingEntry(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add without lock.
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
if g.manifest.Files[0].Locked {
t.Fatal("should not be locked initially")
}
// Lock it.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Error("should be locked after Lock()")
}
// Verify persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !g2.manifest.Files[0].Locked {
t.Error("locked state should persist")
}
}
func TestUnlockExistingEntry(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Fatal("should be locked")
}
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
if g.manifest.Files[0].Locked {
t.Error("should not be locked after Unlock()")
}
}
func TestLockUntrackedErrors(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "nottracked")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Lock([]string{testFile}); err == nil {
t.Fatal("Lock on untracked path should error")
}
}
func TestLockChangesCheckpointBehavior(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
// Add unlocked, checkpoint picks up changes.
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
if err := os.WriteFile(testFile, []byte("changed"), 0o644); 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.Fatal("unlocked file: checkpoint should update hash")
}
newHash := g.manifest.Files[0].Hash
// Now lock it and modify again — checkpoint should NOT update.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if err := os.WriteFile(testFile, []byte("system overwrote"), 0o644); err != nil {
t.Fatalf("overwriting: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != newHash {
t.Error("locked file: checkpoint should not update hash")
}
}
func TestUnlockChangesStatusBehavior(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
t.Fatalf("writing: %v", err)
}
if err := g.Add([]string{testFile}, AddOptions{Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := os.WriteFile(testFile, []byte("changed"), 0o644); err != nil {
t.Fatalf("modifying: %v", err)
}
// Locked: should be "drifted".
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if statuses[0].State != "drifted" {
t.Errorf("locked: expected drifted, got %s", statuses[0].State)
}
// Unlock: should now be "modified".
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
statuses, err = g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if statuses[0].State != "modified" {
t.Errorf("unlocked: expected modified, got %s", statuses[0].State)
}
}

192
garden/locked_combo_test.go Normal file
View File

@@ -0,0 +1,192 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestEncryptedLockedFile(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)
}
testFile := filepath.Join(root, "secret")
if err := os.WriteFile(testFile, []byte("locked secret"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
// Add as both encrypted and locked.
if err := g.Add([]string{testFile}, AddOptions{Encrypt: true, Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if !entry.Encrypted {
t.Error("should be encrypted")
}
if !entry.Locked {
t.Error("should be locked")
}
if entry.PlaintextHash == "" {
t.Error("should have plaintext hash")
}
origHash := entry.Hash
// Modify the file — checkpoint should skip (locked).
if err := os.WriteFile(testFile, []byte("system overwrote"), 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("checkpoint should skip locked file even if encrypted")
}
// Status should report drifted.
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].State != "drifted" {
t.Errorf("expected drifted, got %v", statuses)
}
// Restore should decrypt and overwrite without prompting.
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading: %v", err)
}
if string(got) != "locked secret" {
t.Errorf("content = %q, want %q", got, "locked secret")
}
}
func TestDirOnlyLocked(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "lockdir")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Add as dir-only and locked.
if err := g.Add([]string{testDir}, AddOptions{DirOnly: true, Lock: true}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if entry.Type != "directory" {
t.Errorf("type = %s, want directory", entry.Type)
}
if !entry.Locked {
t.Error("should be locked")
}
// Remove the directory.
if err := os.RemoveAll(testDir); err != nil {
t.Fatalf("removing: %v", err)
}
// Restore should recreate it.
if err := g.Restore(nil, false, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
info, err := os.Stat(testDir)
if err != nil {
t.Fatalf("directory not restored: %v", err)
}
if !info.IsDir() {
t.Error("should be a directory")
}
}
func TestLockUnlockEncryptedToggle(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)
}
testFile := filepath.Join(root, "secret")
if err := os.WriteFile(testFile, []byte("data"), 0o600); err != nil {
t.Fatalf("writing: %v", err)
}
// Add encrypted but not locked.
if err := g.Add([]string{testFile}, AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add: %v", err)
}
if g.manifest.Files[0].Locked {
t.Fatal("should not be locked initially")
}
// Lock it.
if err := g.Lock([]string{testFile}); err != nil {
t.Fatalf("Lock: %v", err)
}
if !g.manifest.Files[0].Locked {
t.Error("should be locked")
}
if !g.manifest.Files[0].Encrypted {
t.Error("should still be encrypted")
}
// Modify — checkpoint should skip.
origHash := g.manifest.Files[0].Hash
if err := os.WriteFile(testFile, []byte("changed"), 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("checkpoint should skip locked encrypted file")
}
// Unlock — checkpoint should now pick up changes.
if err := g.Unlock([]string{testFile}); err != nil {
t.Fatalf("Unlock: %v", err)
}
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash == origHash {
t.Error("unlocked: checkpoint should update encrypted file hash")
}
}

19
go.mod
View File

@@ -3,17 +3,22 @@ module github.com/kisom/sgard
go 1.25.7 go 1.25.7
require ( require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jonboulle/clockwork v0.5.0
github.com/keys-pub/go-libfido2 v1.5.3
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

43
go.sum
View File

@@ -1,30 +1,73 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/keys-pub/go-libfido2 v1.5.3 h1:vtgHxlSB43u6lj0TSuA3VvT6z3E7VI+L1a2hvMFdECk=
github.com/keys-pub/go-libfido2 v1.5.3/go.mod h1:P0V19qHwJNY0htZwZDe9Ilvs/nokGhdFX7faKFyZ6+U=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

265
integration/phase4_test.go Normal file
View File

@@ -0,0 +1,265 @@
package integration
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/kisom/sgard/client"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "sgard-e2e"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
DNSNames: []string{"localhost"},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("creating certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshaling key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("loading key pair: %v", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(certPEM)
return cert, pool
}
// TestE2E_Phase4 exercises TLS + encryption + locked files in a push/pull cycle.
func TestE2E_Phase4(t *testing.T) {
// --- Setup TLS server ---
cert, caPool := generateSelfSignedCert(t)
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
serverCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
srv := grpc.NewServer(grpc.Creds(serverCreds))
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
t.Cleanup(func() { srv.Stop() })
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() { _ = srv.Serve(lis) }()
clientCreds := credentials.NewTLS(&tls.Config{
RootCAs: caPool,
MinVersion: tls.VersionTLS12,
})
// --- Build source garden with encryption + locked files ---
srcRoot := t.TempDir()
srcRepoDir := filepath.Join(srcRoot, "repo")
srcGarden, err := garden.Init(srcRepoDir)
if err != nil {
t.Fatalf("init source garden: %v", err)
}
if err := srcGarden.EncryptInit("test-passphrase"); err != nil {
t.Fatalf("EncryptInit: %v", err)
}
plainFile := filepath.Join(srcRoot, "plain")
secretFile := filepath.Join(srcRoot, "secret")
lockedFile := filepath.Join(srcRoot, "locked")
encLockedFile := filepath.Join(srcRoot, "enc-locked")
if err := os.WriteFile(plainFile, []byte("plain data"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(secretFile, []byte("secret data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(lockedFile, []byte("locked data"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(encLockedFile, []byte("enc+locked data"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := srcGarden.Add([]string{plainFile}); err != nil {
t.Fatalf("Add plain: %v", err)
}
if err := srcGarden.Add([]string{secretFile}, garden.AddOptions{Encrypt: true}); err != nil {
t.Fatalf("Add encrypted: %v", err)
}
if err := srcGarden.Add([]string{lockedFile}, garden.AddOptions{Lock: true}); err != nil {
t.Fatalf("Add locked: %v", err)
}
if err := srcGarden.Add([]string{encLockedFile}, garden.AddOptions{Encrypt: true, Lock: true}); err != nil {
t.Fatalf("Add encrypted+locked: %v", err)
}
// Bump timestamp so push wins.
srcManifest := srcGarden.GetManifest()
srcManifest.Updated = time.Now().UTC().Add(time.Hour)
if err := srcGarden.ReplaceManifest(srcManifest); err != nil {
t.Fatalf("ReplaceManifest: %v", err)
}
// --- Push over TLS ---
ctx := context.Background()
pushConn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(clientCreds),
)
if err != nil {
t.Fatalf("dial for push: %v", err)
}
defer func() { _ = pushConn.Close() }()
pushClient := client.New(pushConn)
pushed, err := pushClient.Push(ctx, srcGarden)
if err != nil {
t.Fatalf("Push: %v", err)
}
if pushed < 2 {
t.Errorf("expected at least 2 blobs pushed, got %d", pushed)
}
// --- Pull to a fresh garden over TLS ---
dstRoot := t.TempDir()
dstRepoDir := filepath.Join(dstRoot, "repo")
dstGarden, err := garden.Init(dstRepoDir)
if err != nil {
t.Fatalf("init dest garden: %v", err)
}
pullConn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(clientCreds),
)
if err != nil {
t.Fatalf("dial for pull: %v", err)
}
defer func() { _ = pullConn.Close() }()
pullClient := client.New(pullConn)
pulled, err := pullClient.Pull(ctx, dstGarden)
if err != nil {
t.Fatalf("Pull: %v", err)
}
if pulled < 2 {
t.Errorf("expected at least 2 blobs pulled, got %d", pulled)
}
// --- Verify the pulled manifest ---
dstManifest := dstGarden.GetManifest()
if len(dstManifest.Files) != 4 {
t.Fatalf("expected 4 entries, got %d", len(dstManifest.Files))
}
type entryInfo struct {
encrypted bool
locked bool
}
entryMap := make(map[string]entryInfo)
for _, e := range dstManifest.Files {
entryMap[e.Path] = entryInfo{e.Encrypted, e.Locked}
}
// Verify flags survived round trip.
for path, info := range entryMap {
switch {
case path == toTilde(secretFile):
if !info.encrypted {
t.Errorf("%s should be encrypted", path)
}
case path == toTilde(lockedFile):
if !info.locked {
t.Errorf("%s should be locked", path)
}
case path == toTilde(encLockedFile):
if !info.encrypted || !info.locked {
t.Errorf("%s should be encrypted+locked", path)
}
case path == toTilde(plainFile):
if info.encrypted || info.locked {
t.Errorf("%s should be plain", path)
}
}
}
// Verify encryption config survived.
if dstManifest.Encryption == nil {
t.Fatal("encryption config should survive push/pull")
}
if dstManifest.Encryption.Algorithm != "xchacha20-poly1305" {
t.Errorf("algorithm = %s, want xchacha20-poly1305", dstManifest.Encryption.Algorithm)
}
if _, ok := dstManifest.Encryption.KekSlots["passphrase"]; !ok {
t.Error("passphrase slot should survive push/pull")
}
// Verify all blobs arrived.
for _, e := range dstManifest.Files {
if e.Hash != "" && !dstGarden.BlobExists(e.Hash) {
t.Errorf("blob missing for %s (hash %s)", e.Path, e.Hash)
}
}
// Unlock on dest and verify DEK works.
if err := dstGarden.UnlockDEK(func() (string, error) { return "test-passphrase", nil }); err != nil {
t.Fatalf("UnlockDEK on dest: %v", err)
}
}
func toTilde(path string) string {
home, err := os.UserHomeDir()
if err != nil {
return path
}
rel, err := filepath.Rel(home, path)
if err != nil || len(rel) > 0 && rel[0] == '.' {
return path
}
return "~/" + rel
}

237
server/tls_test.go Normal file
View File

@@ -0,0 +1,237 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"testing"
"time"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/manifest"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// generateSelfSignedCert creates a self-signed TLS certificate for testing.
func generateSelfSignedCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generating key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "sgard-test"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
DNSNames: []string{"localhost"},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("creating certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshaling key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
t.Fatalf("loading key pair: %v", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(certPEM)
return cert, pool
}
// setupTLSTest creates a TLS-secured client-server pair.
func setupTLSTest(t *testing.T) (sgardpb.GardenSyncClient, *garden.Garden, *garden.Garden) {
t.Helper()
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
clientDir := t.TempDir()
clientGarden, err := garden.Init(clientDir)
if err != nil {
t.Fatalf("init client garden: %v", err)
}
cert, caPool := generateSelfSignedCert(t)
// Server with TLS.
serverCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
srv := grpc.NewServer(grpc.Creds(serverCreds))
sgardpb.RegisterGardenSyncServer(srv, New(serverGarden))
t.Cleanup(func() { srv.Stop() })
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = srv.Serve(lis)
}()
// Client with TLS, trusting the self-signed CA.
clientCreds := credentials.NewTLS(&tls.Config{
RootCAs: caPool,
MinVersion: tls.VersionTLS12,
})
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(clientCreds),
)
if err != nil {
t.Fatalf("dial TLS: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
client := sgardpb.NewGardenSyncClient(conn)
return client, serverGarden, clientGarden
}
func TestTLS_PushPullCycle(t *testing.T) {
client, serverGarden, _ := setupTLSTest(t)
ctx := context.Background()
// Write test blobs to get real hashes.
tmpDir := t.TempDir()
tmpGarden, err := garden.Init(tmpDir)
if err != nil {
t.Fatalf("init tmp garden: %v", err)
}
blobData := []byte("TLS test blob content")
hash, err := tmpGarden.WriteBlob(blobData)
if err != nil {
t.Fatalf("WriteBlob: %v", err)
}
now := time.Now().UTC().Add(time.Hour)
clientManifest := &manifest.Manifest{
Version: 1,
Created: now,
Updated: now,
Files: []manifest.Entry{
{Path: "~/.tlstest", Hash: hash, Type: "file", Mode: "0644", Updated: now},
},
}
// Push manifest over TLS.
pushResp, err := client.PushManifest(ctx, &sgardpb.PushManifestRequest{
Manifest: ManifestToProto(clientManifest),
})
if err != nil {
t.Fatalf("PushManifest over TLS: %v", err)
}
if pushResp.Decision != sgardpb.PushManifestResponse_ACCEPTED {
t.Fatalf("decision: got %v, want ACCEPTED", pushResp.Decision)
}
// Push blob over TLS.
stream, err := client.PushBlobs(ctx)
if err != nil {
t.Fatalf("PushBlobs over TLS: %v", err)
}
if err := stream.Send(&sgardpb.PushBlobsRequest{
Chunk: &sgardpb.BlobChunk{Hash: hash, Data: blobData},
}); err != nil {
t.Fatalf("Send blob: %v", err)
}
blobResp, err := stream.CloseAndRecv()
if err != nil {
t.Fatalf("CloseAndRecv: %v", err)
}
if blobResp.BlobsReceived != 1 {
t.Errorf("blobs_received: got %d, want 1", blobResp.BlobsReceived)
}
// Verify blob arrived on server.
if !serverGarden.BlobExists(hash) {
t.Error("blob not found on server after TLS push")
}
// Pull manifest back over TLS.
pullResp, err := client.PullManifest(ctx, &sgardpb.PullManifestRequest{})
if err != nil {
t.Fatalf("PullManifest over TLS: %v", err)
}
pulledManifest := ProtoToManifest(pullResp.GetManifest())
if len(pulledManifest.Files) != 1 {
t.Fatalf("pulled manifest files: got %d, want 1", len(pulledManifest.Files))
}
if pulledManifest.Files[0].Path != "~/.tlstest" {
t.Errorf("pulled path: got %q, want %q", pulledManifest.Files[0].Path, "~/.tlstest")
}
}
func TestTLS_RejectsPlaintextClient(t *testing.T) {
cert, _ := generateSelfSignedCert(t)
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server garden: %v", err)
}
serverCreds := credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
})
srv := grpc.NewServer(grpc.Creds(serverCreds))
sgardpb.RegisterGardenSyncServer(srv, New(serverGarden))
t.Cleanup(func() { srv.Stop() })
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
_ = srv.Serve(lis)
}()
// Try to connect without TLS — should fail.
conn, err := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
// No RootCAs — won't trust the self-signed cert.
MinVersion: tls.VersionTLS12,
})),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer func() { _ = conn.Close() }()
client := sgardpb.NewGardenSyncClient(conn)
_, err = client.PullManifest(context.Background(), &sgardpb.PullManifestRequest{})
if err == nil {
t.Fatal("expected error when connecting without trusted CA to TLS server")
}
}