// Package blob stores the KDBX payloads. Each database keeps a numbered // version history, which makes a rejected push recoverable and a bad save // reversible. // // The payloads live in the server's database next to the metadata, sealed // under a key derived from the master key and named by a keyed digest of // the database's id. KDBX is already encrypted by KeePass; sealing it again // means the database holds no readable KeePass headers either, and that one // backup of the database is the whole server. package blob import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "strings" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/sqldb" ) // KeepVersions is how many historical versions of each database are retained. const KeepVersions = 20 // timeout bounds one blob operation. A payload is at most tens of megabytes. const timeout = time.Minute type Store struct { db *sqldb.DB key *secret.Keyring } // Open returns the blob store in db, sealing with key. func Open(db *sqldb.DB, key *secret.Keyring) *Store { return &Store{db: db, key: key} } // SHA256 is the lowercase hex digest used everywhere as a content fingerprint. func SHA256(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } func (s *Store) ref(dbID string) string { return s.key.MAC("blob\x00" + dbID) } // aad binds a sealed payload to its database and version, so a row copied // over another one fails to open instead of serving the wrong file. func aad(ref string, version int64) []byte { return []byte(ref + "/" + strconv.FormatInt(version, 10)) } // Put writes one version of a database. Writing a version again replaces it, // so a retried request is harmless. func (s *Store) Put(dbID string, version int64, data []byte) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() return s.put(ctx, s.db, dbID, version, data) } // PutTx is Put inside a transaction, for a version written together with // the metadata that names it: a version a concurrent writer committed can // then never be overwritten by a write whose metadata turns out stale. func (s *Store) PutTx(ctx context.Context, tx *sql.Tx, dbID string, version int64, data []byte) error { return s.put(ctx, tx, dbID, version, data) } func (s *Store) put(ctx context.Context, q sqldb.Querier, dbID string, version int64, data []byte) error { ref := s.ref(dbID) _, err := q.ExecContext(ctx, s.db.Q(`INSERT INTO keeporg_blobs (ref, version, body) VALUES (?, ?, ?) ON CONFLICT (ref, version) DO UPDATE SET body = excluded.body`), ref, version, s.key.Seal(aad(ref, version), data)) return err } // Get reads one version of a database. A version that is not stored is // fs.ErrNotExist. func (s *Store) Get(dbID string, version int64) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() ref := s.ref(dbID) var body []byte err := s.db.QueryRowContext(ctx, s.db.Q(`SELECT body FROM keeporg_blobs WHERE ref = ? AND version = ?`), ref, version).Scan(&body) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("blob %s v%d: %w", dbID, version, fs.ErrNotExist) } if err != nil { return nil, err } return s.key.Open(aad(ref, version), body) } // Versions lists the stored versions of a database, oldest first. func (s *Store) Versions(dbID string) []int64 { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() rows, err := s.db.QueryContext(ctx, s.db.Q(`SELECT version FROM keeporg_blobs WHERE ref = ? ORDER BY version`), s.ref(dbID)) if err != nil { return nil } defer rows.Close() out := []int64{} for rows.Next() { var v int64 if rows.Scan(&v) == nil { out = append(out, v) } } return out } // Prune removes all but the newest KeepVersions versions. func (s *Store) Prune(dbID string) error { vs := s.Versions(dbID) if len(vs) <= KeepVersions { return nil } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() _, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ? AND version < ?`), s.ref(dbID), vs[len(vs)-KeepVersions]) return err } // Forget deletes every version of a database. func (s *Store) Forget(dbID string) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() _, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ?`), s.ref(dbID)) return err } // ImportDir copies the blobs//.kdbx tree that servers // before 0.11 kept on disk into the store, and says how many it copied. func (s *Store) ImportDir(dir string) (int, error) { dbs, err := os.ReadDir(dir) if errors.Is(err, fs.ErrNotExist) { return 0, nil } if err != nil { return 0, err } n := 0 for _, d := range dbs { if !d.IsDir() { continue } files, err := os.ReadDir(filepath.Join(dir, d.Name())) if err != nil { return n, err } for _, f := range files { name := f.Name() if !strings.HasSuffix(name, ".kdbx") { continue } v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64) if err != nil { continue } data, err := os.ReadFile(filepath.Join(dir, d.Name(), name)) if err != nil { return n, err } if err := s.Put(d.Name(), v, data); err != nil { return n, err } n++ } } return n, nil }