//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) { return openJournalWithLocks(dir, floor, key, create, nil, false) } func openJournalWithLocks(dir *privateDirectory, floor *nativeFloor, key *family.Keypair, create bool, held []*os.File, resumeCreate 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 } var stateLock, floorLock *os.File if held == nil { stateLock, e = dir.lock() if e != nil { return nil, e } floorLock, e = floor.dir.lock() if e != nil { stateLock.Close() return nil, e } } else { if len(held) != 2 { return nil, ErrRefused } stateLock = held[0] floorLock = held[1] } keepLocks := false defer func() { if !keepLocks { floorLock.Close() stateLock.Close() } }() if create { if e := dir.create("registry.db", nil); e != nil && !resumeCreate { 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 && resumeCreate { var tables int if e = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); e != nil { return fail() } if tables != 0 { create = false } } 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 || !recordAction(r.Action) || !recordExpectation(r.Expected) || 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 recordAction(action string) bool { switch action { case "provisioner.enroll", "provisioner.in-flight", "provisioner.result", "provisioner.outcome", "credential.owner-seed", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer": return true } return false } func recordExpectation(expected string) bool { switch expected { case "absent", "unseeded", "empty", "pending", "live", "prepared", "in-flight", "outcome": return true } return false } 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, CASE WHEN length(operation)=43 THEN operation ELSE '' END, CASE WHEN typeof(signed_record)='blob' AND length(signed_record)<=8192 THEN signed_record ELSE NULL END, CASE WHEN typeof(sealed_payload)='blob' AND length(sealed_payload)<=98304 THEN sealed_payload ELSE NULL END, CASE WHEN length(state)<=9 THEN state ELSE '' END 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() return j.appendLocked(ctx, installation, operation, action, expected, payload) } func (j *journal) appendLocked(ctx context.Context, installation, operation, action, expected string, payload []byte) ([]byte, error) { return j.appendAtLocked(ctx, installation, operation, action, expected, payload, j.now().Unix()) } func (j *journal) appendAtLocked(ctx context.Context, installation, operation, action, expected string, payload []byte, created int64) ([]byte, error) { if j == nil || len(payload) == 0 || len(payload) > 64<<10 { return nil, ErrRefused } 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 CASE WHEN typeof(signed_record)='blob' AND length(signed_record)<=8192 THEN signed_record ELSE NULL END, CASE WHEN typeof(sealed_payload)='blob' AND length(sealed_payload)<=98304 THEN sealed_payload ELSE NULL END 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, created, 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 }