package operator import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "errors" "math" "amadan.net/oficina/home/internal/home/directorydata" "amadan.net/oficina/home/roster" family "amadan.net/rastrillo/rastrillo/crypto" "github.com/go-json-experiment/json/jsontext" ) type InputBase struct { Version int `json:"version"` Operation string `json:"operation"` OperationID string `json:"operation_id"` } func (b InputBase) matches(r Request) bool { return b.Version == 1 && b.Operation == r.authorization.Action && b.OperationID == r.authorization.OperationID } type InitializeInput struct { InputBase ExpectedGeneration *string `json:"expected_generation"` Generation string `json:"generation"` Baseline string `json:"baseline_attestation"` } type SeedInput struct { InputBase Generation string `json:"expected_generation"` TeamID int64 `json:"team_id"` TeamSQID string `json:"team_sqid"` Label string `json:"label"` Name string `json:"display_name"` Receipt jsontext.Value `json:"receipt"` } type ProvisionInput struct { InputBase Generation string `json:"expected_generation"` TeamSQID string `json:"team_sqid"` App string `json:"app"` Consumer string `json:"consumer"` Audience string `json:"audience"` Sequence int64 `json:"expected_sequence"` } type Result struct { Attempt string `json:"attempt_id,omitempty"` Expires int64 `json:"expires_at,omitempty"` Version int `json:"version"` OperationID string `json:"operation_id"` Outcome string `json:"outcome"` Generation string `json:"generation,omitempty"` TeamID int64 `json:"team_id,omitempty"` TeamSQID string `json:"team_sqid,omitempty"` Epoch int64 `json:"epoch,omitempty"` Record jsontext.Value `json:"record,omitempty"` Bearer string `json:"bearer,omitempty"` Receipt jsontext.Value `json:"receipt,omitempty"` } func resultFor(r Request) Result { return Result{Version: 1, OperationID: r.authorization.OperationID, Outcome: "committed"} } type Directory struct { engine *Engine owner *OwnerVerifier signer *family.Keypair keys []roster.Key names *directorydata.Store policy directorydata.Config apps map[string]bool } func NewDirectory(engine *Engine, owner *OwnerVerifier, signer *family.Keypair, keys []roster.Key, policy directorydata.Config) (*Directory, error) { if engine == nil || engine.verifier.pins.Role != "directory" || owner == nil || owner.directory != engine.verifier.pins.Origin || !signingKey(signer) || len(keys) == 0 || len(keys) > 8 { return nil, ErrRefused } names, e := directorydata.New(engine.db, engine.sealer, policy) if e != nil { return nil, ErrRefused } seen := map[string]bool{} for _, k := range [][]byte{engine.verifier.key, owner.key, signer.SignPub()} { if seen[string(k)] { return nil, ErrRefused } seen[string(k)] = true } ids := map[string]bool{} for _, k := range keys { p, e := public(k.Public) if e != nil || !atom(k.ID, 64) || ids[k.ID] || seen[string(p)] { return nil, ErrRefused } ids[k.ID] = true seen[string(p)] = true } d := &Directory{engine, owner, signer, append([]roster.Key(nil), keys...), names, policy, map[string]bool{}} d.policy.Apps = append([]string(nil), policy.Apps...) for _, app := range policy.Apps { d.apps[app] = true } return d, nil } func atom(s string, max int) bool { if len(s) == 0 || len(s) > max { return false } for _, c := range s { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { return false } } return true } func (d *Directory) Execute(ctx context.Context, raw []byte) ([]byte, error) { return d.engine.Execute(ctx, raw, func(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { switch r.authorization.Action { case "directory.initialize": return d.initialize(ctx, c, r) case "directory.seed-team": return d.seed(ctx, c, r) case "directory.provision-consumer": return d.provision(ctx, c, r) case "directory.verify-consumer": return d.verifyConsumer(ctx, c, r) case "directory.activate-consumer": return d.activate(ctx, c, r) default: return nil, ErrRefused } }) } func generation(ctx context.Context, c *sql.Conn, expected string) error { var actual string if !CanonicalToken(expected) { return ErrRefused } if e := c.QueryRowContext(ctx, "SELECT generation FROM roster_generation WHERE singleton=1").Scan(&actual); e != nil { return ErrUnavailable } if actual != expected { return ErrRefused } return nil } func (d *Directory) initialize(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { var in InitializeInput if DecodeInput(r, &in) != nil || !in.matches(r) || !r.authorization.Initial || in.ExpectedGeneration != nil || !CanonicalToken(in.Generation) || in.Baseline != "operator-attests-never-provisioned" { return nil, ErrRefused } for _, table := range []string{"roster_generation", "roster_consumers", "roster_tokens", "roster_operator_receipts"} { var n int if c.QueryRowContext(ctx, "SELECT count(*) FROM "+table).Scan(&n) != nil { return nil, ErrUnavailable } if n != 0 { return nil, ErrRefused } } if _, e := c.ExecContext(ctx, "INSERT INTO roster_generation VALUES(1,?)", in.Generation); e != nil { return nil, e } result := resultFor(r) result.Generation = in.Generation raw, e := encode(result) if e != nil { return nil, e } h := sha256.Sum256(raw) if _, e = c.ExecContext(ctx, "INSERT INTO roster_operator_receipts VALUES(?,1,'bootstrap',NULL,?,?,?)", r.authorization.OperationID, in.Generation, h[:], d.engine.now().Unix()); e != nil { return nil, e } return raw, nil } func nextTeam(ctx context.Context, c *sql.Conn) (int64, string, error) { var largest, sequence int64 if c.QueryRowContext(ctx, "SELECT coalesce(max(id),0) FROM teams").Scan(&largest) != nil { return 0, "", ErrUnavailable } e := c.QueryRowContext(ctx, "SELECT seq FROM sqlite_sequence WHERE name='teams'").Scan(&sequence) if e != nil && !errors.Is(e, sql.ErrNoRows) { return 0, "", ErrUnavailable } if sequence > largest { largest = sequence } if largest < 0 || largest == math.MaxInt64 { return 0, "", ErrRefused } id := largest + 1 s, e := TeamSQID(id) return id, s, e } func (d *Directory) seed(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { var in SeedInput if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial || !validTeam(in.TeamID, in.TeamSQID) { return nil, ErrRefused } if e := generation(ctx, c, in.Generation); e != nil { return nil, e } owner, e := d.owner.Verify(in.Receipt, d.engine.now()) if e != nil || owner.TeamID != in.TeamID || owner.TeamSQID != in.TeamSQID { return nil, ErrRefused } var used int e = c.QueryRowContext(ctx, "SELECT 1 FROM directory_operator_owner_receipts WHERE issuer=? AND owner_operation_id=?", owner.Issuer, owner.OperationID).Scan(&used) if e == nil { return nil, ErrRefused } if !errors.Is(e, sql.ErrNoRows) { return nil, ErrUnavailable } id, sqid, e := nextTeam(ctx, c) if e != nil { return nil, e } if id != in.TeamID || sqid != in.TeamSQID { return nil, ErrRefused } // The unique label belongs to the team across all sibling applications. if !directorydata.ValidLabel(in.Label) || len(in.Name) > 255 { return nil, ErrRefused } name, e := d.names.SealTeamName(id, in.Name) if e != nil { return nil, ErrRefused } var collision int e = c.QueryRowContext(ctx, "SELECT 1 FROM teams WHERE label=? OR sqid=?", in.Label, in.TeamSQID).Scan(&collision) if e == nil { return nil, ErrRefused } if !errors.Is(e, sql.ErrNoRows) { return nil, ErrUnavailable } res, e := c.ExecContext(ctx, "INSERT INTO teams(sqid,label,sealed_name,created_at) VALUES(?,?,?,?)", sqid, in.Label, name, d.engine.now().Unix()) if e != nil { return nil, e } inserted, e := res.LastInsertId() if e != nil || inserted != id { return nil, ErrUnavailable } if _, e = c.ExecContext(ctx, "INSERT INTO memberships(team_id,person_ref,role) VALUES(?,?,'owner')", id, owner.PersonRef); e != nil { return nil, e } if _, e = c.ExecContext(ctx, "INSERT INTO directory_operator_owner_receipts VALUES(?,?,?,?)", owner.Issuer, owner.OperationID, r.authorization.OperationID, id); e != nil { return nil, e } result := resultFor(r) result.Generation = in.Generation result.TeamID = id result.TeamSQID = sqid if c.QueryRowContext(ctx, "SELECT roster_epoch FROM teams WHERE id=?", id).Scan(&result.Epoch) != nil { return nil, ErrUnavailable } return encode(result) } func (d *Directory) provision(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { var in ProvisionInput if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial || in.Sequence != 0 || !d.apps[in.App] || !atom(in.Consumer, 64) || !Origin(in.Audience) { return nil, ErrRefused } if e := generation(ctx, c, in.Generation); e != nil { return nil, e } route := directorydata.Route{TeamSQID: in.TeamSQID, App: in.App, Origin: in.Audience, ConsumerID: in.Consumer} var epoch int64 if e := c.QueryRowContext(ctx, "SELECT id,label,roster_epoch FROM teams WHERE sqid=?", in.TeamSQID).Scan(&route.TeamID, &route.Label, &epoch); e != nil { if errors.Is(e, sql.ErrNoRows) { return nil, ErrRefused } return nil, ErrUnavailable } if !validTeam(route.TeamID, route.TeamSQID) || !d.policy.ValidateRoute(route) || epoch <= 0 { return nil, ErrRefused } var owner string if e := c.QueryRowContext(ctx, "SELECT person_ref FROM memberships WHERE team_id=? AND role='owner' LIMIT 1", route.TeamID).Scan(&owner); e != nil || !PersonRef(owner) { return nil, ErrRefused } var conflict int e := c.QueryRowContext(ctx, "SELECT 1 FROM team_instances WHERE (team_id=? AND app=?) OR consumer_id=? OR origin=?", route.TeamID, in.App, in.Consumer, in.Audience).Scan(&conflict) if e == nil { return nil, ErrRefused } if !errors.Is(e, sql.ErrNoRows) { return nil, ErrUnavailable } random := make([]byte, 32) if _, e = rand.Read(random); e != nil { return nil, ErrUnavailable } token := base64.RawURLEncoding.EncodeToString(random) hash := sha256.Sum256([]byte(token)) binding := roster.Bindings{Issuer: d.owner.issuer, Directory: d.owner.directory, Consumer: in.Consumer, App: in.App, Team: in.TeamSQID, Audience: in.Audience} p := roster.Provisioning{Version: 1, Operation: r.authorization.OperationID, Sequence: 1, Bindings: binding, Generation: in.Generation, MinEpoch: epoch, Keys: d.keys} record, e := roster.SignProvisioning(d.signer, keyID(d.signer.SignPub()), p) if e != nil { return nil, ErrUnavailable } if _, e = roster.VerifyProvisioning(record, []roster.Key{{ID: keyID(d.signer.SignPub()), Public: base64.RawURLEncoding.EncodeToString(d.signer.SignPub())}}, binding); e != nil { return nil, ErrUnavailable } if _, e = c.ExecContext(ctx, "INSERT INTO team_instances VALUES(?,?,?,?,'pending')", route.TeamID, in.App, in.Audience, in.Consumer); e != nil { return nil, e } if _, e = c.ExecContext(ctx, "INSERT INTO roster_consumers VALUES(?,?,?,?,1)", in.Consumer, route.TeamID, in.App, in.Audience); e != nil { return nil, e } if _, e = c.ExecContext(ctx, "INSERT INTO roster_tokens VALUES(?,?,'current',?,NULL)", hash[:], in.Consumer, d.engine.now().Unix()); e != nil { return nil, e } recordHash := sha256.Sum256(record) if _, e = c.ExecContext(ctx, "INSERT INTO roster_operator_receipts VALUES(?,1,'provision',?,?,?,?)", r.authorization.OperationID, in.Consumer, in.Generation, recordHash[:], d.engine.now().Unix()); e != nil { return nil, e } if _, e = c.ExecContext(ctx, "INSERT INTO directory_operator_consumer_records VALUES(?,?,?,?,?)", in.Consumer, r.authorization.OperationID, int64(1), in.Generation, recordHash[:]); e != nil { return nil, e } result := resultFor(r) result.Generation = in.Generation result.TeamID = route.TeamID result.TeamSQID = in.TeamSQID result.Epoch = epoch result.Record = record result.Bearer = token return encode(result) }