Implement atomic credential owner receipts and directory provisioning
12 files changed,
+924
−12
internal/home/directorydata/store.go+4 −0internal/home/links.go+12 −2internal/home/operator_person.go+40 −0internal/home/operator_person_test.go+178 −0internal/operator/credential.go+74 −0internal/operator/directory.go+333 −0internal/operator/directory_test.go+258 −0internal/operator/engine.go+8 −8internal/operator/engine_test.go+6 −2internal/operator/migrations/credential/migrations/0002_operator_key_identity.sql+2 −0internal/operator/migrations/directory/migrations/0002_owner_receipts.sql+7 −0internal/operator/migrations/directory/migrations/0003_operator_key_identity.sql+2 −0
CI passed — run details
diff --git a/internal/home/directorydata/store.go b/internal/home/directorydata/store.go| index c7bb31b..e32af2d 100644 |
| --- a/internal/home/directorydata/store.go |
| +++ b/internal/home/directorydata/store.go |
| @@ -194,6 +194,10 @@ func validSubject(s string) bool { |
| } |
| return true |
| } |
| + |
| +// ValidLabel checks the shared DNS-label shape; reservation is a writer transaction. |
| +func ValidLabel(s string) bool { return validLabel(s) } |
| + |
| func validLabel(s string) bool { |
| if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' { |
| return false |
diff --git a/internal/home/links.go b/internal/home/links.go| index b2ec1fc..48d300e 100644 |
| --- a/internal/home/links.go |
| +++ b/internal/home/links.go |
| @@ -144,17 +144,27 @@ func (s *linkStore) sweep(ctx context.Context, now time.Time) error { |
| // no row for a conflict that does nothing, so writing the index back to |
| // itself is what makes the existing ref come out. |
| func (a *App) findOrMintPerson(ctx context.Context, address string) (string, error) { |
| + return findOrMintPersonOn(ctx, a.db.Writer(), a.secrets.Pepper, address, time.Now()) |
| +} |
| + |
| +type personQueryer interface { |
| + QueryRowContext(context.Context, string, ...any) *sql.Row |
| +} |
| + |
| +// The operator path supplies its locked connection so person creation and the |
| +// owner receipt either commit together or both roll back. |
| +func findOrMintPersonOn(ctx context.Context, q personQueryer, pepper []byte, address string, now time.Time) (string, error) { |
| ref, err := NewPersonRef() |
| if err != nil { |
| return "", err |
| } |
| var got string |
| - err = a.db.Writer().QueryRowContext(ctx, |
| + err = q.QueryRowContext(ctx, |
| `INSERT INTO people (person_ref, blind_index, created_at) |
| VALUES (?, ?, ?) |
| ON CONFLICT(blind_index) DO UPDATE SET blind_index = excluded.blind_index |
| RETURNING person_ref`, |
| - ref, blindIndex(a.secrets.Pepper, address), time.Now().UTC().Format(dayFormat)). |
| + ref, blindIndex(pepper, address), now.UTC().Format(dayFormat)). |
| Scan(&got) |
| if err != nil { |
| return "", err |
diff --git a/internal/home/operator_person.go b/internal/home/operator_person.go| new file mode 100644 |
| index 0000000..209f6a7 |
| --- /dev/null |
| +++ b/internal/home/operator_person.go |
| @@ -0,0 +1,40 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/operator" |
| +) |
| + |
| +// NewOperatorPersonResolver supplies identity resolution, never invocation |
| +// authority. Only the signed operator transaction may call this adapter. |
| +func NewOperatorPersonResolver(pepper []byte) (operator.PersonResolver, error) { |
| + if len(pepper) != 32 { |
| + return nil, operator.ErrRefused |
| + } |
| + key := append([]byte(nil), pepper...) |
| + return func(ctx context.Context, conn *sql.Conn, address string, existingOnly bool, now time.Time) (string, bool, error) { |
| + if conn == nil || now.Unix() <= 0 { |
| + return "", false, operator.ErrRefused |
| + } |
| + if existingOnly { |
| + var ref string |
| + e := conn.QueryRowContext(ctx, "SELECT person_ref FROM people WHERE blind_index=?", blindIndex(key, address)).Scan(&ref) |
| + if errors.Is(e, sql.ErrNoRows) { |
| + return "", false, nil |
| + } |
| + if e != nil { |
| + return "", false, operator.ErrUnavailable |
| + } |
| + return ref, true, nil |
| + } |
| + ref, e := findOrMintPersonOn(ctx, conn, key, address, now) |
| + if e != nil { |
| + return "", false, operator.ErrUnavailable |
| + } |
| + return ref, true, nil |
| + }, nil |
| +} |
diff --git a/internal/home/operator_person_test.go b/internal/home/operator_person_test.go| new file mode 100644 |
| index 0000000..fdd6cea |
| --- /dev/null |
| +++ b/internal/home/operator_person_test.go |
| @@ -0,0 +1,178 @@ |
| +package home |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "encoding/base64" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| + "amadan.net/oficina/home/internal/operator" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +func opToken(b byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) } |
| + |
| +type failedOperatorSeal struct{} |
| + |
| +func (failedOperatorSeal) Seal(string, []byte) ([]byte, error) { |
| + return nil, errors.New("seal unavailable") |
| +} |
| +func (failedOperatorSeal) Open(string, []byte) ([]byte, error) { |
| + return nil, errors.New("seal unavailable") |
| +} |
| +func TestOperatorSeedUsesNormalIdentityAndAtomicPrivateReceipt(t *testing.T) { |
| + ctx := context.Background() |
| + path := filepath.Join(t.TempDir(), "credential.db") |
| + handle, e := db.Open(path, nil) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer handle.Close() |
| + if _, e = migrate.Apply(ctx, handle, migrate.Merge(BootSchema, operator.CredentialSchema)); e != nil { |
| + t.Fatal(e) |
| + } |
| + provisioner, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + owner, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + browser, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + pins := operator.Pins{Role: "credential", Origin: "https://id.oficina.works", Installation: opToken(1), Registry: opToken(2), Device: opToken(3), PublicKey: base64.RawURLEncoding.EncodeToString(provisioner.SignPub()), Actions: []string{"credential.owner-seed"}} |
| + verifier, e := operator.NewVerifier(pins) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + sealer, e := seal.New(bytes.Repeat([]byte{4}, 32)) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + now := time.Unix(2000000000, 0) |
| + clock := func() time.Time { return now } |
| + mac := bytes.Repeat([]byte{5}, 32) |
| + engine, e := operator.NewEngine(handle.Writer(), verifier, sealer, mac, clock) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + pepper := bytes.Repeat([]byte{6}, 32) |
| + resolve, e := NewOperatorPersonResolver(pepper) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + credential, e := operator.NewCredential(engine, "https://oficina.works", owner, [][]byte{browser.SignPub()}, resolve) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + sequence := int64(0) |
| + head := "" |
| + attempt := byte(10) |
| + request := func(address string, existing bool) []byte { |
| + t.Helper() |
| + a := operator.Authorization{Version: 1, Purpose: "operator-input", Role: pins.Role, Origin: pins.Origin, Installation: pins.Installation, Registry: pins.Registry, Device: pins.Device, Sequence: sequence + 1, Initial: sequence == 0, Previous: head, JournalSequence: int64(attempt), OperationID: opToken(attempt), Action: "credential.owner-seed", Issued: now.Unix(), Expires: now.Unix() + 300} |
| + attempt++ |
| + input := operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: a.Action, OperationID: a.OperationID}, Directory: "https://oficina.works", TeamID: 1, TeamSQID: "Uk", Address: address, ExistingOnly: existing} |
| + raw, e := operator.SignRequest(provisioner, a, input) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return raw |
| + } |
| + accept := func(raw []byte) operator.Result { |
| + t.Helper() |
| + out, e := credential.Execute(ctx, raw) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var result operator.Result |
| + if json.Unmarshal(out, &result) != nil || len(result.Receipt) == 0 { |
| + t.Fatal("missing receipt") |
| + } |
| + r, e := verifier.Verify(raw) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + sequence = r.Authorization().Sequence |
| + head = r.Head() |
| + return result |
| + } |
| + address := "Synthetic-Owner@Example.test" |
| + first := accept(request(address, false)) |
| + ov, e := operator.NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(owner.SignPub()), pins.Origin, "https://oficina.works") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + receipt, e := ov.Verify(first.Receipt, now) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var person, index, created string |
| + if e = handle.Writer().QueryRow("SELECT person_ref,blind_index,created_at FROM people").Scan(&person, &index, &created); e != nil || person != receipt.PersonRef || index != blindIndex(pepper, address) || created != now.UTC().Format(dayFormat) { |
| + t.Fatal("identity changed", e) |
| + } |
| + second := accept(request("synthetic-owner@example.test", true)) |
| + again, e := ov.Verify(second.Receipt, now) |
| + if e != nil || again.PersonRef != person { |
| + t.Fatal("existing identity changed", e) |
| + } |
| + if _, e = credential.Execute(ctx, request("absent-owner@example.test", true)); e != operator.ErrRefused { |
| + t.Fatal("missing existing owner accepted", e) |
| + } |
| + failedEngine, e := operator.NewEngine(handle.Writer(), verifier, failedOperatorSeal{}, mac, clock) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + failed, e := operator.NewCredential(failedEngine, "https://oficina.works", owner, [][]byte{browser.SignPub()}, resolve) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = failed.Execute(ctx, request("rollback-owner@example.test", false)); e != operator.ErrUnavailable { |
| + t.Fatal("seal failure", e) |
| + } |
| + var count int |
| + if e = handle.Writer().QueryRow("SELECT count(*) FROM people").Scan(&count); e != nil || count != 1 { |
| + t.Fatal("orphan person", count, e) |
| + } |
| + if e = handle.Writer().QueryRow("SELECT count(*) FROM credential_operator_results").Scan(&count); e != nil || count != 2 { |
| + t.Fatal("partial result", count, e) |
| + } |
| + for _, suffix := range []string{"", "-wal"} { |
| + disk, e := os.ReadFile(path + suffix) |
| + if e != nil && !errors.Is(e, os.ErrNotExist) { |
| + t.Fatal(e) |
| + } |
| + for _, value := range []string{address, "synthetic-owner@example.test", "absent-owner@example.test", "rollback-owner@example.test", string(first.Receipt)} { |
| + if bytes.Contains(disk, []byte(value)) { |
| + t.Fatal("operator private input/result at rest") |
| + } |
| + } |
| + } |
| + var rows *sql.Rows |
| + rows, e = handle.Writer().Query("SELECT name FROM sqlite_master WHERE type='table'") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + defer rows.Close() |
| + for rows.Next() { |
| + var name string |
| + if rows.Scan(&name) != nil { |
| + t.Fatal("schema read") |
| + } |
| + if name == "teams" || name == "memberships" { |
| + t.Fatal("directory schema on credential role") |
| + } |
| + } |
| +} |
diff --git a/internal/operator/credential.go b/internal/operator/credential.go| new file mode 100644 |
| index 0000000..d76249f |
| --- /dev/null |
| +++ b/internal/operator/credential.go |
| @@ -0,0 +1,74 @@ |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "encoding/base64" |
| + "net/mail" |
| + "time" |
| + "unicode/utf8" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +// PersonResolver uses the supplied transaction connection and the credential |
| +// role's normal blind index. False means existing-only resolution found nobody. |
| +type PersonResolver func(context.Context, *sql.Conn, string, bool, time.Time) (string, bool, error) |
| + |
| +type OwnerInput struct { |
| + InputBase |
| + Directory string `json:"directory"` |
| + TeamID int64 `json:"team_id"` |
| + TeamSQID string `json:"team_sqid"` |
| + Address string `json:"address"` |
| + ExistingOnly bool `json:"existing_only"` |
| +} |
| +type Credential struct { |
| + engine *Engine |
| + directory string |
| + signer *family.Keypair |
| + resolve PersonResolver |
| +} |
| + |
| +func NewCredential(engine *Engine, directory string, signer *family.Keypair, browserPublicKeys [][]byte, resolve PersonResolver) (*Credential, error) { |
| + if engine == nil || engine.verifier.pins.Role != "credential" || !Origin(directory) || directory == engine.verifier.pins.Origin || !signingKey(signer) || resolve == nil || len(browserPublicKeys) == 0 || len(browserPublicKeys) > 8 || bytes.Equal(signer.SignPub(), engine.verifier.key) { |
| + return nil, ErrRefused |
| + } |
| + for _, key := range browserPublicKeys { |
| + if _, e := public(base64.RawURLEncoding.EncodeToString(key)); e != nil { |
| + return nil, ErrRefused |
| + } |
| + if bytes.Equal(key, signer.SignPub()) || bytes.Equal(key, engine.verifier.key) { |
| + return nil, ErrRefused |
| + } |
| + } |
| + return &Credential{engine, directory, signer, resolve}, nil |
| +} |
| +func (c *Credential) Execute(ctx context.Context, raw []byte) ([]byte, error) { |
| + return c.engine.Execute(ctx, raw, func(ctx context.Context, conn *sql.Conn, r Request) ([]byte, error) { |
| + var in OwnerInput |
| + if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Action != "credential.owner-seed" || in.Directory != c.directory || !validTeam(in.TeamID, in.TeamSQID) || len(in.Address) == 0 || len(in.Address) > 320 || !utf8.ValidString(in.Address) { |
| + return nil, ErrRefused |
| + } |
| + address, e := mail.ParseAddress(in.Address) |
| + if e != nil || address.Address != in.Address || address.Name != "" { |
| + return nil, ErrRefused |
| + } |
| + now := c.engine.now() |
| + subject, found, e := c.resolve(ctx, conn, in.Address, in.ExistingOnly, now) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + if !found || !PersonRef(subject) { |
| + return nil, ErrRefused |
| + } |
| + receipt, e := SignOwner(c.signer, OwnerReceipt{1, "owner-seed", r.authorization.OperationID, r.authorization.Origin, c.directory, in.TeamID, in.TeamSQID, subject, "owner", now.Unix(), now.Unix() + 300}) |
| + if e != nil { |
| + return nil, e |
| + } |
| + result := resultFor(r) |
| + result.Receipt = receipt |
| + return encode(result) |
| + }) |
| +} |
diff --git a/internal/operator/directory.go b/internal/operator/directory.go| new file mode 100644 |
| index 0000000..de80ebb |
| --- /dev/null |
| +++ b/internal/operator/directory.go |
| @@ -0,0 +1,333 @@ |
| +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 { |
| + 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) |
| + 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) |
| +} |
diff --git a/internal/operator/directory_test.go b/internal/operator/directory_test.go| new file mode 100644 |
| index 0000000..6fb407e |
| --- /dev/null |
| +++ b/internal/operator/directory_test.go |
| @@ -0,0 +1,258 @@ |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "database/sql" |
| + "encoding/base64" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +type directoryFixture struct { |
| + engineFixture |
| + directory *Directory |
| + ownerKey *family.Keypair |
| + attempt byte |
| +} |
| + |
| +func newDirectoryFixture(t *testing.T) *directoryFixture { |
| + t.Helper() |
| + f := newEngineFixture(t) |
| + p := f.engine.verifier.pins |
| + p.Actions = []string{"directory.initialize", "directory.seed-team", "directory.provision-consumer"} |
| + v, e := NewVerifier(p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.engine.verifier = v |
| + owner, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + signer, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + pageKey, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + ov, e := NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(owner.SignPub()), "https://id.oficina.works", p.Origin) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + d, e := NewDirectory(f.engine, ov, signer, []roster.Key{{ID: "page1", Public: base64.RawURLEncoding.EncodeToString(pageKey.SignPub())}}, directorydata.Config{Apps: []string{"docs", "sheets"}, ValidateRoute: func(r directorydata.Route) bool { return r.Origin == "https://"+r.Label+"."+r.App+".oficina.works" }}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return &directoryFixture{f, d, owner, 20} |
| +} |
| +func (f *directoryFixture) input(t *testing.T, action string, build func(InputBase) any) []byte { |
| + t.Helper() |
| + a := f.auth |
| + var sequence int64 |
| + var head string |
| + e := f.db.QueryRow("SELECT sequence,head FROM directory_operator_state WHERE singleton=1").Scan(&sequence, &head) |
| + if e != nil && !errors.Is(e, sql.ErrNoRows) { |
| + t.Fatal(e) |
| + } |
| + a.Sequence = sequence + 1 |
| + a.Previous = head |
| + a.Initial = a.Sequence == 1 |
| + a.JournalSequence = int64(f.attempt) |
| + a.Action = action |
| + a.OperationID = token(f.attempt) |
| + f.attempt++ |
| + a.Issued = f.now.Unix() |
| + a.Expires = a.Issued + 300 |
| + raw, e := SignRequest(f.key, a, build(InputBase{1, action, a.OperationID})) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return raw |
| +} |
| +func (f *directoryFixture) initialize(t *testing.T) { |
| + t.Helper() |
| + raw := f.input(t, "directory.initialize", func(b InputBase) any { return InitializeInput{b, nil, token(10), "operator-attests-never-provisioned"} }) |
| + if _, e := f.directory.Execute(context.Background(), raw); e != nil { |
| + t.Fatal(e) |
| + } |
| +} |
| +func (f *directoryFixture) receipt(t *testing.T, id int64) []byte { |
| + t.Helper() |
| + sqid, e := TeamSQID(id) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + p := OwnerReceipt{1, "owner-seed", token(byte(id) + 100), f.directory.owner.issuer, f.directory.owner.directory, id, sqid, "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", "owner", f.now.Unix(), f.now.Unix() + 300} |
| + raw, e := SignOwner(f.ownerKey, p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return raw |
| +} |
| +func (f *directoryFixture) seed(t *testing.T, id int64, label string) ([]byte, error) { |
| + t.Helper() |
| + sqid, _ := TeamSQID(id) |
| + proof := f.receipt(t, id) |
| + raw := f.input(t, "directory.seed-team", func(b InputBase) any { |
| + return SeedInput{b, token(10), id, sqid, label, "PRIVATE-TEAM-NAME-CANARY", proof} |
| + }) |
| + out, e := f.directory.Execute(context.Background(), raw) |
| + if e != nil { |
| + return raw, e |
| + } |
| + var result Result |
| + if json.Unmarshal(out, &result) != nil || result.Epoch != 2 || result.TeamID != id { |
| + t.Fatal("wrong seed result") |
| + } |
| + return raw, nil |
| +} |
| +func TestDirectoryInitializeOwnerAndPendingConsumerAreAtomic(t *testing.T) { |
| + f := newDirectoryFixture(t) |
| + f.initialize(t) |
| + seedRaw, e := f.seed(t, 1, "alpha") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.directory.Execute(context.Background(), seedRaw); e != nil { |
| + t.Fatal("seed replay", e) |
| + } |
| + if countRows(t, f.db, "teams") != 1 || countRows(t, f.db, "memberships") != 1 || countRows(t, f.db, "directory_operator_owner_receipts") != 1 { |
| + t.Fatal("duplicate owner/team") |
| + } |
| + raw := f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| + return ProvisionInput{b, token(10), "Uk", "docs", "alpha-docs", "https://alpha.docs.oficina.works", 0} |
| + }) |
| + output, e := f.directory.Execute(context.Background(), raw) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var result Result |
| + if json.Unmarshal(output, &result) != nil || !CanonicalToken(result.Bearer) || len(result.Record) == 0 { |
| + t.Fatal("missing private provision result") |
| + } |
| + repeated, e := f.directory.Execute(context.Background(), raw) |
| + if e != nil || !bytes.Equal(output, repeated) { |
| + t.Fatal("token changed on replay", e) |
| + } |
| + binding := roster.Bindings{Issuer: f.directory.owner.issuer, Directory: f.directory.owner.directory, Consumer: "alpha-docs", App: "docs", Team: "Uk", Audience: "https://alpha.docs.oficina.works"} |
| + if _, e = roster.VerifyProvisioning(result.Record, []roster.Key{{ID: keyID(f.directory.signer.SignPub()), Public: base64.RawURLEncoding.EncodeToString(f.directory.signer.SignPub())}}, binding); e != nil { |
| + t.Fatal(e) |
| + } |
| + var state string |
| + var pointerSequence int64 |
| + var pd, rd []byte |
| + if e = f.db.QueryRow("SELECT i.state,p.sequence,p.record_digest,r.record_digest FROM team_instances i JOIN directory_operator_consumer_records p ON p.consumer_id=i.consumer_id JOIN roster_operator_receipts r ON r.operation_id=p.operation_id").Scan(&state, &pointerSequence, &pd, &rd); e != nil || state != "pending" || pointerSequence != 1 || !bytes.Equal(pd, rd) { |
| + t.Fatal("wrong current pointer", e) |
| + } |
| + src, e := directorydata.NewRosterSource(f.db, f.engine.sealer, f.directory.policy, f.directory.owner.directory) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + _, status, e := src.Read(context.Background(), directorydata.RosterRequest{Token: result.Bearer, Team: "Uk", Fetch: token(80)}, *f.now) |
| + if status != 503 || e != directorydata.ErrUnavailable { |
| + t.Fatal("pending standing leaked", status, e) |
| + } |
| + for _, suffix := range []string{"", "-wal"} { |
| + disk, e := os.ReadFile(f.path + suffix) |
| + if e != nil && !errors.Is(e, os.ErrNotExist) { |
| + t.Fatal(e) |
| + } |
| + for _, secret := range []string{"PRIVATE-TEAM-NAME-CANARY", result.Bearer} { |
| + if bytes.Contains(disk, []byte(secret)) { |
| + t.Fatal("unsealed private field") |
| + } |
| + } |
| + } |
| +} |
| +func TestDirectoryRefusesChangedGenerationProposalLabelAndOwnerProof(t *testing.T) { |
| + f := newDirectoryFixture(t) |
| + f.initialize(t) |
| + if _, e := f.seed(t, 1, "alpha"); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := f.seed(t, 1, "beta"); e != ErrRefused { |
| + t.Fatal("old owner receipt reused", e) |
| + } |
| + if _, e := f.seed(t, 2, "alpha"); e != ErrRefused { |
| + t.Fatal("suite label collision", e) |
| + } |
| + if _, e := f.seed(t, 3, "gamma"); e != ErrRefused { |
| + t.Fatal("raced integer proposal", e) |
| + } |
| + if _, e := f.seed(t, 2, "alpha.beta"); e != ErrRefused { |
| + t.Fatal("invalid label", e) |
| + } |
| + raw := f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| + return ProvisionInput{b, token(11), "Uk", "docs", "alpha-docs", "https://alpha.docs.oficina.works", 0} |
| + }) |
| + if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| + t.Fatal("generation mismatch", e) |
| + } |
| + raw = f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| + return ProvisionInput{b, token(10), "Uk", "docs", "alpha-docs", "https://other.docs.oficina.works", 0} |
| + }) |
| + if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| + t.Fatal("host mismatch", e) |
| + } |
| + if countRows(t, f.db, "teams") != 1 || countRows(t, f.db, "team_instances") != 0 { |
| + t.Fatal("failed seed/provision mutated") |
| + } |
| +} |
| +func TestDirectoryWillNotInitializeFromEmptyTablesWithoutSignedBaseline(t *testing.T) { |
| + f := newDirectoryFixture(t) |
| + raw := f.input(t, "directory.initialize", func(b InputBase) any { return InitializeInput{b, nil, token(10), ""} }) |
| + if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| + t.Fatal("missing baseline", e) |
| + } |
| + if countRows(t, f.db, "roster_generation") != 0 || countRows(t, f.db, "directory_operator_state") != 0 { |
| + t.Fatal("invented initial authority") |
| + } |
| + f.initialize(t) |
| + raw = f.input(t, "directory.initialize", func(b InputBase) any { return InitializeInput{b, nil, token(11), "operator-attests-never-provisioned"} }) |
| + if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| + t.Fatal("generation reset", e) |
| + } |
| +} |
| +func TestEngineOperatorKeyPinCannotSilentlyRotate(t *testing.T) { |
| + f := newEngineFixture(t) |
| + if _, e := f.engine.Execute(context.Background(), f.raw, insertOne); e != nil { |
| + t.Fatal(e) |
| + } |
| + old, _ := f.engine.verifier.Verify(f.raw) |
| + k, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + p := f.engine.verifier.pins |
| + p.PublicKey = base64.RawURLEncoding.EncodeToString(k.SignPub()) |
| + v, e := NewVerifier(p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.engine.verifier = v |
| + a := f.auth |
| + a.Sequence = 2 |
| + a.Initial = false |
| + a.Previous = old.Head() |
| + a.OperationID = token(55) |
| + a.JournalSequence = 2 |
| + raw, e := SignRequest(k, a, struct{}{}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = f.engine.Execute(context.Background(), raw, func(context.Context, *sql.Conn, Request) ([]byte, error) { |
| + t.Fatal("rotated key reached mutation") |
| + return nil, nil |
| + }); e != ErrRefused { |
| + t.Fatal("key changed", e) |
| + } |
| +} |
diff --git a/internal/operator/engine.go b/internal/operator/engine.go| index a88296f..7311358 100644 |
| --- a/internal/operator/engine.go |
| +++ b/internal/operator/engine.go |
| @@ -114,21 +114,21 @@ func (e *Engine) Execute(ctx context.Context, raw []byte, mutate Mutation) ([]by |
| } |
| } |
| }() |
| - var storedOrigin, storedInstallation, storedRegistry, storedDevice, storedHead string |
| + var storedOrigin, storedInstallation, storedRegistry, storedDevice, storedHead, storedKey string |
| var storedSequence, storedCompleted int64 |
| - stateErr := conn.QueryRowContext(ctx, "SELECT origin,installation,registry,device,sequence,head,completed_at FROM "+e.prefix+"_state WHERE singleton=1").Scan(&storedOrigin, &storedInstallation, &storedRegistry, &storedDevice, &storedSequence, &storedHead, &storedCompleted) |
| + stateErr := conn.QueryRowContext(ctx, "SELECT origin,installation,registry,device,sequence,head,completed_at,key_id FROM "+e.prefix+"_state WHERE singleton=1").Scan(&storedOrigin, &storedInstallation, &storedRegistry, &storedDevice, &storedSequence, &storedHead, &storedCompleted, &storedKey) |
| if stateErr != nil && !errors.Is(stateErr, sql.ErrNoRows) { |
| return nil, ErrUnavailable |
| } |
| - if stateErr == nil && (storedOrigin != a.Origin || storedInstallation != a.Installation || storedRegistry != a.Registry || storedDevice != a.Device || storedSequence <= 0 || !CanonicalToken(storedHead) || storedCompleted <= 0 || e.now().Unix() < storedCompleted) { |
| + if stateErr == nil && (storedOrigin != a.Origin || storedInstallation != a.Installation || storedRegistry != a.Registry || storedDevice != a.Device || storedKey != e.verifier.kid || storedSequence <= 0 || !CanonicalToken(storedHead) || storedCompleted <= 0 || e.now().Unix() < storedCompleted) { |
| return nil, ErrRefused |
| } |
| - var oldAction, oldHead string |
| + var oldAction, oldHead, oldOperator string |
| var oldSequence int64 |
| var oldMAC, sealed []byte |
| - err = conn.QueryRowContext(ctx, "SELECT action,sequence,authorization_head,request_mac,sealed_result FROM "+e.prefix+"_results WHERE operation_id=?", a.OperationID).Scan(&oldAction, &oldSequence, &oldHead, &oldMAC, &sealed) |
| + err = conn.QueryRowContext(ctx, "SELECT action,sequence,authorization_head,request_mac,sealed_result,operator_ref FROM "+e.prefix+"_results WHERE operation_id=?", a.OperationID).Scan(&oldAction, &oldSequence, &oldHead, &oldMAC, &sealed, &oldOperator) |
| if err == nil { |
| - if stateErr != nil || storedSequence < a.Sequence || oldAction != a.Action || oldSequence != a.Sequence || oldHead != req.head || !hmac.Equal(oldMAC, e.mac(req)) { |
| + if stateErr != nil || storedSequence < a.Sequence || oldOperator != a.Device || oldAction != a.Action || oldSequence != a.Sequence || oldHead != req.head || !hmac.Equal(oldMAC, e.mac(req)) { |
| return nil, ErrRefused |
| } |
| result, openErr := e.sealer.Open(e.purpose(a), sealed) |
| @@ -184,11 +184,11 @@ func (e *Engine) Execute(ctx context.Context, raw []byte, mutate Mutation) ([]by |
| if err != nil { |
| return nil, ErrUnavailable |
| } |
| - _, err = conn.ExecContext(ctx, "INSERT INTO "+e.prefix+"_results(operation_id,action,sequence,authorization_head,request_mac,sealed_result,completed_at) VALUES(?,?,?,?,?,?,?)", a.OperationID, a.Action, a.Sequence, req.head, e.mac(req), sealed, completed.Unix()) |
| + _, err = conn.ExecContext(ctx, "INSERT INTO "+e.prefix+"_results(operation_id,action,sequence,authorization_head,request_mac,sealed_result,completed_at,operator_ref) VALUES(?,?,?,?,?,?,?,?)", a.OperationID, a.Action, a.Sequence, req.head, e.mac(req), sealed, completed.Unix(), a.Device) |
| if err != nil { |
| return nil, ErrUnavailable |
| } |
| - _, err = conn.ExecContext(ctx, "INSERT INTO "+e.prefix+"_state(singleton,origin,installation,registry,device,sequence,head,completed_at) VALUES(1,?,?,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET sequence=excluded.sequence,head=excluded.head,completed_at=excluded.completed_at", a.Origin, a.Installation, a.Registry, a.Device, a.Sequence, req.head, completed.Unix()) |
| + _, err = conn.ExecContext(ctx, "INSERT INTO "+e.prefix+"_state(singleton,origin,installation,registry,device,sequence,head,completed_at,key_id) VALUES(1,?,?,?,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET sequence=excluded.sequence,head=excluded.head,completed_at=excluded.completed_at", a.Origin, a.Installation, a.Registry, a.Device, a.Sequence, req.head, completed.Unix(), e.verifier.kid) |
| if err != nil { |
| return nil, ErrUnavailable |
| } |
diff --git a/internal/operator/engine_test.go b/internal/operator/engine_test.go| index a59a899..d6f70b5 100644 |
| --- a/internal/operator/engine_test.go |
| +++ b/internal/operator/engine_test.go |
| @@ -13,6 +13,8 @@ import ( |
| "time" |
| sharedseal "amadan.net/oficina/home/handoff/seal" |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| rdb "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/migrate" |
| _ "modernc.org/sqlite" |
| @@ -24,6 +26,8 @@ type engineFixture struct { |
| raw []byte |
| now *time.Time |
| path string |
| + key *family.Keypair |
| + auth Authorization |
| } |
| func newEngineFixture(t *testing.T) engineFixture { |
| @@ -40,7 +44,7 @@ func newEngineFixture(t *testing.T) engineFixture { |
| if _, e = db.Exec("PRAGMA journal_mode=WAL"); e != nil { |
| t.Fatal(e) |
| } |
| - if _, e = migrate.Apply(context.Background(), handle, DirectorySchema); e != nil { |
| + if _, e = migrate.Apply(context.Background(), handle, migrate.Merge(directorydata.Schema, directorydata.RosterSchema, directorydata.OperatorSchema, DirectorySchema)); e != nil { |
| t.Fatal(e) |
| } |
| if _, e = db.Exec("CREATE TABLE test_items(id INTEGER PRIMARY KEY)"); e != nil { |
| @@ -55,7 +59,7 @@ func newEngineFixture(t *testing.T) engineFixture { |
| if e != nil { |
| t.Fatal(e) |
| } |
| - return engineFixture{engine, db, signed(t, k, a), &now, path} |
| + return engineFixture{engine, db, signed(t, k, a), &now, path, k, a} |
| } |
| var privateResult = []byte(`{"bearer":"synthetic-private-bearer-keep-out-of-db","receipt":"synthetic-private-proof"}`) |
diff --git a/internal/operator/migrations/credential/migrations/0002_operator_key_identity.sql b/internal/operator/migrations/credential/migrations/0002_operator_key_identity.sql| new file mode 100644 |
| index 0000000..fa339df |
| --- /dev/null |
| +++ b/internal/operator/migrations/credential/migrations/0002_operator_key_identity.sql |
| @@ -0,0 +1,2 @@ |
| +ALTER TABLE credential_operator_state ADD COLUMN key_id TEXT NOT NULL DEFAULT ''; |
| +ALTER TABLE credential_operator_results ADD COLUMN operator_ref TEXT NOT NULL DEFAULT ''; |
diff --git a/internal/operator/migrations/directory/migrations/0002_owner_receipts.sql b/internal/operator/migrations/directory/migrations/0002_owner_receipts.sql| new file mode 100644 |
| index 0000000..fdf16e2 |
| --- /dev/null |
| +++ b/internal/operator/migrations/directory/migrations/0002_owner_receipts.sql |
| @@ -0,0 +1,7 @@ |
| +CREATE TABLE directory_operator_owner_receipts ( |
| + issuer TEXT NOT NULL, |
| + owner_operation_id TEXT NOT NULL CHECK(length(owner_operation_id)=43), |
| + directory_operation_id TEXT NOT NULL UNIQUE REFERENCES directory_operator_results(operation_id) DEFERRABLE INITIALLY DEFERRED, |
| + team_id INTEGER NOT NULL UNIQUE REFERENCES teams(id), |
| + PRIMARY KEY(issuer,owner_operation_id) |
| +); |
diff --git a/internal/operator/migrations/directory/migrations/0003_operator_key_identity.sql b/internal/operator/migrations/directory/migrations/0003_operator_key_identity.sql| new file mode 100644 |
| index 0000000..69102fe |
| --- /dev/null |
| +++ b/internal/operator/migrations/directory/migrations/0003_operator_key_identity.sql |
| @@ -0,0 +1,2 @@ |
| +ALTER TABLE directory_operator_state ADD COLUMN key_id TEXT NOT NULL DEFAULT ''; |
| +ALTER TABLE directory_operator_results ADD COLUMN operator_ref TEXT NOT NULL DEFAULT ''; |