| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | |
| 9 | "github.com/google/uuid" |
| 10 | "gorm.io/gorm" |
| 11 | ) |
| 12 | |
| 13 | var errContentState = errors.New("docs: invalid content sealing state") |
| 14 | var contentKeyCheck = []byte("oficina/docs/content-key-check/v1") |
| 15 | |
| 16 | type contentSealing struct { |
| 17 | ID int |
| 18 | Encoding int |
| 19 | KeyFingerprint string |
| 20 | KeyCheck []byte |
| 21 | Scrubbed bool |
| 22 | } |
| 23 | |
| 24 | func (contentSealing) TableName() string { return "content_sealing" } |
| 25 | |
| 26 | func readContentSealing(ctx context.Context, g *gorm.DB, c *contentCipher) (*contentSealing, error) { |
| 27 | var state contentSealing |
| 28 | err := g.WithContext(ctx).First(&state, 1).Error |
| 29 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 30 | var count int64 |
| 31 | 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 { |
| 32 | return nil, err |
| 33 | } |
| 34 | if count != 0 { |
| 35 | return nil, errContentState |
| 36 | } |
| 37 | return nil, nil |
| 38 | } |
| 39 | if err != nil { |
| 40 | return nil, err |
| 41 | } |
| 42 | if c == nil || state.KeyFingerprint != c.fingerprint { |
| 43 | return nil, errContentKeyMismatch |
| 44 | } |
| 45 | if state.Encoding != contentEncoding { |
| 46 | return nil, errContentState |
| 47 | } |
| 48 | plain, err := c.open(contentKeyCheckKind, uuid.Nil.String(), 0, uint32(state.Encoding), state.KeyCheck) |
| 49 | if err != nil || !bytes.Equal(plain, contentKeyCheck) { |
| 50 | return nil, errContentAuthentication |
| 51 | } |
| 52 | return &state, nil |
| 53 | } |
| 54 | |
| 55 | func convertContent(ctx context.Context, g *gorm.DB, c *contentCipher) error { |
| 56 | if c == nil { |
| 57 | return errContentKey |
| 58 | } |
| 59 | return g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 60 | if err := tx.Exec("PRAGMA secure_delete=ON").Error; err != nil { |
| 61 | return err |
| 62 | } |
| 63 | state, err := readContentSealing(ctx, tx, c) |
| 64 | if err != nil { |
| 65 | return err |
| 66 | } |
| 67 | var hadLegacy bool |
| 68 | if state != nil { |
| 69 | 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 { |
| 70 | return err |
| 71 | } |
| 72 | } |
| 73 | for _, snapshot := range []bool{false, true} { |
| 74 | if err := convertContentRows(ctx, tx, c, snapshot, state != nil); err != nil { |
| 75 | return err |
| 76 | } |
| 77 | } |
| 78 | if state != nil { |
| 79 | if hadLegacy { |
| 80 | return tx.Model(&contentSealing{}).Where("id = 1").Update("scrubbed", false).Error |
| 81 | } |
| 82 | return nil |
| 83 | } |
| 84 | check, err := c.seal(contentKeyCheckKind, uuid.Nil.String(), 0, contentEncoding, contentKeyCheck) |
| 85 | if err != nil { |
| 86 | return err |
| 87 | } |
| 88 | return tx.Create(&contentSealing{ID: 1, Encoding: contentEncoding, KeyFingerprint: c.fingerprint, KeyCheck: check}).Error |
| 89 | }) |
| 90 | } |
| 91 | |
| 92 | type contentMigrationRow struct { |
| 93 | ID int64 |
| 94 | DocID int64 |
| 95 | Seq int64 |
| 96 | ABIVersion uint32 |
| 97 | ContentEncoding int |
| 98 | Body []byte |
| 99 | GUID string |
| 100 | } |
| 101 | |
| 102 | func convertContentRows(ctx context.Context, tx *gorm.DB, c *contentCipher, snapshot, active bool) error { |
| 103 | table, column, abi, kind := "doc_updates", "update_bin", "0", contentUpdateKind |
| 104 | if snapshot { |
| 105 | table, column, abi, kind = "doc_snapshots", "state", "r.abi_version", contentSnapshotKind |
| 106 | } |
| 107 | last := int64(0) |
| 108 | first := true |
| 109 | for { |
| 110 | var rows []contentMigrationRow |
| 111 | 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" |
| 112 | if err := tx.WithContext(ctx).Raw(query, first, last).Scan(&rows).Error; err != nil { |
| 113 | return err |
| 114 | } |
| 115 | if len(rows) == 0 { |
| 116 | return nil |
| 117 | } |
| 118 | first = false |
| 119 | for _, row := range rows { |
| 120 | if err := ctx.Err(); err != nil { |
| 121 | return err |
| 122 | } |
| 123 | last = row.ID |
| 124 | if active && row.ContentEncoding == contentEncoding { |
| 125 | if _, err := c.open(kind, row.GUID, row.Seq, row.ABIVersion, row.Body); err != nil { |
| 126 | return err |
| 127 | } |
| 128 | continue |
| 129 | } |
| 130 | if row.ContentEncoding != 0 { |
| 131 | return errContentEncoding |
| 132 | } |
| 133 | sealed, err := c.seal(kind, row.GUID, row.Seq, row.ABIVersion, row.Body) |
| 134 | if err != nil { |
| 135 | return err |
| 136 | } |
| 137 | result := tx.Table(table).Where("id = ? AND content_encoding = 0", row.ID). |
| 138 | Updates(map[string]any{column: sealed, "content_encoding": contentEncoding}) |
| 139 | if result.Error != nil { |
| 140 | return result.Error |
| 141 | } |
| 142 | if result.RowsAffected != 1 { |
| 143 | return fmt.Errorf("%w: content row %d", errContentState, row.ID) |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | |