// Command mavseal encrypts a live tmpfs working copy back to the ciphertext // file, for the case mavend could not do it itself. // // mavend seals its database in `defer st.Close()` when run() returns. A daemon // that is killed rather than shut down never gets there, and because the // working copy lives in the container's /dev/shm it dies with the container: // everything written since the last clean shutdown is lost, and the next boot // silently rolls back to the stale ciphertext. That is not hypothetical — on // 2026-08-01 the deployed ciphertext was eleven days old. // // This is a recovery tool, not part of the daemon. It is safe to run against a // live database: it takes a consistent snapshot with VACUUM INTO rather than // mutating the working copy the daemon owns. // // Usage: // // mavseal -plain /dev/shm/maven-plain.db -cipher /var/lib/maven/maven.db.enc // // The key is read from MAVEN_DB_KEY (base64, 32 bytes decoded), the same // variable the daemon uses. It is never taken as an argument: an argument ends // up in the shell history and in ps. package main import ( "context" "database/sql" "encoding/base64" "flag" "fmt" "log" "os" "github.com/kami/maven/internal/store" _ "modernc.org/sqlite" ) func main() { log.SetFlags(0) if err := run(); err != nil { log.Fatalf("mavseal: %v", err) } } func run() error { plain := flag.String("plain", "", "path to the plaintext working copy (required)") cipher := flag.String("cipher", "", "path to write the ciphertext to (required)") keep := flag.Bool("keep-snapshot", false, "leave the intermediate snapshot on disk for inspection") flag.Parse() if *plain == "" || *cipher == "" { flag.Usage() return fmt.Errorf("both -plain and -cipher are required") } key, err := readKey() if err != nil { return err } // VACUUM INTO rather than a WAL checkpoint on the file itself. The daemon // is usually still running and still writing when this is needed, and // checkpointing its working copy mutates a database it owns. VACUUM INTO // reads a consistent snapshot into a new file and touches nothing else, so // the worst case is a snapshot a few seconds stale instead of a torn one. snap := *plain + ".mavseal-snapshot" os.Remove(snap) if err := snapshot(*plain, snap); err != nil { return err } if !*keep { defer os.Remove(snap) } before := fileSize(*cipher) if err := store.SealPlaintext(snap, *cipher, key); err != nil { return err } log.Printf("sealed %s → %s (%d bytes, was %d)", *plain, *cipher, fileSize(*cipher), before) return nil } // readKey pulls the same base64 key the daemon reads. Fails closed: a short or // unparseable key must not silently produce a file nothing can open. func readKey() ([]byte, error) { raw := os.Getenv("MAVEN_DB_KEY") if raw == "" { return nil, fmt.Errorf("MAVEN_DB_KEY is not set") } key, err := base64.StdEncoding.DecodeString(raw) if err != nil { return nil, fmt.Errorf("MAVEN_DB_KEY is not valid base64: %w", err) } if len(key) != 32 { return nil, fmt.Errorf("MAVEN_DB_KEY decodes to %d bytes, want 32", len(key)) } return key, nil } // snapshot writes a consistent copy of src to dst with VACUUM INTO. The copy // includes everything committed to the write-ahead log, which is most of what // is worth saving on a daemon that has been up for hours. func snapshot(src, dst string) error { db, err := sql.Open("sqlite", src) if err != nil { return fmt.Errorf("open working copy: %w", err) } defer db.Close() if _, err := db.ExecContext(context.Background(), "VACUUM INTO ?", dst); err != nil { return fmt.Errorf("snapshot: %w", err) } return nil } func fileSize(path string) int64 { fi, err := os.Stat(path) if err != nil { return 0 } return fi.Size() }