oficina / home Public

Observe directory proposals through signed read-only challenges

Paul Campbell pushed by paul@keymail.dev 39aaed130f7076b1ed1ba2c37d2c027f324492ce parent 4a27860
2 files changed, +211 −0
  • internal/operator/inspect.go +132 −0
  • internal/operator/inspect_test.go +79 −0

CI failed — run details

diff --git a/internal/operator/inspect.go b/internal/operator/inspect.go
new file mode 100644
index 0000000..14acf01
--- /dev/null
+++ b/internal/operator/inspect.go
@@ -0,0 +1,132 @@
+package operator
+
+import (
+ "context"
+ "encoding/base64"
+ "time"
+
+ family "amadan.net/rastrillo/rastrillo/crypto"
+)
+
+const InspectContext = "oficina/home/operator-inspect/v1"
+
+type InspectChallenge struct {
+ Version int `json:"version"`
+ Purpose string `json:"purpose"`
+ Role string `json:"role"`
+ Origin string `json:"origin"`
+ Installation string `json:"installation"`
+ Registry string `json:"registry"`
+ Device string `json:"device"`
+ Challenge string `json:"challenge"`
+ Issued int64 `json:"issued_at"`
+ Expires int64 `json:"expires_at"`
+}
+
+func (c InspectChallenge) valid() bool {
+ return c.Version == 1 && c.Purpose == "operator-inspect" && (c.Role == "directory" || c.Role == "credential") && Origin(c.Origin) && CanonicalToken(c.Installation) && CanonicalToken(c.Registry) && CanonicalToken(c.Device) && CanonicalToken(c.Challenge) && c.Issued > 0 && c.Expires > c.Issued && c.Expires-c.Issued <= 300
+}
+func SignInspect(k *family.Keypair, c InspectChallenge) ([]byte, error) {
+ if !signingKey(k) || !c.valid() {
+ return nil, ErrRefused
+ }
+ payload, err := encode(c)
+ if err != nil {
+ return nil, ErrRefused
+ }
+ signature, err := family.Sign(k, InspectContext, payload)
+ if err != nil {
+ return nil, ErrUnavailable
+ }
+ return encode(envelope{1, keyID(k.SignPub()), base64.RawURLEncoding.EncodeToString(payload), base64.RawURLEncoding.EncodeToString(signature)})
+}
+func (v *Verifier) verifyInspect(raw []byte, now time.Time) (InspectChallenge, error) {
+ var env envelope
+ var c InspectChallenge
+ if v == nil || len(raw) == 0 || len(raw) > AuthorizationLimit || strictCanonical(raw, &env) != nil || env.Version != 1 || env.KeyID != v.kid {
+ return c, ErrRefused
+ }
+ payload, ok := canonical(env.Payload, -1)
+ sig, ok2 := canonical(env.Signature, 64)
+ if !ok || !ok2 || !family.Verify(v.key, InspectContext, payload, sig) || strictCanonical(payload, &c) != nil || !c.valid() {
+ return InspectChallenge{}, ErrRefused
+ }
+ p := v.pins
+ if c.Role != p.Role || c.Origin != p.Origin || c.Installation != p.Installation || c.Registry != p.Registry || c.Device != p.Device || c.Issued > now.Unix() || c.Expires <= now.Unix() {
+ return InspectChallenge{}, ErrRefused
+ }
+ return c, nil
+}
+
+type Inspection struct {
+ Version int `json:"version"`
+ Challenge string `json:"challenge"`
+ Observed int64 `json:"observed_at"`
+ Generation *string `json:"generation"`
+ TeamID int64 `json:"proposed_team_id"`
+ TeamSQID string `json:"proposed_team_sqid"`
+}
+
+// Inspect observes the next proposal without reserving it or advancing authority.
+// Its private result still requires the platform's attempt-bound result channel.
+func (d *Directory) Inspect(ctx context.Context, raw []byte) ([]byte, error) {
+ if d == nil || d.engine == nil {
+ return nil, ErrRefused
+ }
+ e := d.engine
+ challenge, err := e.verifier.verifyInspect(raw, e.now())
+ if err != nil {
+ return nil, err
+ }
+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ c, err := e.db.Conn(ctx)
+ if err != nil {
+ return nil, ErrUnavailable
+ }
+ defer c.Close()
+ if _, err = c.ExecContext(ctx, "BEGIN"); err != nil {
+ return nil, ErrUnavailable
+ }
+ defer c.ExecContext(context.Background(), "ROLLBACK")
+ var count int
+ if err = c.QueryRowContext(ctx, "SELECT count(*) FROM directory_operator_state WHERE singleton=1 AND (origin!=? OR installation!=? OR registry!=? OR device!=? OR key_id!=? OR completed_at>?)", e.verifier.pins.Origin, e.verifier.pins.Installation, e.verifier.pins.Registry, e.verifier.pins.Device, e.verifier.kid, e.now().Unix()).Scan(&count); err != nil {
+ return nil, ErrUnavailable
+ }
+ if count != 0 {
+ return nil, ErrRefused
+ }
+ var generation *string
+ rows, err := c.QueryContext(ctx, "SELECT generation FROM roster_generation WHERE singleton=1")
+ if err != nil {
+ return nil, ErrUnavailable
+ }
+ if rows.Next() {
+ var g string
+ if rows.Scan(&g) != nil || !CanonicalToken(g) {
+ rows.Close()
+ return nil, ErrUnavailable
+ }
+ generation = &g
+ }
+ err = rows.Err()
+ closeErr := rows.Close()
+ if err != nil || closeErr != nil {
+ return nil, ErrUnavailable
+ }
+ id, sqid, err := nextTeam(ctx, c)
+ if err != nil {
+ return nil, ErrUnavailable
+ }
+ if _, err = e.verifier.verifyInspect(raw, e.now()); err != nil {
+ return nil, ErrRefused
+ }
+ out, err := encode(Inspection{1, challenge.Challenge, e.now().Unix(), generation, id, sqid})
+ if err != nil {
+ return nil, ErrUnavailable
+ }
+ if _, err = c.ExecContext(ctx, "COMMIT"); err != nil {
+ return nil, ErrUnavailable
+ }
+ return out, nil
+}
diff --git a/internal/operator/inspect_test.go b/internal/operator/inspect_test.go
new file mode 100644
index 0000000..c256306
--- /dev/null
+++ b/internal/operator/inspect_test.go
@@ -0,0 +1,79 @@
+package operator
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/go-json-experiment/json"
+)
+
+func inspectChallenge(f *directoryFixture) InspectChallenge {
+ p := f.engine.verifier.pins
+ return InspectChallenge{1, "operator-inspect", p.Role, p.Origin, p.Installation, p.Registry, p.Device, token(85), f.now.Unix(), f.now.Unix() + 300}
+}
+func TestInspectDoesNotInitializeOrReserveAuthority(t *testing.T) {
+ f := newDirectoryFixture(t)
+ challenge := inspectChallenge(f)
+ raw, err := SignInspect(f.key, challenge)
+ if err != nil {
+ t.Fatal(err)
+ }
+ read := func(wantGen *string, wantID int64) {
+ t.Helper()
+ out, err := f.directory.Inspect(context.Background(), raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got Inspection
+ if err = json.Unmarshal(out, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Challenge != challenge.Challenge || got.TeamID != wantID || got.Observed != f.now.Unix() || (got.Generation == nil) != (wantGen == nil) || (wantGen != nil && *got.Generation != *wantGen) {
+ t.Fatal("wrong inspection")
+ }
+ }
+ read(nil, 1)
+ read(nil, 1)
+ if countRows(t, f.db, "directory_operator_state") != 0 || countRows(t, f.db, "directory_operator_results") != 0 || countRows(t, f.db, "teams") != 0 {
+ t.Fatal("inspection minted authority")
+ }
+ f.initialize(t)
+ gen := token(10)
+ read(&gen, 1)
+ if _, err = f.seed(t, 1, "alpha"); err != nil {
+ t.Fatal(err)
+ }
+ read(&gen, 2)
+ if countRows(t, f.db, "directory_operator_results") != 2 {
+ t.Fatal("inspection advanced accepted stream")
+ }
+ if _, err = f.directory.Execute(context.Background(), raw); err != ErrRefused {
+ t.Fatal("inspection became mutation", err)
+ }
+ *f.now = f.now.Add(300 * time.Second)
+ if _, err = f.directory.Inspect(context.Background(), raw); err != ErrRefused {
+ t.Fatal("expired inspection replayed", err)
+ }
+}
+func TestInspectWrongBindingsAndMutableInputRefuse(t *testing.T) {
+ f := newDirectoryFixture(t)
+ for _, change := range []func(*InspectChallenge){func(c *InspectChallenge) { c.Origin = "https://other.oficina.works" }, func(c *InspectChallenge) { c.Device = token(86) }, func(c *InspectChallenge) { c.Registry = token(86) }, func(c *InspectChallenge) { c.Installation = token(86) }, func(c *InspectChallenge) { c.Role = "credential" }, func(c *InspectChallenge) { c.Issued++; c.Expires++ }} {
+ c := inspectChallenge(f)
+ change(&c)
+ raw, err := SignInspect(f.key, c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.directory.Inspect(context.Background(), raw); err != ErrRefused {
+ t.Fatal("wrong inspection accepted", err)
+ }
+ }
+ raw, err := SignInspect(f.key, inspectChallenge(f))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = f.directory.Inspect(context.Background(), append(raw, ' ')); err != ErrRefused {
+ t.Fatal("noncanonical challenge", err)
+ }
+}