oficina / home Public

Extend sign-in privacy gate through separate-role owner seed

Paul Campbell pushed by paul@keymail.dev 643ff5667991fa8df30a91c0e6017cce12fec6a4 parent 107ea4b
3 files changed, +134 −3
  • Makefile +1 −1
  • internal/home/operator_person_test.go +130 −0
  • internal/home/signin_test.go +3 −2

CI passed — run details

diff --git a/Makefile b/Makefile
index 9bb9a7b..49971a8 100644
--- a/Makefile
+++ b/Makefile
@@ -56,6 +56,6 @@ test-go:
# CGO_ENABLED. Nothing it builds is shipped, so it does not violate the
# cgo-free binary rule, which is about what gets deployed.
test-race:
- go test -race -count=1 ./internal/home/
+ go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/provisioner/ ./internal/home/directorydata/
cd handoff && CGO_ENABLED=1 go test -race -count=1 ./...
cd roster && CGO_ENABLED=1 go test -race -count=1 ./...
diff --git a/internal/home/operator_person_test.go b/internal/home/operator_person_test.go
index fdd6cea..e97d63a 100644
--- a/internal/home/operator_person_test.go
+++ b/internal/home/operator_person_test.go
@@ -11,8 +11,11 @@ import (
"testing"
"time"
+ "amadan.net/oficina/home/internal/home/directorydata"
"amadan.net/oficina/home/internal/home/seal"
+ "amadan.net/oficina/home/internal/home/sealtest"
"amadan.net/oficina/home/internal/operator"
+ "amadan.net/oficina/home/roster"
family "amadan.net/rastrillo/rastrillo/crypto"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/migrate"
@@ -176,3 +179,130 @@ func TestOperatorSeedUsesNormalIdentityAndAtomicPrivateReceipt(t *testing.T) {
}
}
}
+
+// exerciseOperatorSeedPrivacy extends the original sign-in gate through the
+// credential receipt and directory owner seed, using separate role databases.
+func exerciseOperatorSeedPrivacy(t *testing.T, app *testApp) {
+ t.Helper()
+ ctx := context.Background()
+ if _, e := migrate.Apply(ctx, app.db, operator.CredentialSchema); e != nil {
+ t.Fatal(e)
+ }
+ signer, 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)
+ }
+ page, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ pins := operator.Pins{Role: "credential", Origin: Origin, Installation: opToken(20), Registry: opToken(21), Device: opToken(22), PublicKey: base64.RawURLEncoding.EncodeToString(signer.SignPub()), Actions: []string{"credential.owner-seed"}}
+ verifier, e := operator.NewVerifier(pins)
+ if e != nil {
+ t.Fatal(e)
+ }
+ now := time.Now()
+ clock := func() time.Time { return now }
+ engine, e := operator.NewEngine(app.db.Writer(), verifier, app.secrets.Sealer, bytes.Repeat([]byte{23}, 32), clock)
+ if e != nil {
+ t.Fatal(e)
+ }
+ resolve, e := NewOperatorPersonResolver(app.secrets.Pepper)
+ if e != nil {
+ t.Fatal(e)
+ }
+ credential, e := operator.NewCredential(engine, directoryOrigin, owner, [][]byte{browser.SignPub()}, resolve)
+ if e != nil {
+ t.Fatal(e)
+ }
+ auth := operator.Authorization{Version: 1, Purpose: "operator-input", Role: pins.Role, Origin: pins.Origin, Installation: pins.Installation, Registry: pins.Registry, Device: pins.Device, Sequence: 1, Initial: true, JournalSequence: 1, OperationID: opToken(24), Action: "credential.owner-seed", Issued: now.Unix(), Expires: now.Unix() + 300}
+ input := operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: auth.Action, OperationID: auth.OperationID}, Directory: directoryOrigin, TeamID: 1, TeamSQID: "Uk", Address: sealtest.CanaryAddress, ExistingOnly: true}
+ raw, e := operator.SignRequest(signer, auth, input)
+ if e != nil {
+ t.Fatal(e)
+ }
+ out, e := credential.Execute(ctx, raw)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var receipt operator.Result
+ if e = json.Unmarshal(out, &receipt); e != nil {
+ t.Fatal(e)
+ }
+ path := filepath.Join(t.TempDir(), "directory.db")
+ directoryDB, e := db.Open(path, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer directoryDB.Close()
+ if _, e = migrate.Apply(ctx, directoryDB, migrate.Merge(directorydata.Schema, directorydata.RosterSchema, directorydata.OperatorSchema, operator.DirectorySchema)); e != nil {
+ t.Fatal(e)
+ }
+ pins.Role = "directory"
+ pins.Origin = directoryOrigin
+ pins.Installation = opToken(25)
+ pins.Actions = []string{"directory.initialize", "directory.seed-team"}
+ verifier, e = operator.NewVerifier(pins)
+ if e != nil {
+ t.Fatal(e)
+ }
+ engine, e = operator.NewEngine(directoryDB.Writer(), verifier, app.secrets.Sealer, bytes.Repeat([]byte{26}, 32), clock)
+ if e != nil {
+ t.Fatal(e)
+ }
+ ov, e := operator.NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(owner.SignPub()), Origin, directoryOrigin)
+ if e != nil {
+ t.Fatal(e)
+ }
+ rosterSigner, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ directory, e := operator.NewDirectory(engine, ov, rosterSigner, []roster.Key{{ID: "page", Public: base64.RawURLEncoding.EncodeToString(page.SignPub())}}, directorydata.Config{Apps: []string{"docs"}, ValidateRoute: func(r directorydata.Route) bool { return r.Origin == "https://"+r.Label+".docs.oficina.works" }})
+ if e != nil {
+ t.Fatal(e)
+ }
+ auth.Role = pins.Role
+ auth.Origin = pins.Origin
+ auth.Installation = pins.Installation
+ auth.OperationID = opToken(27)
+ auth.Action = "directory.initialize"
+ raw, e = operator.SignRequest(signer, auth, operator.InitializeInput{InputBase: operator.InputBase{Version: 1, Operation: auth.Action, OperationID: auth.OperationID}, Generation: opToken(28), Baseline: "operator-attests-never-provisioned"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = directory.Execute(ctx, raw); e != nil {
+ t.Fatal(e)
+ }
+ request, e := verifier.Verify(raw)
+ if e != nil {
+ t.Fatal(e)
+ }
+ auth.Sequence = 2
+ auth.Initial = false
+ auth.Previous = request.Head()
+ auth.JournalSequence = 2
+ auth.OperationID = opToken(29)
+ auth.Action = "directory.seed-team"
+ raw, e = operator.SignRequest(signer, auth, operator.SeedInput{InputBase: operator.InputBase{Version: 1, Operation: auth.Action, OperationID: auth.OperationID}, Generation: opToken(28), TeamID: 1, TeamSQID: "Uk", Label: "alpha", Name: "CANARY-TEAM-NAME-2b71", Receipt: receipt.Receipt})
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = directory.Execute(ctx, raw); e != nil {
+ t.Fatal(e)
+ }
+ var members int
+ if e = directoryDB.Writer().QueryRow("SELECT count(*) FROM memberships WHERE role='owner'").Scan(&members); e != nil || members != 1 {
+ t.Fatal("owner not seeded", e)
+ }
+ sealtest.AssertNoPlaintext(t, path)
+ sealtest.AssertNoPlaintext(t, app.dbPath)
+}
diff --git a/internal/home/signin_test.go b/internal/home/signin_test.go
index e21409c..e308d8d 100644
--- a/internal/home/signin_test.go
+++ b/internal/home/signin_test.go
@@ -85,8 +85,8 @@ func sessionCookie(rec *httptest.ResponseRecorder) *http.Cookie {
// TestFullSigninLeavesNoReadableAddress is §12 gate 2: a full sign-in
// leaves no readable address anywhere, WAL included. The directory and
-// rekey halves arrive with those steps; this asserts the sign-in half
-// and must be EXTENDED, not replaced, then.
+// owner seed now extends the completed sign-in into separate role databases.
+// The rekey half remains for its implementation.
func TestFullSigninLeavesNoReadableAddress(t *testing.T) {
app := newTestApp(t)
@@ -97,6 +97,7 @@ func TestFullSigninLeavesNoReadableAddress(t *testing.T) {
t.Fatalf("redeem = %d, want 303", rec.Code)
}
+ exerciseOperatorSeedPrivacy(t, app)
sealtest.AssertNoPlaintext(t, app.dbPath)
}