oficina / home Public

Implement atomic credential owner receipts and directory provisioning

Paul Campbell pushed by paul@keymail.dev a9eba8a27e4222de038f3a2c832f99b6451a7dde parent 89aadfc
12 files changed, +924 −12
  • internal/home/directorydata/store.go +4 −0
  • internal/home/links.go +12 −2
  • internal/home/operator_person.go +40 −0
  • internal/home/operator_person_test.go +178 −0
  • internal/operator/credential.go +74 −0
  • internal/operator/directory.go +333 −0
  • internal/operator/directory_test.go +258 −0
  • internal/operator/engine.go +8 −8
  • internal/operator/engine_test.go +6 −2
  • internal/operator/migrations/credential/migrations/0002_operator_key_identity.sql +2 −0
  • internal/operator/migrations/directory/migrations/0002_owner_receipts.sql +7 −0
  • internal/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 '';