| 1 | package operator |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "database/sql" |
| 7 | "encoding/base64" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "testing" |
| 11 | |
| 12 | "amadan.net/oficina/home/internal/home/directorydata" |
| 13 | "amadan.net/oficina/home/roster" |
| 14 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 15 | "github.com/go-json-experiment/json" |
| 16 | ) |
| 17 | |
| 18 | type directoryFixture struct { |
| 19 | engineFixture |
| 20 | directory *Directory |
| 21 | ownerKey *family.Keypair |
| 22 | attempt byte |
| 23 | bearer string |
| 24 | } |
| 25 | |
| 26 | func newDirectoryFixture(t *testing.T) *directoryFixture { |
| 27 | t.Helper() |
| 28 | f := newEngineFixture(t) |
| 29 | p := f.engine.verifier.pins |
| 30 | p.Actions = []string{"directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer"} |
| 31 | v, e := NewVerifier(p) |
| 32 | if e != nil { |
| 33 | t.Fatal(e) |
| 34 | } |
| 35 | f.engine.verifier = v |
| 36 | owner, e := family.Generate() |
| 37 | if e != nil { |
| 38 | t.Fatal(e) |
| 39 | } |
| 40 | signer, e := family.Generate() |
| 41 | if e != nil { |
| 42 | t.Fatal(e) |
| 43 | } |
| 44 | pageKey, e := family.Generate() |
| 45 | if e != nil { |
| 46 | t.Fatal(e) |
| 47 | } |
| 48 | ov, e := NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(owner.SignPub()), "https://id.oficina.works", p.Origin) |
| 49 | if e != nil { |
| 50 | t.Fatal(e) |
| 51 | } |
| 52 | 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" }}) |
| 53 | if e != nil { |
| 54 | t.Fatal(e) |
| 55 | } |
| 56 | return &directoryFixture{engineFixture: f, directory: d, ownerKey: owner, attempt: 20} |
| 57 | } |
| 58 | func (f *directoryFixture) input(t *testing.T, action string, build func(InputBase) any) []byte { |
| 59 | t.Helper() |
| 60 | a := f.auth |
| 61 | var sequence int64 |
| 62 | var head string |
| 63 | e := f.db.QueryRow("SELECT sequence,head FROM directory_operator_state WHERE singleton=1").Scan(&sequence, &head) |
| 64 | if e != nil && !errors.Is(e, sql.ErrNoRows) { |
| 65 | t.Fatal(e) |
| 66 | } |
| 67 | a.Sequence = sequence + 1 |
| 68 | a.Previous = head |
| 69 | a.Initial = a.Sequence == 1 |
| 70 | a.JournalSequence = int64(f.attempt) |
| 71 | a.Action = action |
| 72 | a.OperationID = token(f.attempt) |
| 73 | f.attempt++ |
| 74 | a.Issued = f.now.Unix() |
| 75 | a.Expires = a.Issued + 300 |
| 76 | raw, e := SignRequest(f.key, a, build(InputBase{1, action, a.OperationID})) |
| 77 | if e != nil { |
| 78 | t.Fatal(e) |
| 79 | } |
| 80 | return raw |
| 81 | } |
| 82 | func (f *directoryFixture) initialize(t *testing.T) { |
| 83 | t.Helper() |
| 84 | raw := f.input(t, "directory.initialize", func(b InputBase) any { return InitializeInput{b, nil, token(10), "operator-attests-never-provisioned"} }) |
| 85 | if _, e := f.directory.Execute(context.Background(), raw); e != nil { |
| 86 | t.Fatal(e) |
| 87 | } |
| 88 | } |
| 89 | func (f *directoryFixture) receipt(t *testing.T, id int64) []byte { |
| 90 | t.Helper() |
| 91 | sqid, e := TeamSQID(id) |
| 92 | if e != nil { |
| 93 | t.Fatal(e) |
| 94 | } |
| 95 | 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} |
| 96 | raw, e := SignOwner(f.ownerKey, p) |
| 97 | if e != nil { |
| 98 | t.Fatal(e) |
| 99 | } |
| 100 | return raw |
| 101 | } |
| 102 | func (f *directoryFixture) seed(t *testing.T, id int64, label string) ([]byte, error) { |
| 103 | t.Helper() |
| 104 | sqid, _ := TeamSQID(id) |
| 105 | proof := f.receipt(t, id) |
| 106 | raw := f.input(t, "directory.seed-team", func(b InputBase) any { |
| 107 | return SeedInput{b, token(10), id, sqid, label, "PRIVATE-TEAM-NAME-CANARY", proof} |
| 108 | }) |
| 109 | out, e := f.directory.Execute(context.Background(), raw) |
| 110 | if e != nil { |
| 111 | return raw, e |
| 112 | } |
| 113 | var result Result |
| 114 | if json.Unmarshal(out, &result) != nil || result.Epoch != 2 || result.TeamID != id { |
| 115 | t.Fatal("wrong seed result") |
| 116 | } |
| 117 | return raw, nil |
| 118 | } |
| 119 | func TestDirectoryInitializeOwnerAndPendingConsumerAreAtomic(t *testing.T) { |
| 120 | f := newDirectoryFixture(t) |
| 121 | f.initialize(t) |
| 122 | seedRaw, e := f.seed(t, 1, "alpha") |
| 123 | if e != nil { |
| 124 | t.Fatal(e) |
| 125 | } |
| 126 | if _, e = f.directory.Execute(context.Background(), seedRaw); e != nil { |
| 127 | t.Fatal("seed replay", e) |
| 128 | } |
| 129 | if countRows(t, f.db, "teams") != 1 || countRows(t, f.db, "memberships") != 1 || countRows(t, f.db, "directory_operator_owner_receipts") != 1 { |
| 130 | t.Fatal("duplicate owner/team") |
| 131 | } |
| 132 | raw := f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| 133 | return ProvisionInput{b, token(10), "Uk", "docs", "alpha-docs", "https://alpha.docs.oficina.works", 0} |
| 134 | }) |
| 135 | output, e := f.directory.Execute(context.Background(), raw) |
| 136 | if e != nil { |
| 137 | t.Fatal(e) |
| 138 | } |
| 139 | var result Result |
| 140 | if json.Unmarshal(output, &result) != nil || !CanonicalToken(result.Bearer) || len(result.Record) == 0 { |
| 141 | t.Fatal("missing private provision result") |
| 142 | } |
| 143 | repeated, e := f.directory.Execute(context.Background(), raw) |
| 144 | if e != nil || !bytes.Equal(output, repeated) { |
| 145 | t.Fatal("token changed on replay", e) |
| 146 | } |
| 147 | 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"} |
| 148 | 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 { |
| 149 | t.Fatal(e) |
| 150 | } |
| 151 | var state string |
| 152 | var pointerSequence int64 |
| 153 | var pd, rd []byte |
| 154 | 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) { |
| 155 | t.Fatal("wrong current pointer", e) |
| 156 | } |
| 157 | src, e := directorydata.NewRosterSource(f.db, f.engine.sealer, f.directory.policy, f.directory.owner.directory) |
| 158 | if e != nil { |
| 159 | t.Fatal(e) |
| 160 | } |
| 161 | _, status, e := src.Read(context.Background(), directorydata.RosterRequest{Token: result.Bearer, Team: "Uk", Fetch: token(80)}, *f.now) |
| 162 | if status != 503 || e != directorydata.ErrUnavailable { |
| 163 | t.Fatal("pending standing leaked", status, e) |
| 164 | } |
| 165 | for _, suffix := range []string{"", "-wal"} { |
| 166 | disk, e := os.ReadFile(f.path + suffix) |
| 167 | if e != nil && !errors.Is(e, os.ErrNotExist) { |
| 168 | t.Fatal(e) |
| 169 | } |
| 170 | for _, secret := range []string{"PRIVATE-TEAM-NAME-CANARY", result.Bearer} { |
| 171 | if bytes.Contains(disk, []byte(secret)) { |
| 172 | t.Fatal("unsealed private field") |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | func TestDirectoryRefusesChangedGenerationProposalLabelAndOwnerProof(t *testing.T) { |
| 178 | f := newDirectoryFixture(t) |
| 179 | f.initialize(t) |
| 180 | if _, e := f.seed(t, 1, "alpha"); e != nil { |
| 181 | t.Fatal(e) |
| 182 | } |
| 183 | if _, e := f.seed(t, 1, "beta"); e != ErrRefused { |
| 184 | t.Fatal("old owner receipt reused", e) |
| 185 | } |
| 186 | if _, e := f.seed(t, 2, "alpha"); e != ErrRefused { |
| 187 | t.Fatal("suite label collision", e) |
| 188 | } |
| 189 | if _, e := f.seed(t, 3, "gamma"); e != ErrRefused { |
| 190 | t.Fatal("raced integer proposal", e) |
| 191 | } |
| 192 | if _, e := f.seed(t, 2, "alpha.beta"); e != ErrRefused { |
| 193 | t.Fatal("invalid label", e) |
| 194 | } |
| 195 | raw := f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| 196 | return ProvisionInput{b, token(11), "Uk", "docs", "alpha-docs", "https://alpha.docs.oficina.works", 0} |
| 197 | }) |
| 198 | if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| 199 | t.Fatal("generation mismatch", e) |
| 200 | } |
| 201 | raw = f.input(t, "directory.provision-consumer", func(b InputBase) any { |
| 202 | return ProvisionInput{b, token(10), "Uk", "docs", "alpha-docs", "https://other.docs.oficina.works", 0} |
| 203 | }) |
| 204 | if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| 205 | t.Fatal("host mismatch", e) |
| 206 | } |
| 207 | if countRows(t, f.db, "teams") != 1 || countRows(t, f.db, "team_instances") != 0 { |
| 208 | t.Fatal("failed seed/provision mutated") |
| 209 | } |
| 210 | } |
| 211 | func TestDirectoryWillNotInitializeFromEmptyTablesWithoutSignedBaseline(t *testing.T) { |
| 212 | f := newDirectoryFixture(t) |
| 213 | raw := f.input(t, "directory.initialize", func(b InputBase) any { return InitializeInput{b, nil, token(10), ""} }) |
| 214 | if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| 215 | t.Fatal("missing baseline", e) |
| 216 | } |
| 217 | if countRows(t, f.db, "roster_generation") != 0 || countRows(t, f.db, "directory_operator_state") != 0 { |
| 218 | t.Fatal("invented initial authority") |
| 219 | } |
| 220 | f.initialize(t) |
| 221 | raw = f.input(t, "directory.initialize", func(b InputBase) any { return InitializeInput{b, nil, token(11), "operator-attests-never-provisioned"} }) |
| 222 | if _, e := f.directory.Execute(context.Background(), raw); e != ErrRefused { |
| 223 | t.Fatal("generation reset", e) |
| 224 | } |
| 225 | } |
| 226 | func TestEngineOperatorKeyPinCannotSilentlyRotate(t *testing.T) { |
| 227 | f := newEngineFixture(t) |
| 228 | if _, e := f.engine.Execute(context.Background(), f.raw, insertOne); e != nil { |
| 229 | t.Fatal(e) |
| 230 | } |
| 231 | old, _ := f.engine.verifier.Verify(f.raw) |
| 232 | k, e := family.Generate() |
| 233 | if e != nil { |
| 234 | t.Fatal(e) |
| 235 | } |
| 236 | p := f.engine.verifier.pins |
| 237 | p.PublicKey = base64.RawURLEncoding.EncodeToString(k.SignPub()) |
| 238 | v, e := NewVerifier(p) |
| 239 | if e != nil { |
| 240 | t.Fatal(e) |
| 241 | } |
| 242 | f.engine.verifier = v |
| 243 | a := f.auth |
| 244 | a.Sequence = 2 |
| 245 | a.Initial = false |
| 246 | a.Previous = old.Head() |
| 247 | a.OperationID = token(55) |
| 248 | a.JournalSequence = 2 |
| 249 | raw, e := SignRequest(k, a, struct{}{}) |
| 250 | if e != nil { |
| 251 | t.Fatal(e) |
| 252 | } |
| 253 | if _, e = f.engine.Execute(context.Background(), raw, func(context.Context, *sql.Conn, Request) ([]byte, error) { |
| 254 | t.Fatal("rotated key reached mutation") |
| 255 | return nil, nil |
| 256 | }); e != ErrRefused { |
| 257 | t.Fatal("key changed", e) |
| 258 | } |
| 259 | } |
| 260 | |