| new file mode 100644 |
| index 0000000..b5b7cee |
| --- /dev/null |
| +++ b/internal/provisioner/journal_linux.go |
| @@ -0,0 +1,339 @@ |
| +//go:build linux |
| + |
| +package provisioner |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "encoding/base64" |
| + "errors" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +const recordContext = "oficina/provisioner/record/v1" |
| +const payloadContext = "oficina/provisioner/private-record/v1" |
| + |
| +type journalRecord struct { |
| + Version int `json:"version"` |
| + Identity floorIdentity `json:"identity"` |
| + Installation string `json:"installation"` |
| + Sequence int64 `json:"sequence"` |
| + Previous string `json:"previous"` |
| + Operation string `json:"operation"` |
| + Action string `json:"action"` |
| + Expected string `json:"expected_state"` |
| + Created int64 `json:"created_at"` |
| + CiphertextDigest string `json:"ciphertext_digest"` |
| +} |
| +type signedRecord struct { |
| + Record journalRecord `json:"record"` |
| + Signature string `json:"signature"` |
| +} |
| +type journal struct { |
| + mu sync.Mutex |
| + locks []*os.File |
| + db *sql.DB |
| + dir *privateDirectory |
| + floor *nativeFloor |
| + key *family.Keypair |
| + now func() time.Time |
| + boundary func(string) error |
| +} |
| + |
| +func openJournal(dir *privateDirectory, floor *nativeFloor, key *family.Keypair, create bool) (*journal, error) { |
| + if dir == nil || floor == nil || key == nil || key.SignPriv == nil || key.BoxPriv == nil || !bytes.Equal(key.SignPub(), floor.public) { |
| + return nil, ErrRefused |
| + } |
| + stateInfo, e := dir.file.Stat() |
| + floorInfo, floorErr := floor.dir.file.Stat() |
| + if e != nil || floorErr != nil || os.SameFile(stateInfo, floorInfo) { |
| + return nil, ErrRefused |
| + } |
| + stateLock, e := dir.lock() |
| + if e != nil { |
| + return nil, e |
| + } |
| + floorLock, e := floor.dir.lock() |
| + if e != nil { |
| + stateLock.Close() |
| + return nil, e |
| + } |
| + keepLocks := false |
| + defer func() { |
| + if !keepLocks { |
| + floorLock.Close() |
| + stateLock.Close() |
| + } |
| + }() |
| + if create { |
| + if e := dir.create("registry.db", nil); e != nil { |
| + return nil, e |
| + } |
| + } |
| + if _, e := dir.read("registry.db", 128<<10); e != nil { |
| + // Registry size is bounded by record count and private payload limits, not |
| + // the small-file reader; inspect its type without reading all ciphertext. |
| + if e = checkRegistryFile(dir); e != nil { |
| + return nil, e |
| + } |
| + } |
| + u := url.URL{Scheme: "file", Path: filepath.Join(dir.file.Name(), "registry.db")} |
| + q := u.Query() |
| + q.Set("mode", "rw") |
| + u.RawQuery = q.Encode() |
| + db, e := sql.Open("sqlite", u.String()) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + db.SetMaxOpenConns(1) |
| + j := &journal{db: db, dir: dir, floor: floor, key: key, now: time.Now, locks: []*os.File{stateLock, floorLock}} |
| + fail := func() (*journal, error) { db.Close(); return nil, ErrUnavailable } |
| + for _, statement := range []string{"PRAGMA foreign_keys=ON", "PRAGMA secure_delete=ON", "PRAGMA synchronous=FULL", "PRAGMA busy_timeout=1000", "PRAGMA journal_mode=WAL"} { |
| + if _, e = db.Exec(statement); e != nil { |
| + return fail() |
| + } |
| + } |
| + var fk, secure, synchronous int |
| + if db.QueryRow("PRAGMA foreign_keys").Scan(&fk) != nil || db.QueryRow("PRAGMA secure_delete").Scan(&secure) != nil || db.QueryRow("PRAGMA synchronous").Scan(&synchronous) != nil || fk != 1 || secure != 1 || synchronous != 2 { |
| + return fail() |
| + } |
| + if create { |
| + point, _, e := floor.current() |
| + if e != nil || point != (floorPoint{}) { |
| + db.Close() |
| + return nil, ErrRefused |
| + } |
| + _, e = db.Exec(`BEGIN IMMEDIATE; |
| +CREATE TABLE identity(singleton INTEGER PRIMARY KEY CHECK(singleton=1), registry TEXT NOT NULL, device TEXT NOT NULL, public_key TEXT NOT NULL, sequence INTEGER NOT NULL, head TEXT NOT NULL); |
| +CREATE TABLE records(sequence INTEGER PRIMARY KEY CHECK(sequence>0), operation TEXT NOT NULL UNIQUE, signed_record BLOB NOT NULL, sealed_payload BLOB NOT NULL, state TEXT NOT NULL CHECK(state IN ('prepared','finalized'))); |
| +INSERT INTO identity VALUES(1,?,?,?,0,''); COMMIT;`, floor.identity.Registry, floor.identity.Device, floor.identity.Public) |
| + if e != nil { |
| + return fail() |
| + } |
| + if e = dir.file.Sync(); e != nil { |
| + return fail() |
| + } |
| + } |
| + if e = j.recover(); e != nil { |
| + db.Close() |
| + return nil, e |
| + } |
| + keepLocks = true |
| + return j, nil |
| +} |
| +func (j *journal) close() error { |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + err := j.db.Close() |
| + for _, lock := range j.locks { |
| + lock.Close() |
| + } |
| + j.locks = nil |
| + return err |
| +} |
| +func (j *journal) stop(stage string) error { |
| + if j.boundary != nil { |
| + return j.boundary(stage) |
| + } |
| + return nil |
| +} |
| +func (j *journal) validate(raw, ciphertext []byte) (journalRecord, error) { |
| + var signed signedRecord |
| + if len(raw) > 8192 || len(ciphertext) > 96<<10 || closedJSON(raw, &signed) != nil { |
| + return journalRecord{}, ErrRefused |
| + } |
| + r := signed.Record |
| + _, installation := tokenBytes(r.Installation, 32) |
| + _, operation := tokenBytes(r.Operation, 32) |
| + if r.Version != 1 || r.Identity != j.floor.identity || !installation || !operation || r.Sequence <= 0 || r.Sequence > maxRecords || r.Created <= 0 || r.Action == "" || len(r.Action) > 64 || len(r.Expected) > 64 || r.CiphertextDigest != hash(ciphertext) { |
| + return journalRecord{}, ErrRefused |
| + } |
| + body, e := canonicalJSON(r) |
| + sig, ok := tokenBytes(signed.Signature, 64) |
| + if e != nil || !ok || !family.Verify(j.floor.public, recordContext, body, sig) { |
| + return journalRecord{}, ErrRefused |
| + } |
| + return r, nil |
| +} |
| +func (j *journal) recover() error { |
| + var id floorIdentity |
| + var accepted floorPoint |
| + if e := j.db.QueryRow("SELECT registry,device,public_key,sequence,head FROM identity WHERE singleton=1").Scan(&id.Registry, &id.Device, &id.Public, &accepted.Sequence, &accepted.Head); e != nil { |
| + return ErrUnavailable |
| + } |
| + if id != j.floor.identity { |
| + return ErrRefused |
| + } |
| + rows, e := j.db.Query("SELECT sequence,operation,signed_record,sealed_payload,state FROM records ORDER BY sequence") |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + var latest floorPoint |
| + var pending *floorPoint |
| + for rows.Next() { |
| + var seq int64 |
| + var op, state string |
| + var raw, sealed []byte |
| + if rows.Scan(&seq, &op, &raw, &sealed, &state) != nil { |
| + rows.Close() |
| + return ErrUnavailable |
| + } |
| + r, e := j.validate(raw, sealed) |
| + if e != nil || pending != nil || seq != latest.Sequence+1 || r.Sequence != seq || r.Operation != op || r.Previous != latest.Head { |
| + rows.Close() |
| + return ErrRefused |
| + } |
| + point := floorPoint{seq, hash(raw)} |
| + if state == "prepared" { |
| + if latest != accepted { |
| + rows.Close() |
| + return ErrRefused |
| + } |
| + pending = &point |
| + } else if state != "finalized" { |
| + rows.Close() |
| + return ErrRefused |
| + } |
| + latest = point |
| + } |
| + e = rows.Err() |
| + rows.Close() |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + if pending == nil && latest != accepted { |
| + return ErrRefused |
| + } |
| + current, _, e := j.floor.current() |
| + if e != nil { |
| + return e |
| + } |
| + if pending == nil { |
| + if current != accepted { |
| + return ErrRefused |
| + } |
| + return nil |
| + } |
| + if current == accepted { |
| + if e = j.floor.advance(j.key, accepted, *pending); e != nil { |
| + return e |
| + } |
| + } else if current != *pending { |
| + return ErrRefused |
| + } |
| + return j.finalize(accepted, *pending) |
| +} |
| +func (j *journal) finalize(old, next floorPoint) error { |
| + tx, e := j.db.Begin() |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + result, e := tx.Exec("UPDATE identity SET sequence=?,head=? WHERE singleton=1 AND sequence=? AND head=?", next.Sequence, next.Head, old.Sequence, old.Head) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + n, e := result.RowsAffected() |
| + if e != nil || n != 1 { |
| + return ErrRefused |
| + } |
| + result, e = tx.Exec("UPDATE records SET state='finalized' WHERE sequence=? AND state='prepared'", next.Sequence) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + n, e = result.RowsAffected() |
| + if e != nil || n != 1 { |
| + return ErrRefused |
| + } |
| + if tx.Commit() != nil { |
| + return ErrUnavailable |
| + } |
| + if _, e = j.db.Exec("PRAGMA wal_checkpoint(FULL)"); e != nil || j.dir.file.Sync() != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| +func (j *journal) append(ctx context.Context, installation, operation, action, expected string, payload []byte) ([]byte, error) { |
| + if j == nil || len(payload) == 0 || len(payload) > 64<<10 { |
| + return nil, ErrRefused |
| + } |
| + j.mu.Lock() |
| + defer j.mu.Unlock() |
| + if len(j.locks) != 2 { |
| + return nil, ErrRefused |
| + } |
| + if e := j.recover(); e != nil { |
| + return nil, e |
| + } |
| + var oldRaw, oldSealed []byte |
| + e := j.db.QueryRowContext(ctx, "SELECT signed_record,sealed_payload FROM records WHERE operation=?", operation).Scan(&oldRaw, &oldSealed) |
| + if e == nil { |
| + r, e := j.validate(oldRaw, oldSealed) |
| + if e != nil || r.Installation != installation || r.Action != action || r.Expected != expected { |
| + return nil, ErrRefused |
| + } |
| + plain, e := family.Open(j.key, payloadContext+"/"+operation, oldSealed) |
| + if e != nil || !bytes.Equal(plain, payload) { |
| + return nil, ErrRefused |
| + } |
| + return oldRaw, nil |
| + } |
| + if !errors.Is(e, sql.ErrNoRows) { |
| + return nil, ErrUnavailable |
| + } |
| + old, _, e := j.floor.current() |
| + if e != nil { |
| + return nil, e |
| + } |
| + if old.Sequence >= maxRecords { |
| + return nil, ErrRefused |
| + } |
| + sealed, e := family.Seal(j.key.BoxPub(), payloadContext+"/"+operation, payload) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + r := journalRecord{1, j.floor.identity, installation, old.Sequence + 1, old.Head, operation, action, expected, j.now().Unix(), hash(sealed)} |
| + body, e := canonicalJSON(r) |
| + if e != nil { |
| + return nil, e |
| + } |
| + sig, e := family.Sign(j.key, recordContext, body) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + raw, e := canonicalJSON(signedRecord{r, base64.RawURLEncoding.EncodeToString(sig)}) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if _, e = j.validate(raw, sealed); e != nil { |
| + return nil, e |
| + } |
| + if _, e = j.db.ExecContext(ctx, "INSERT INTO records VALUES(?,?,?,?,'prepared')", r.Sequence, operation, raw, sealed); e != nil { |
| + return nil, ErrRefused |
| + } |
| + if e = j.stop("prepared"); e != nil { |
| + return nil, e |
| + } |
| + next := floorPoint{r.Sequence, hash(raw)} |
| + if e = j.floor.advance(j.key, old, next); e != nil { |
| + return nil, e |
| + } |
| + if e = j.stop("floor"); e != nil { |
| + return nil, e |
| + } |
| + if e = j.finalize(old, next); e != nil { |
| + return nil, e |
| + } |
| + if e = j.stop("finalized"); e != nil { |
| + return nil, e |
| + } |
| + return raw, nil |
| +} |