Wire phased content sealing and explicit offline activation
11 files changed,
+498
−18
cmd/docs/content_maintenance.go+26 −0cmd/docs/content_maintenance_test.go+53 −0cmd/docs/main.go+8 −0internal/docs/app.go+22 −1internal/docs/content_migration.go+14 −5internal/docs/content_runtime.go+67 −0internal/docs/content_runtime_test.go+280 −0internal/docs/handlers.go+8 −0internal/docs/models.go+0 −10internal/docs/snapshots.go+10 −0internal/docs/store.go+10 −2
diff --git a/cmd/docs/content_maintenance.go b/cmd/docs/content_maintenance.go| new file mode 100644 |
| index 0000000..f9a2618 |
| --- /dev/null |
| +++ b/cmd/docs/content_maintenance.go |
| @@ -0,0 +1,26 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func runContentMaintenance(ctx context.Context, args []string, getenv func(string) string, logger *slog.Logger) error { |
| + flags := flag.NewFlagSet("docs-seal", flag.ContinueOnError) |
| + flags.SetOutput(io.Discard) |
| + path := flags.String("db", "", "") |
| + fingerprint := flags.String("expect-fingerprint", "", "") |
| + scrub := flags.Bool("scrub", false, "") |
| + if err := flags.Parse(args); err != nil { |
| + return err |
| + } |
| + if flags.NArg() != 0 || *path == "" || *fingerprint == "" { |
| + return fmt.Errorf("docs-seal: -db, -expect-fingerprint") |
| + } |
| + return docs.MaintainContent(ctx, docs.ContentMaintenanceConfig{DBPath: *path, ContentKey: getenv("DOCS_CONTENT_KEY"), ExpectedFingerprint: *fingerprint, Scrub: *scrub, Logger: logger}) |
| +} |
diff --git a/cmd/docs/content_maintenance_test.go b/cmd/docs/content_maintenance_test.go| new file mode 100644 |
| index 0000000..3cfce77 |
| --- /dev/null |
| +++ b/cmd/docs/content_maintenance_test.go |
| @@ -0,0 +1,53 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "log/slog" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +func TestContentMaintenanceCommandActivatesExistingDatabase(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + key := bytes.Repeat([]byte{91}, 32) |
| + cfg := docs.Config{DBPath: path, Env: "development", ContentKey: base64.StdEncoding.EncodeToString(key)} |
| + app, err := docs.New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + fingerprint := sha256.Sum256(key) |
| + env := func(name string) string { |
| + if name != "DOCS_CONTENT_KEY" { |
| + t.Fatalf("unexpected env: %s", name) |
| + } |
| + return cfg.ContentKey |
| + } |
| + if err := runContentMaintenance(context.Background(), []string{"-db", path, "-expect-fingerprint", hex.EncodeToString(fingerprint[:]), "-scrub"}, env, slog.Default()); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{92}, 32)) |
| + app, err = docs.New(cfg) |
| + if app != nil { |
| + app.Close() |
| + } |
| + if err == nil { |
| + t.Fatal("maintenance did not bind database to key") |
| + } |
| +} |
| + |
| +func TestContentMaintenanceCommandRequiresExplicitInputs(t *testing.T) { |
| + for _, args := range [][]string{nil, {"-db", "somewhere"}, {"-expect-fingerprint", "something"}, {"-deactivate"}, {"-db", "somewhere", "-expect-fingerprint", "something", "extra"}} { |
| + if err := runContentMaintenance(context.Background(), args, func(string) string { t.Fatal("invalid CLI reached key loading"); return "" }, slog.Default()); err == nil { |
| + t.Fatalf("accepted args: %v", args) |
| + } |
| + } |
| +} |
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index 7f68336..69cf7d6 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -135,6 +135,13 @@ func checkSeedGate(seed bool, env string) error { |
| func main() { |
| logger := slog.Default() |
| + if len(os.Args) > 1 && os.Args[1] == "docs-seal" { |
| + if err := runContentMaintenance(context.Background(), os.Args[2:], os.Getenv, logger); err != nil { |
| + logger.Error("docs: new", "err", err) |
| + os.Exit(1) |
| + } |
| + return |
| + } |
| // -env (chooseEnv above; $DOCS_ENV is consulted too, but never for |
| // "development" — see chooseEnv's own doc comment, fix round 2) |
| @@ -264,6 +271,7 @@ func main() { |
| // already owns. |
| cfg := docs.Config{ |
| DBPath: opts.DBPath, |
| + ContentKey: os.Getenv("DOCS_CONTENT_KEY"), |
| Env: env, |
| Origin: origin, |
| InstanceKey: instanceKey, |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 5308573..a5ddb45 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -33,7 +33,8 @@ import ( |
| // Config configures New. |
| type Config struct { |
| // DBPath is the SQLite file New opens via rastrillo's db package. |
| - DBPath string |
| + DBPath string |
| + ContentKey string |
| // Env is "development" or "production" ("" defaults to |
| // "production"). It gates two fail-closed defaults below — Origin |
| @@ -361,6 +362,11 @@ func New(cfg Config) (*App, error) { |
| return nil, err |
| } |
| + cipher, err := newContentCipher(cfg.ContentKey, cfg.Env) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| d, err := db.Open(cfg.DBPath, logger) |
| if err != nil { |
| return nil, fmt.Errorf("docs: open database: %w", err) |
| @@ -371,6 +377,18 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: apply migrations: %w", err) |
| } |
| + state, err := readContentSealing(context.Background(), d.G, cipher) |
| + if err == nil && state != nil { |
| + err = requireSealedContent(context.Background(), d.G) |
| + } |
| + if err != nil { |
| + d.Close() |
| + return nil, err |
| + } |
| + if state == nil { |
| + logger.Error("docs: content sealing not activated", "content_encoding", 0, "key_fingerprint", cipher.fingerprint) |
| + } |
| + |
| // One Runtime for the process (core.New compiles the wasm module |
| // once — expensive; instantiating a per-document Handle from it is |
| // not), shared by every document's Store.replay. |
| @@ -380,6 +398,9 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: start core runtime: %w", err) |
| } |
| store := NewStore(d.G, rt, parseSampleDocuments(cfg.SampleDocuments), logger) |
| + if state != nil { |
| + store.content = cipher |
| + } |
| // A grant admits its holder. Wired here rather than inside |
| // resolveAllowlist because the allowlist is resolved before the store |
diff --git a/internal/docs/content_migration.go b/internal/docs/content_migration.go| index 9115fb1..7b82759 100644 |
| --- a/internal/docs/content_migration.go |
| +++ b/internal/docs/content_migration.go |
| @@ -39,7 +39,10 @@ func readContentSealing(ctx context.Context, g *gorm.DB, c *contentCipher) (*con |
| if err != nil { |
| return nil, err |
| } |
| - if state.Encoding != contentEncoding || c == nil || state.KeyFingerprint != c.fingerprint { |
| + if c == nil || state.KeyFingerprint != c.fingerprint { |
| + return nil, errContentKeyMismatch |
| + } |
| + if state.Encoding != contentEncoding { |
| return nil, errContentState |
| } |
| plain, err := c.open(contentKeyCheckKind, uuid.Nil.String(), 0, uint32(state.Encoding), state.KeyCheck) |
| @@ -61,12 +64,21 @@ func convertContent(ctx context.Context, g *gorm.DB, c *contentCipher) error { |
| if err != nil { |
| return err |
| } |
| + var hadLegacy bool |
| + if state != nil { |
| + if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM doc_updates WHERE content_encoding = 0 LIMIT 1) OR EXISTS(SELECT 1 FROM doc_snapshots WHERE content_encoding = 0 LIMIT 1)").Scan(&hadLegacy).Error; 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 { |
| + if hadLegacy { |
| + return tx.Model(&contentSealing{}).Where("id = 1").Update("scrubbed", false).Error |
| + } |
| return nil |
| } |
| check, err := c.seal(contentKeyCheckKind, uuid.Nil.String(), 0, contentEncoding, contentKeyCheck) |
| @@ -109,10 +121,7 @@ func convertContentRows(ctx context.Context, tx *gorm.DB, c *contentCipher, snap |
| return err |
| } |
| last = row.ID |
| - if active { |
| - if row.ContentEncoding != contentEncoding { |
| - return errContentEncoding |
| - } |
| + if active && row.ContentEncoding == contentEncoding { |
| if _, err := c.open(kind, row.GUID, row.Seq, row.ABIVersion, row.Body); err != nil { |
| return err |
| } |
diff --git a/internal/docs/content_runtime.go b/internal/docs/content_runtime.go| new file mode 100644 |
| index 0000000..ce5d239 |
| --- /dev/null |
| +++ b/internal/docs/content_runtime.go |
| @@ -0,0 +1,67 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +var errContentKeyMismatch = errors.New("docs: content key does not match this database") |
| +var errContentMixed = errors.New("docs: mixed content encodings; run docs-seal offline") |
| + |
| +func requireSealedContent(ctx context.Context, g *gorm.DB) error { |
| + var mixed bool |
| + if err := g.WithContext(ctx).Raw(`SELECT EXISTS(SELECT 1 FROM doc_updates WHERE content_encoding != 1 LIMIT 1) OR EXISTS(SELECT 1 FROM doc_snapshots WHERE content_encoding != 1 LIMIT 1)`).Scan(&mixed).Error; err != nil { |
| + return err |
| + } |
| + if mixed { |
| + return errContentMixed |
| + } |
| + return nil |
| +} |
| + |
| +func refuseActivatedLegacyWrite(tx *gorm.DB) error { |
| + var count int64 |
| + if err := tx.Model(&contentSealing{}).Where("id = 1").Count(&count).Error; err != nil { |
| + return err |
| + } |
| + if count != 0 { |
| + return errContentState |
| + } |
| + return nil |
| +} |
| + |
| +func (s *Store) prepareContentUpdate(tx *gorm.DB, guid string, row *DocUpdate) error { |
| + if s.content == nil { |
| + return refuseActivatedLegacyWrite(tx) |
| + } |
| + return s.content.sealUpdate(guid, row) |
| +} |
| + |
| +func (s *Store) prepareContentSnapshot(tx *gorm.DB, guid string, row *DocSnapshot) error { |
| + if s.content == nil { |
| + return refuseActivatedLegacyWrite(tx) |
| + } |
| + return s.content.sealSnapshot(guid, row) |
| +} |
| + |
| +func (s *Store) openContentUpdate(guid string, row *DocUpdate) error { |
| + if s.content == nil { |
| + if row.ContentEncoding != 0 { |
| + return errContentEncoding |
| + } |
| + return nil |
| + } |
| + return s.content.openUpdate(guid, row) |
| +} |
| + |
| +func (s *Store) openContentSnapshot(guid string, row *DocSnapshot) error { |
| + if s.content == nil { |
| + if row.ContentEncoding != 0 { |
| + return errContentEncoding |
| + } |
| + return nil |
| + } |
| + return s.content.openSnapshot(guid, row) |
| +} |
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go| new file mode 100644 |
| index 0000000..cf4209b |
| --- /dev/null |
| +++ b/internal/docs/content_runtime_test.go |
| @@ -0,0 +1,280 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "log/slog" |
| + "net/http" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +func activatedContentApp(t *testing.T) (*App, string) { |
| + t.Helper() |
| + key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{71}, 32)) |
| + cfg := Config{DBPath: filepath.Join(t.TempDir(), "docs.db"), Env: "development", ContentKey: key} |
| + app, err := New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + cipher, err := newContentCipher(key, "production") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: cfg.DBPath, ContentKey: key, ExpectedFingerprint: cipher.fingerprint}); err != nil { |
| + t.Fatal(err) |
| + } |
| + app, err = New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { |
| + if err := app.Close(); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + return app, key |
| +} |
| + |
| +func TestActivatedContentPersistsSealedAndServesPlainUpdates(t *testing.T) { |
| + app, _ := activatedContentApp(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.Store().Create(ctx, owner, "Sealed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req := app.TestRequest(t, "owner@example.com", http.MethodPost, "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t))) |
| + if rec := serve(app, req); rec.Code != http.StatusNoContent { |
| + t.Fatalf("POST: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + var row DocUpdate |
| + if err := app.store.db.First(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.ContentEncoding != 1 || bytes.Contains(row.UpdateBin, []byte("First paragraph.")) || bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| + t.Fatal("persisted content is not sealed") |
| + } |
| + for _, suffix := range []string{"/updates", "/updates?raw=1"} { |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+suffix, nil)) |
| + if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), validUpdate(t)) { |
| + t.Fatalf("GET %s: %d", suffix, rec.Code) |
| + } |
| + } |
| + h, seq, _, err := app.store.replay(ctx, doc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Close(ctx) |
| + if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil { |
| + t.Fatal(err) |
| + } |
| + var snap DocSnapshot |
| + if err := app.store.db.First(&snap).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if snap.ContentEncoding != 1 || bytes.Contains(snap.State, []byte("First paragraph.")) { |
| + t.Fatal("snapshot is not sealed") |
| + } |
| + rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)) |
| + if rec.Code != 200 || bytes.Equal(rec.Body.Bytes(), snap.State) { |
| + t.Fatalf("snapshot GET: %d", rec.Code) |
| + } |
| + if text, err := app.store.Text(ctx, doc); err != nil || !strings.Contains(text, "First paragraph.") { |
| + t.Fatalf("sealed replay text: %q %v", text, err) |
| + } |
| + replayed, _, _, err := app.store.replay(ctx, doc) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + replayed.Close(ctx) |
| + row.UpdateBin[len(row.UpdateBin)-1] ^= 1 |
| + if err := app.store.db.Model(&row).Update("update_bin", row.UpdateBin).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + rec = serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil)) |
| + if rec.Code != 500 || strings.Contains(rec.Body.String(), "First paragraph.") || rec.Header().Get("Docs-Seq") != "" { |
| + t.Fatal("corrupt update was exposed or acknowledged") |
| + } |
| +} |
| + |
| +func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + doc, err := app.store.Create(ctx, owner, "Legacy") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h, err := app.core.NewDoc(ctx, doc.GUID, doc.ClientID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer h.Close(ctx) |
| + if err := h.Apply(ctx, validUpdate(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + cipher, err := newContentCipher("", "development") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(ctx, app.store.db, cipher); err != nil { |
| + t.Fatal(err) |
| + } |
| + if seq, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 { |
| + t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err) |
| + } |
| + update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + err = app.store.db.Transaction(func(tx *gorm.DB) error { |
| + if err := app.store.prepareContentUpdate(tx, doc.GUID, update); err != nil { |
| + return err |
| + } |
| + return tx.Create(update).Error |
| + }) |
| + if !errors.Is(err, errContentState) { |
| + t.Fatalf("legacy update: %v", err) |
| + } |
| + if err := app.store.writeSnapshot(ctx, doc, h, 1); !errors.Is(err, errContentState) { |
| + t.Fatalf("legacy snapshot: %v", err) |
| + } |
| + var count int64 |
| + if err := app.store.db.Model(&DocUpdate{}).Count(&count).Error; err != nil || count != 0 { |
| + t.Fatalf("legacy write committed: %d %v", count, err) |
| + } |
| +} |
| + |
| +func TestContentStartupRejectsMixedStateAndMaintenanceRepairsWithoutMarkerRewrite(t *testing.T) { |
| + app, key := activatedContentApp(t) |
| + ctx := context.Background() |
| + doc, err := app.store.Create(ctx, app.TestPerson(t, "owner@example.com"), "Mixed") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var before contentSealing |
| + if err := app.store.db.First(&before).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()} |
| + if err := app.store.db.Create(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate, err := New(app.cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentMixed) { |
| + t.Fatalf("mixed boot: %v", err) |
| + } |
| + if err := convertContent(ctx, app.store.db, app.store.content); err != nil { |
| + t.Fatal(err) |
| + } |
| + var after contentSealing |
| + if err := app.store.db.First(&after).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) { |
| + t.Fatal("repair rewrote activation marker") |
| + } |
| + if err := app.store.db.First(&row).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.ContentEncoding != 1 { |
| + t.Fatal("repair retained legacy row") |
| + } |
| + cfg := app.cfg |
| + cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{72}, 32)) |
| + candidate, err = New(cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentKeyMismatch) { |
| + t.Fatalf("wrong-key boot: %v", err) |
| + } |
| + cfg.ContentKey = key |
| + candidate, err = New(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate.Close() |
| + if err := app.store.db.Delete(&contentSealing{}, 1).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + candidate, err = New(cfg) |
| + if candidate != nil { |
| + candidate.Close() |
| + } |
| + if !errors.Is(err, errContentState) { |
| + t.Fatalf("missing-marker boot: %v", err) |
| + } |
| +} |
| + |
| +func TestLegacyBootDiagnosesUnactivatedContent(t *testing.T) { |
| + var logs bytes.Buffer |
| + app, err := NewForTestWith(t, Config{Logger: slog.New(slog.NewJSONHandler(&logs, nil))}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if app.store.content != nil { |
| + t.Fatal("legacy boot activated content") |
| + } |
| + if !strings.Contains(logs.String(), `"level":"ERROR"`) || !strings.Contains(logs.String(), `"content_encoding":0`) { |
| + t.Fatalf("legacy diagnosis absent: %s", logs.String()) |
| + } |
| +} |
| + |
| +func TestContentRepairFailurePreservesActivatedMarkerAndLegacyRows(t *testing.T) { |
| + app, _ := activatedContentApp(t) |
| + doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Repair") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var before contentSealing |
| + if err := app.store.db.First(&before).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + rows := []DocUpdate{{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}, {DocID: doc.ID, Seq: 2, UpdateBin: validUpdate(t), ContentEncoding: 99, CreatedAt: time.Now().UTC()}} |
| + if err := app.store.db.Create(&rows).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := convertContent(context.Background(), app.store.db, app.store.content); !errors.Is(err, errContentEncoding) { |
| + t.Fatalf("repair failure: %v", err) |
| + } |
| + var after contentSealing |
| + if err := app.store.db.First(&after).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.Encoding != after.Encoding || before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) || before.Scrubbed != after.Scrubbed { |
| + t.Fatal("failed repair changed marker") |
| + } |
| + var row DocUpdate |
| + if err := app.store.db.First(&row, rows[0].ID).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.ContentEncoding != 0 || !bytes.Equal(row.UpdateBin, validUpdate(t)) { |
| + t.Fatal("failed repair partially converted legacy row") |
| + } |
| +} |
| + |
| +func TestProductionBootRequiresContentKeyBeforeOpeningLegacyDatabase(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "docs.db") |
| + cfg := Config{DBPath: path, Env: "production", Origin: "https://docs.example.com", InstanceKey: "independent auth key", Mailer: &recordingMailer{}, AllowedAddresses: "owner@example.com"} |
| + app, err := New(cfg) |
| + if app != nil { |
| + app.Close() |
| + } |
| + if !errors.Is(err, errContentKey) { |
| + t.Fatalf("missing production content key: %v", err) |
| + } |
| +} |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 6c14a80..53061c8 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -738,6 +738,14 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| + for i := range updates { |
| + if err := h.store.openContentUpdate(doc.GUID, &updates[i]); err != nil { |
| + h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + } |
| + |
| total := 0 |
| if snapshot != nil { |
| total = len(snapshot.State) |
diff --git a/internal/docs/models.go b/internal/docs/models.go| index 17301af..6758361 100644 |
| --- a/internal/docs/models.go |
| +++ b/internal/docs/models.go |
| @@ -78,12 +78,6 @@ type Person struct { |
| // DocUpdate is one Yjs update in a document's log, applied in Seq |
| // order to rebuild the document's CRDT state (Store.replay). |
| -// |
| -// UpdateBin is plaintext in this table. Sealing it (spec §2 — per-row |
| -// AES-GCM, same as doc titles and everything else that is document |
| -// content) is out of scope for this plan; this comment exists so the |
| -// next reader does not mistake that for a decision that it stays |
| -// plaintext. |
| type DocUpdate struct { |
| ID int64 `gorm:"primaryKey"` |
| DocID int64 `gorm:"column:doc_id;not null;index"` |
| @@ -101,10 +95,6 @@ type DocUpdate struct { |
| // a snapshot that cannot be read is discarded in favour of replaying |
| // them (the snapshots design, D2). |
| // |
| -// State is plaintext in this table, exactly as DocUpdate.UpdateBin is. |
| -// Worth one caveat the sealing work should carry: a doc_updates row is a |
| -// fragment of an edit, while this is the entire document in one blob. |
| -// |
| // ABIVersion records the core that produced State. Without it, "discard |
| // a snapshot from an older ABI" is unenforceable prose. |
| type DocSnapshot struct { |
diff --git a/internal/docs/snapshots.go b/internal/docs/snapshots.go| index e632f65..3ffe465 100644 |
| --- a/internal/docs/snapshots.go |
| +++ b/internal/docs/snapshots.go |
| @@ -49,6 +49,13 @@ func (s *Store) newestSnapshot(ctx context.Context, docID int64) (*DocSnapshot, |
| if err != nil { |
| return nil, fmt.Errorf("docs: newest snapshot for document %d: %w", docID, err) |
| } |
| + var doc Document |
| + if err := s.db.WithContext(ctx).Select("guid").First(&doc, docID).Error; err != nil { |
| + return nil, err |
| + } |
| + if err := s.openContentSnapshot(doc.GUID, &row); err != nil { |
| + return nil, err |
| + } |
| return &row, nil |
| } |
| @@ -102,6 +109,9 @@ func (s *Store) writeSnapshot(ctx context.Context, doc *Document, h *core.Handle |
| CreatedAt: time.Now().UTC(), |
| } |
| return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + if err := s.prepareContentSnapshot(tx, doc.GUID, row); err != nil { |
| + return err |
| + } |
| if err := tx.Create(row).Error; err != nil { |
| return fmt.Errorf("docs: persist snapshot for document %d at seq %d: %w", doc.ID, seq, err) |
| } |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 5ef83f9..d268d4a 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -21,8 +21,9 @@ import ( |
| // the update log, and the document's actual text read back through |
| // the real CRDT core rather than any column here — see Text. |
| type Store struct { |
| - db *gorm.DB |
| - core *core.Runtime |
| + db *gorm.DB |
| + core *core.Runtime |
| + content *contentCipher |
| // sampleDocuments mirrors Config.SampleDocuments, already parsed — |
| // see EnsurePerson's own doc comment for what it gates and why the |
| @@ -451,6 +452,10 @@ func (s *Store) replay(ctx context.Context, doc *Document) (h *core.Handle, repl |
| } |
| for _, u := range updates { |
| + if err := s.openContentUpdate(doc.GUID, &u); err != nil { |
| + h.Close(ctx) |
| + return nil, 0, 0, err |
| + } |
| applyErr := h.Apply(ctx, u.UpdateBin) |
| if errors.Is(applyErr, core.ErrUpdateLogFull) { |
| // This handle has retained core.MaxRetainedUpdates updates |
| @@ -853,6 +858,9 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd |
| UpdateBin: update, |
| CreatedAt: time.Now().UTC(), |
| } |
| + if err := s.prepareContentUpdate(tx, doc.GUID, row); err != nil { |
| + return err |
| + } |
| if err := tx.Create(row).Error; err != nil { |
| return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err) |
| } |