Prepare atomic content conversion and explicit database scrub
6 files changed,
+421
−3
internal/docs/content_migration.go+138 −0internal/docs/content_migration_test.go+169 −0internal/docs/content_rows.go+47 −0internal/docs/content_scrub.go+52 −0internal/docs/migrations/0007_content_encoding.sql+10 −0internal/docs/models.go+5 −3
diff --git a/internal/docs/content_migration.go b/internal/docs/content_migration.go| new file mode 100644 |
| index 0000000..9115fb1 |
| --- /dev/null |
| +++ b/internal/docs/content_migration.go |
| @@ -0,0 +1,138 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/google/uuid" |
| + "gorm.io/gorm" |
| +) |
| + |
| +var errContentState = errors.New("docs: invalid content sealing state") |
| +var contentKeyCheck = []byte("oficina/docs/content-key-check/v1") |
| + |
| +type contentSealing struct { |
| + ID int |
| + Encoding int |
| + KeyFingerprint string |
| + KeyCheck []byte |
| + Scrubbed bool |
| +} |
| + |
| +func (contentSealing) TableName() string { return "content_sealing" } |
| + |
| +func readContentSealing(ctx context.Context, g *gorm.DB, c *contentCipher) (*contentSealing, error) { |
| + var state contentSealing |
| + err := g.WithContext(ctx).First(&state, 1).Error |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + var count int64 |
| + if err := g.WithContext(ctx).Raw("SELECT (SELECT COUNT(*) FROM doc_updates WHERE content_encoding != 0) + (SELECT COUNT(*) FROM doc_snapshots WHERE content_encoding != 0)").Scan(&count).Error; err != nil { |
| + return nil, err |
| + } |
| + if count != 0 { |
| + return nil, errContentState |
| + } |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + if state.Encoding != contentEncoding || c == nil || state.KeyFingerprint != c.fingerprint { |
| + return nil, errContentState |
| + } |
| + plain, err := c.open(contentKeyCheckKind, uuid.Nil.String(), 0, uint32(state.Encoding), state.KeyCheck) |
| + if err != nil || !bytes.Equal(plain, contentKeyCheck) { |
| + return nil, errContentAuthentication |
| + } |
| + return &state, nil |
| +} |
| + |
| +func convertContent(ctx context.Context, g *gorm.DB, c *contentCipher) error { |
| + if c == nil { |
| + return errContentKey |
| + } |
| + return g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Exec("PRAGMA secure_delete=ON").Error; err != nil { |
| + return err |
| + } |
| + state, err := readContentSealing(ctx, tx, c) |
| + if err != nil { |
| + return err |
| + } |
| + for _, snapshot := range []bool{false, true} { |
| + if err := convertContentRows(ctx, tx, c, snapshot, state != nil); err != nil { |
| + return err |
| + } |
| + } |
| + if state != nil { |
| + return nil |
| + } |
| + check, err := c.seal(contentKeyCheckKind, uuid.Nil.String(), 0, contentEncoding, contentKeyCheck) |
| + if err != nil { |
| + return err |
| + } |
| + return tx.Create(&contentSealing{ID: 1, Encoding: contentEncoding, KeyFingerprint: c.fingerprint, KeyCheck: check}).Error |
| + }) |
| +} |
| + |
| +type contentMigrationRow struct { |
| + ID int64 |
| + DocID int64 |
| + Seq int64 |
| + ABIVersion uint32 |
| + ContentEncoding int |
| + Body []byte |
| + GUID string |
| +} |
| + |
| +func convertContentRows(ctx context.Context, tx *gorm.DB, c *contentCipher, snapshot, active bool) error { |
| + table, column, abi, kind := "doc_updates", "update_bin", "0", contentUpdateKind |
| + if snapshot { |
| + table, column, abi, kind = "doc_snapshots", "state", "r.abi_version", contentSnapshotKind |
| + } |
| + last := int64(0) |
| + first := true |
| + for { |
| + var rows []contentMigrationRow |
| + query := "SELECT r.id, r.doc_id, r.seq, " + abi + " AS abi_version, r.content_encoding, r." + column + " AS body, d.guid FROM " + table + " r LEFT JOIN documents d ON d.id=r.doc_id WHERE (? OR r.id > ?) ORDER BY r.id LIMIT 100" |
| + if err := tx.WithContext(ctx).Raw(query, first, last).Scan(&rows).Error; err != nil { |
| + return err |
| + } |
| + if len(rows) == 0 { |
| + return nil |
| + } |
| + first = false |
| + for _, row := range rows { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + last = row.ID |
| + if active { |
| + if row.ContentEncoding != contentEncoding { |
| + return errContentEncoding |
| + } |
| + if _, err := c.open(kind, row.GUID, row.Seq, row.ABIVersion, row.Body); err != nil { |
| + return err |
| + } |
| + continue |
| + } |
| + if row.ContentEncoding != 0 { |
| + return errContentEncoding |
| + } |
| + sealed, err := c.seal(kind, row.GUID, row.Seq, row.ABIVersion, row.Body) |
| + if err != nil { |
| + return err |
| + } |
| + result := tx.Table(table).Where("id = ? AND content_encoding = 0", row.ID). |
| + Updates(map[string]any{column: sealed, "content_encoding": contentEncoding}) |
| + if result.Error != nil { |
| + return result.Error |
| + } |
| + if result.RowsAffected != 1 { |
| + return fmt.Errorf("%w: content row %d", errContentState, row.ID) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/docs/content_migration_test.go b/internal/docs/content_migration_test.go| new file mode 100644 |
| index 0000000..aac080f |
| --- /dev/null |
| +++ b/internal/docs/content_migration_test.go |
| @@ -0,0 +1,169 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func contentMigrationFixture(t *testing.T) (*App, *Document, *contentCipher) { |
| + t.Helper() |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := app.Store().Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Migration") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := newContentCipher(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{53}, 32)), "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return app, doc, c |
| +} |
| + |
| +func TestContentMigrationPreservesBodiesAndIsIdempotent(t *testing.T) { |
| + app, doc, c := contentMigrationFixture(t) |
| + ctx := context.Background() |
| + update := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + snapshot := DocSnapshot{DocID: doc.ID, Seq: 1, State: validUpdate(t), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()} |
| + if err := app.db.G.Create(&update).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.db.G.Create(&snapshot).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + state, err := readContentSealing(ctx, app.db.G, c) |
| + if err != nil || state == nil || state.Scrubbed { |
| + t.Fatalf("state: %#v, %v", state, err) |
| + } |
| + if err := app.db.G.First(&update, update.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.db.G.First(&snapshot, snapshot.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + sealed := append([]byte(nil), update.UpdateBin...) |
| + if bytes.Equal(update.UpdateBin, validUpdate(t)) || bytes.Equal(snapshot.State, validUpdate(t)) { |
| + t.Fatal("migration left plaintext") |
| + } |
| + if err := c.openUpdate(doc.GUID, &update); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.openSnapshot(doc.GUID, &snapshot); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(update.UpdateBin, validUpdate(t)) || !bytes.Equal(snapshot.State, validUpdate(t)) { |
| + t.Fatal("migration changed content") |
| + } |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.db.G.First(&update, update.ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(update.UpdateBin, sealed) { |
| + t.Fatal("repeat migration rewrote encrypted content") |
| + } |
| + wrong, _ := newContentCipher("", "development") |
| + if _, err := readContentSealing(ctx, app.db.G, wrong); err == nil { |
| + t.Fatal("wrong key accepted") |
| + } |
| +} |
| + |
| +func TestContentMigrationRollsBackOnInvalidDocument(t *testing.T) { |
| + app, doc, c := contentMigrationFixture(t) |
| + rows := []DocUpdate{ |
| + {DocID: doc.ID, Seq: 1, UpdateBin: []byte("valid first row"), CreatedAt: time.Now().UTC()}, |
| + {DocID: doc.ID + 999, Seq: 1, UpdateBin: []byte("orphan second row"), CreatedAt: time.Now().UTC()}, |
| + } |
| + if err := app.db.G.Create(&rows).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(context.Background(), app.db.G, c); err == nil { |
| + t.Fatal("orphan was accepted") |
| + } |
| + var got DocUpdate |
| + if err := app.db.G.First(&got, rows[0].ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.ContentEncoding != 0 || !bytes.Equal(got.UpdateBin, rows[0].UpdateBin) { |
| + t.Fatal("failed migration changed first row") |
| + } |
| + var count int64 |
| + if err := app.db.G.Model(&contentSealing{}).Count(&count).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if count != 0 { |
| + t.Fatal("failed migration activated sealing") |
| + } |
| +} |
| + |
| +func TestContentRowsRefusePlaintextDowngrade(t *testing.T) { |
| + c, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, encoding := range []int{0, 2, -1} { |
| + if err := c.openUpdate("", &DocUpdate{ContentEncoding: encoding, UpdateBin: []byte("plaintext")}); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("update encoding %d: %v", encoding, err) |
| + } |
| + if err := c.openSnapshot("", &DocSnapshot{ContentEncoding: encoding, State: []byte("plaintext")}); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("snapshot encoding %d: %v", encoding, err) |
| + } |
| + } |
| +} |
| + |
| +func TestContentScrubRemovesLegacyBytesFromDatabaseAndWAL(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + app, err := NewForTestWith(t, Config{DBPath: path}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := app.Store().Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Scrub") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + canary := []byte("CONTENT-SCRUB-CANARY-55de8635-70b3-488a") |
| + row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: canary, CreatedAt: time.Now().UTC()} |
| + if err := app.db.G.Create(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + wal, err := os.ReadFile(path + "-wal") |
| + if err != nil || !bytes.Contains(wal, canary) { |
| + t.Fatalf("legacy WAL canary absent: %v", err) |
| + } |
| + c, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + if err := convertContent(ctx, app.db.G, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := scrubContent(ctx, app.db, c); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, filename := range []string{path, path + "-wal"} { |
| + body, err := os.ReadFile(filename) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(body, canary) { |
| + t.Fatalf("plaintext retained in %s", filename) |
| + } |
| + } |
| + state, err := readContentSealing(ctx, app.db.G, c) |
| + if err != nil || state == nil || !state.Scrubbed { |
| + t.Fatalf("scrub state: %#v, %v", state, err) |
| + } |
| +} |
diff --git a/internal/docs/content_rows.go b/internal/docs/content_rows.go| new file mode 100644 |
| index 0000000..9076e32 |
| --- /dev/null |
| +++ b/internal/docs/content_rows.go |
| @@ -0,0 +1,47 @@ |
| +package docs |
| + |
| +import "fmt" |
| + |
| +func (c *contentCipher) sealUpdate(guid string, row *DocUpdate) error { |
| + sealed, err := c.seal(contentUpdateKind, guid, row.Seq, 0, row.UpdateBin) |
| + if err != nil { |
| + return fmt.Errorf("docs: seal update %d: %w", row.ID, err) |
| + } |
| + row.UpdateBin = sealed |
| + row.ContentEncoding = contentEncoding |
| + return nil |
| +} |
| + |
| +func (c *contentCipher) openUpdate(guid string, row *DocUpdate) error { |
| + if row.ContentEncoding != contentEncoding { |
| + return errContentEncoding |
| + } |
| + plain, err := c.open(contentUpdateKind, guid, row.Seq, 0, row.UpdateBin) |
| + if err != nil { |
| + return fmt.Errorf("docs: open update %d: %w", row.ID, err) |
| + } |
| + row.UpdateBin = plain |
| + return nil |
| +} |
| + |
| +func (c *contentCipher) sealSnapshot(guid string, row *DocSnapshot) error { |
| + sealed, err := c.seal(contentSnapshotKind, guid, row.Seq, row.ABIVersion, row.State) |
| + if err != nil { |
| + return fmt.Errorf("docs: seal snapshot %d: %w", row.ID, err) |
| + } |
| + row.State = sealed |
| + row.ContentEncoding = contentEncoding |
| + return nil |
| +} |
| + |
| +func (c *contentCipher) openSnapshot(guid string, row *DocSnapshot) error { |
| + if row.ContentEncoding != contentEncoding { |
| + return errContentEncoding |
| + } |
| + plain, err := c.open(contentSnapshotKind, guid, row.Seq, row.ABIVersion, row.State) |
| + if err != nil { |
| + return fmt.Errorf("docs: open snapshot %d: %w", row.ID, err) |
| + } |
| + row.State = plain |
| + return nil |
| +} |
diff --git a/internal/docs/content_scrub.go b/internal/docs/content_scrub.go| new file mode 100644 |
| index 0000000..be113f7 |
| --- /dev/null |
| +++ b/internal/docs/content_scrub.go |
| @@ -0,0 +1,52 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "fmt" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| +) |
| + |
| +func scrubContent(ctx context.Context, d *db.DB, c *contentCipher) error { |
| + state, err := readContentSealing(ctx, d.G, c) |
| + if err != nil { |
| + return err |
| + } |
| + if state == nil { |
| + return errContentState |
| + } |
| + if err := convertContent(ctx, d.G, c); err != nil { |
| + return err |
| + } |
| + conn, err := d.Writer().Conn(ctx) |
| + if err != nil { |
| + return err |
| + } |
| + defer conn.Close() |
| + if _, err := conn.ExecContext(ctx, "UPDATE content_sealing SET scrubbed = 0 WHERE id = 1"); err != nil { |
| + return err |
| + } |
| + if err := truncateContentWAL(ctx, conn); err != nil { |
| + return err |
| + } |
| + if _, err := conn.ExecContext(ctx, "VACUUM"); err != nil { |
| + return err |
| + } |
| + if err := truncateContentWAL(ctx, conn); err != nil { |
| + return err |
| + } |
| + _, err = conn.ExecContext(ctx, "UPDATE content_sealing SET scrubbed = 1 WHERE id = 1") |
| + return err |
| +} |
| + |
| +func truncateContentWAL(ctx context.Context, conn *sql.Conn) error { |
| + var busy, frames, checkpointed int |
| + if err := conn.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &frames, &checkpointed); err != nil { |
| + return err |
| + } |
| + if busy != 0 || frames != checkpointed { |
| + return fmt.Errorf("docs: checkpoint incomplete (%d, %d, %d)", busy, frames, checkpointed) |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/migrations/0007_content_encoding.sql b/internal/docs/migrations/0007_content_encoding.sql| new file mode 100644 |
| index 0000000..8b90a14 |
| --- /dev/null |
| +++ b/internal/docs/migrations/0007_content_encoding.sql |
| @@ -0,0 +1,10 @@ |
| +ALTER TABLE doc_updates ADD COLUMN content_encoding INTEGER NOT NULL DEFAULT 0; |
| +ALTER TABLE doc_snapshots ADD COLUMN content_encoding INTEGER NOT NULL DEFAULT 0; |
| + |
| +CREATE TABLE IF NOT EXISTS content_sealing ( |
| + id INTEGER PRIMARY KEY CHECK (id = 1), |
| + encoding INTEGER NOT NULL, |
| + key_fingerprint TEXT NOT NULL, |
| + key_check BLOB NOT NULL, |
| + scrubbed INTEGER NOT NULL DEFAULT 0 |
| +); |
diff --git a/internal/docs/models.go b/internal/docs/models.go| index c95c8d6..17301af 100644 |
| --- a/internal/docs/models.go |
| +++ b/internal/docs/models.go |
| @@ -89,7 +89,8 @@ type DocUpdate struct { |
| DocID int64 `gorm:"column:doc_id;not null;index"` |
| Seq int64 `gorm:"column:seq;not null"` |
| - UpdateBin []byte `gorm:"column:update_bin;not null"` |
| + UpdateBin []byte `gorm:"column:update_bin;not null"` |
| + ContentEncoding int `gorm:"column:content_encoding;not null"` |
| CreatedAt time.Time `gorm:"column:created_at;not null"` |
| } |
| @@ -111,8 +112,9 @@ type DocSnapshot struct { |
| DocID int64 `gorm:"column:doc_id;not null;index"` |
| Seq int64 `gorm:"column:seq;not null"` |
| - State []byte `gorm:"column:state;not null"` |
| - ABIVersion uint32 `gorm:"column:abi_version;not null"` |
| + State []byte `gorm:"column:state;not null"` |
| + ContentEncoding int `gorm:"column:content_encoding;not null"` |
| + ABIVersion uint32 `gorm:"column:abi_version;not null"` |
| CreatedAt time.Time `gorm:"column:created_at;not null"` |
| } |