diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 72c7093..762ccd3 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -14,6 +14,7 @@ Cloudy is a **front end** of a JFA substrate — the member-facing application | `internal/covenant` | the **assessment scale** implementation (member reputation as covenant) | | `internal/economy` | **member-issued credit** (the member economy) | | `internal/coord` | consumption of the substrate **coordination protocol** | +| `internal/enroll` | **frontend-as-operator enrollment** with a coordinator (apply → keys → verify → conformance → active) | | Member | a person, platform-scoped; PII stays member-local and erasable | ## Invariants and their bindings — as of `main` @@ -38,6 +39,7 @@ Statuses are stated per the architecture's honesty rule. Built member-layer work - **Record domain-tag rename** (product naming) is pending and must land **before any durable persistence** — retagging after durable logs exist would be a rewrite. - **Named record residuals** (steganographic floor, timestamp covert channel, witness amnesia, liveness gap) are documented in the record package doc rather than pretended away. - **Contributor node — Executor seam built; runtime implementations pending.** `internal/contribute` fixes the node-contribution contract (Executor / ServiceExecutor / Registry / NodeContract) with two invariants enforced by tests: a scavenged node accepts only preemptible executors (owner-never-interrupted), and a Service workload requires pinned placement. `cmd/cloudy-agent` runs it end to end with a **real** Storage executor (opaque sealed shards + the `internal/storage` proof-of-possession) and **placeholder** Compute/Service executors. The heavy runtime port from the coordinator's transitional `internal/agent` — Docker executor, gopsutil hardware sampling, opt-out/allowlist/printers, and the agent→cloudyd→`/v0` relay — is the named next step and lands *behind* this seam without changing it. +- **Operator enrollment driver — built and live-verified.** `internal/enroll` + `cmd/cloudy-enroll` drive a coordinator's operator onboarding lifecycle end to end (apply → register the 7-key set → email verification → conformance suites A/B → automatic activation), reusing `internal/opcred` for key custody and for computing conformance answers through the real protocol canon. Suite C is graded coordinator-side and needs no operator response, so there is no suite-C step. The client is deliberately separate from `internal/coord`: enrollment is plain JSON on the coordinator's **portal** surface, not the `/v0` node-side wire. The out-of-band email code is supplied via an injected `CodeSource`. Verified against a live SoHoLINK coordinator — the enrolled operator reached `active` and its credential then authenticated on `/v0`. ## Dependency declaration diff --git a/cmd/cloudy-enroll/main.go b/cmd/cloudy-enroll/main.go new file mode 100644 index 0000000..3dfb69e --- /dev/null +++ b/cmd/cloudy-enroll/main.go @@ -0,0 +1,152 @@ +// Command cloudy-enroll drives this frontend through the SoHoLINK coordinator's +// operator onboarding lifecycle (apply → register the 7-key set → email +// verification → conformance A/B → active) and reports whether the operator +// became active. +// +// The coordinator emails a 6-digit verification code during the run and never +// returns it in an API response, so the code is supplied out-of-band: either +// interactively (default — prompts on stdin once the code has been sent) or by +// polling a file (-code-file), which suits automated/containerized bring-up. +// +// Point -portal at the coordinator's PORTAL surface (e.g. http://portal:8080 on +// the coordinator's own network). The public edge gates /operators/apply behind +// the federation disclaimer, so enrollment is a trusted, pre-launch bring-up +// step, not a public-internet flow. +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/enroll" + "github.com/NTARI-RAND/Cloudy/internal/opcred" +) + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stdin); err != nil { + fmt.Fprintln(os.Stderr, "cloudy-enroll:", err) + os.Exit(1) + } +} + +func run(args []string, out io.Writer, in io.Reader) error { + fs := flag.NewFlagSet("cloudy-enroll", flag.ContinueOnError) + fs.SetOutput(out) + portal := fs.String("portal", "", "coordinator portal base URL (e.g. http://portal:8080)") + slug := fs.String("slug", "", "operator id (^[a-z0-9-]{1,64}$)") + name := fs.String("name", "", "operator display name") + email := fs.String("email", "", "operator contact email (receives the verification code)") + phone := fs.String("phone", "", "operator contact phone (optional)") + keysDir := fs.String("keys", "", "directory holding (or, with -gen-keys, receiving) the 7 key-seed files") + genKeys := fs.Bool("gen-keys", false, "generate a fresh keyset into -keys (fails if one already exists)") + session := fs.String("session", "", "verification session id (default: derived from -slug)") + codeFile := fs.String("code-file", "", "poll this file for the verification code instead of prompting on stdin") + codeWait := fs.Duration("code-wait", 5*time.Minute, "how long to wait for the verification code") + if err := fs.Parse(args); err != nil { + return err + } + + for _, req := range []struct{ v, n string }{ + {*portal, "portal"}, {*slug, "slug"}, {*name, "name"}, {*email, "email"}, {*keysDir, "keys"}, + } { + if req.v == "" { + return fmt.Errorf("-%s is required", req.n) + } + } + sess := *session + if sess == "" { + sess = "enroll-" + *slug + } + + ks, err := loadOrGenKeyset(*keysDir, *genKeys, out) + if err != nil { + return err + } + + codeSrc := stdinCodeSource(out, in) + if *codeFile != "" { + codeSrc = fileCodeSource(*codeFile, *codeWait, out) + } + + c := enroll.NewClient(*portal) + fmt.Fprintf(out, "enrolling %q at %s ...\n", *slug, *portal) + res, err := c.Enroll(context.Background(), enroll.Config{ + Slug: *slug, Name: *name, Email: *email, Phone: *phone, + SessionID: sess, Keyset: ks, Code: codeSrc, + }) + if res != nil { + fmt.Fprintf(out, "keys_registered=%d verified=%v conformance_passed=%v activated=%v run_id=%s\n", + res.KeysRegistered, res.Verified, res.ConformancePassed, res.Activated, res.RunID) + } + if err != nil { + return err + } + if res.Activated { + fmt.Fprintf(out, "OPERATOR ACTIVE: %s\n", res.OperatorID) + return nil + } + fmt.Fprintln(out, "enrollment completed but operator is not yet active") + return nil +} + +func loadOrGenKeyset(dir string, gen bool, out io.Writer) (*opcred.Keyset, error) { + if gen { + ks, err := opcred.GenerateKeyset() + if err != nil { + return nil, fmt.Errorf("generate keyset: %w", err) + } + if err := ks.Save(dir); err != nil { + return nil, fmt.Errorf("save keyset to %s: %w", dir, err) + } + fmt.Fprintf(out, "generated a fresh 7-key set in %s\n", dir) + return ks, nil + } + ks, err := opcred.LoadKeyset(dir) + if err != nil { + return nil, fmt.Errorf("load keyset from %s: %w (use -gen-keys to create one)", dir, err) + } + return ks, nil +} + +func stdinCodeSource(out io.Writer, in io.Reader) enroll.CodeSource { + return enroll.CodeSourceFunc(func(ctx context.Context) (string, error) { + fmt.Fprint(out, "enter the emailed verification code: ") + s := bufio.NewScanner(in) + if !s.Scan() { + if err := s.Err(); err != nil { + return "", err + } + return "", errors.New("no verification code provided on stdin") + } + return strings.TrimSpace(s.Text()), nil + }) +} + +func fileCodeSource(path string, wait time.Duration, out io.Writer) enroll.CodeSource { + return enroll.CodeSourceFunc(func(ctx context.Context) (string, error) { + fmt.Fprintf(out, "waiting up to %s for the verification code in %s ...\n", wait, path) + ctx, cancel := context.WithTimeout(ctx, wait) + defer cancel() + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for { + if b, err := os.ReadFile(path); err == nil { + if code := strings.TrimSpace(string(b)); code != "" { + return code, nil + } + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("timed out waiting for the verification code in %s", path) + case <-t.C: + } + } + }) +} diff --git a/internal/enroll/enroll.go b/internal/enroll/enroll.go new file mode 100644 index 0000000..70bd1f6 --- /dev/null +++ b/internal/enroll/enroll.go @@ -0,0 +1,376 @@ +// Package enroll drives a frontend through the SoHoLINK coordinator's operator +// onboarding lifecycle: +// +// apply → register the 7-key set → email verification → conformance A/B → active +// +// These endpoints are plain JSON on the coordinator's PORTAL surface — NOT the +// /v0 node-side wire that internal/coord speaks — so this package carries its +// own minimal HTTP+JSON client rather than reusing coord.Client. It reuses +// internal/opcred for key custody and for computing conformance answers through +// the real protocol canon. Suite C is graded entirely on the coordinator side +// and needs no operator response, so there is no suite-C step here. +// +// The email verification code is out-of-band: verify/start emails a 6-digit +// code and never returns it in an API response. The caller supplies a +// CodeSource to obtain it (an interactive prompt, a mailbox poll, or — in +// tests and local bring-up — a log scrape). +package enroll + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/opcred" + "github.com/NTARI-RAND/sohocloud-protocol/operator" +) + +// CodeSource yields the out-of-band email verification code. Code blocks until +// the code is available or ctx is done. +type CodeSource interface { + Code(ctx context.Context) (string, error) +} + +// CodeSourceFunc adapts a plain function to a CodeSource. +type CodeSourceFunc func(ctx context.Context) (string, error) + +// Code implements CodeSource. +func (f CodeSourceFunc) Code(ctx context.Context) (string, error) { return f(ctx) } + +// Config parameterizes one enrollment run. +type Config struct { + Slug string // operator id; the coordinator requires ^[a-z0-9-]{1,64}$ + Name string // human-readable operator name + Email string // where the coordinator emails the verification code + Phone string // optional + SessionID string // binds the verification code to this client across start/check + Keyset *opcred.Keyset // the 7-key set to register + Code CodeSource // supplies the emailed verification code +} + +// Result reports the terminal state of an enrollment run. +type Result struct { + OperatorID string + KeysRegistered int + Verified bool + ConformancePassed bool + Activated bool + RunID string +} + +// Client talks to the coordinator's operator onboarding surface at a portal +// base URL (e.g. "http://portal:8080" on the coordinator's own network, since +// the public edge gates /operators/apply behind the federation disclaimer). +type Client struct { + base string + hc *http.Client +} + +// NewClient returns a Client for the coordinator portal at base. +func NewClient(base string) *Client { + return &Client{ + base: strings.TrimRight(base, "/"), + hc: &http.Client{Timeout: 30 * time.Second}, + } +} + +// WithHTTPClient overrides the HTTP client (tests, custom transports/timeouts). +func (c *Client) WithHTTPClient(hc *http.Client) *Client { c.hc = hc; return c } + +// HTTPError is a non-2xx response from the coordinator, carrying the status and +// the coordinator's {"error":"..."} message when present. +type HTTPError struct { + Method string + Path string + Status int + Msg string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.Path, e.Status, e.Msg) +} + +// Status reports the HTTP status of err if it is an *HTTPError, else 0. +func Status(err error) int { + var he *HTTPError + if errors.As(err, &he) { + return he.Status + } + return 0 +} + +type apiError struct { + Err string `json:"error"` +} + +// do performs a JSON request. reqBody nil sends no body; respBody nil discards +// the response body. Non-2xx becomes an *HTTPError. +func (c *Client) do(ctx context.Context, method, path string, reqBody, respBody any) error { + var body io.Reader + if reqBody != nil { + b, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal %s %s: %w", method, path, err) + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+path, body) + if err != nil { + return err + } + if reqBody != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.hc.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + msg := strings.TrimSpace(string(raw)) + var ae apiError + if json.Unmarshal(raw, &ae) == nil && ae.Err != "" { + msg = ae.Err + } + return &HTTPError{Method: method, Path: path, Status: resp.StatusCode, Msg: msg} + } + if respBody != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, respBody); err != nil { + return fmt.Errorf("decode %s %s response: %w", method, path, err) + } + } + return nil +} + +// ---- wire structs (JSON tags are the compatibility contract with the +// coordinator's internal/api onboarding handlers) ---- + +type applyRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone,omitempty"` +} + +// ApplyResponse is the coordinator's reply to a registration. +type ApplyResponse struct { + OperatorID string `json:"operator_id"` + OnboardingState string `json:"onboarding_state"` +} + +type registerKeysRequest struct { + Algo string `json:"algo"` + PublicKeys []string `json:"public_keys"` +} + +type registerKeysResponse struct { + Registered int `json:"registered"` +} + +type verifyStartRequest struct { + Channel string `json:"channel"` + SessionID string `json:"session_id"` +} + +type verifyCheckRequest struct { + Channel string `json:"channel"` + SessionID string `json:"session_id"` + Code string `json:"code"` +} + +type statusResponse struct { + Status string `json:"status"` +} + +type conformanceStartResponse struct { + RunID string `json:"run_id"` + ChallengesA []opcred.ChallengeA `json:"challenges_a"` + ChallengesB []opcred.ChallengeB `json:"challenges_b"` +} + +type conformanceSubmitRequest struct { + SuiteA []opcred.ResponseA `json:"suite_a"` + SuiteB []opcred.ResponseB `json:"suite_b"` +} + +// ChallengeResult is one graded conformance challenge. +type ChallengeResult struct { + ChallengeID string `json:"challenge_id"` + Suite string `json:"suite"` + Passed bool `json:"passed"` + Detail string `json:"detail"` +} + +type conformanceSubmitResponse struct { + Results []ChallengeResult `json:"results"` + Passed bool `json:"passed"` + Activated bool `json:"activated"` +} + +const channelEmail = "email" + +// ---- individual steps (each maps to one coordinator endpoint) ---- + +// Apply registers the operator. A 409 (duplicate slug/email) is returned as an +// *HTTPError so callers can treat a re-run as idempotent. +func (c *Client) Apply(ctx context.Context, cfg Config) (ApplyResponse, error) { + var out ApplyResponse + err := c.do(ctx, http.MethodPost, "/operators/apply", + applyRequest{Slug: cfg.Slug, Name: cfg.Name, Email: cfg.Email, Phone: cfg.Phone}, &out) + return out, err +} + +// RegisterKeys registers the operator's seven Ed25519 public keys, base64-std +// encoded in index order. Re-registering clears any prior conformance pass. +func (c *Client) RegisterKeys(ctx context.Context, slug string, ks *opcred.Keyset) (int, error) { + pubs := ks.PublicKeys() + enc := make([]string, len(pubs)) + for i, p := range pubs { + enc[i] = base64.StdEncoding.EncodeToString(p) + } + var out registerKeysResponse + err := c.do(ctx, http.MethodPost, "/operators/"+slug+"/keys", + registerKeysRequest{Algo: operator.AlgoEd25519, PublicKeys: enc}, &out) + return out.Registered, err +} + +// VerifyStart asks the coordinator to email a fresh verification code bound to +// sessionID. +func (c *Client) VerifyStart(ctx context.Context, slug, sessionID string) error { + return c.do(ctx, http.MethodPost, "/operators/"+slug+"/verify/start", + verifyStartRequest{Channel: channelEmail, SessionID: sessionID}, nil) +} + +// VerifyCheck submits the emailed code for sessionID. +func (c *Client) VerifyCheck(ctx context.Context, slug, sessionID, code string) error { + return c.do(ctx, http.MethodPost, "/operators/"+slug+"/verify/check", + verifyCheckRequest{Channel: channelEmail, SessionID: sessionID, Code: code}, nil) +} + +// ConformanceStart opens a conformance run and returns its challenges. The +// endpoint reads no request body. +func (c *Client) ConformanceStart(ctx context.Context, slug string) (conformanceStartResponse, error) { + var out conformanceStartResponse + err := c.do(ctx, http.MethodPost, "/operators/"+slug+"/conformance/start", nil, &out) + return out, err +} + +// AnswerChallenges computes suite A and B responses from the keyset through the +// real protocol canon (opcred.ConformanceResponder). Suite C needs no response. +func AnswerChallenges(slug string, ks *opcred.Keyset, start conformanceStartResponse) (conformanceSubmitRequest, error) { + r := opcred.NewConformanceResponder(slug, ks) + req := conformanceSubmitRequest{ + SuiteA: make([]opcred.ResponseA, 0, len(start.ChallengesA)), + SuiteB: make([]opcred.ResponseB, 0, len(start.ChallengesB)), + } + for _, ch := range start.ChallengesA { + a, err := r.AnswerA(ch) + if err != nil { + return conformanceSubmitRequest{}, fmt.Errorf("answer suite A %s: %w", ch.ChallengeID, err) + } + req.SuiteA = append(req.SuiteA, a) + } + for _, ch := range start.ChallengesB { + b, err := r.AnswerB(ch) + if err != nil { + return conformanceSubmitRequest{}, fmt.Errorf("answer suite B %s: %w", ch.ChallengeID, err) + } + req.SuiteB = append(req.SuiteB, b) + } + return req, nil +} + +// ConformanceSubmit submits answers for a run and returns the grade. +func (c *Client) ConformanceSubmit(ctx context.Context, slug, runID string, req conformanceSubmitRequest) (conformanceSubmitResponse, error) { + var out conformanceSubmitResponse + err := c.do(ctx, http.MethodPost, "/operators/"+slug+"/conformance/"+runID+"/submit", req, &out) + return out, err +} + +// Enroll runs the whole lifecycle to activation. It is the reusable entry +// point; each step is also exported for callers that need finer control. +// +// A 409 on Apply is tolerated (the operator already exists — a re-run), since +// RegisterKeys and conformance are idempotent enough to complete enrollment +// against an existing pending operator. +func (c *Client) Enroll(ctx context.Context, cfg Config) (*Result, error) { + if cfg.Keyset == nil { + return nil, errors.New("enroll: nil keyset") + } + if cfg.Code == nil { + return nil, errors.New("enroll: nil CodeSource") + } + res := &Result{OperatorID: cfg.Slug} + + applied, err := c.Apply(ctx, cfg) + switch { + case err == nil: + res.OperatorID = applied.OperatorID + case Status(err) == http.StatusConflict: + // Already applied — continue against the existing operator record. + default: + return res, fmt.Errorf("apply: %w", err) + } + + n, err := c.RegisterKeys(ctx, cfg.Slug, cfg.Keyset) + if err != nil { + return res, fmt.Errorf("register keys: %w", err) + } + res.KeysRegistered = n + + if err := c.VerifyStart(ctx, cfg.Slug, cfg.SessionID); err != nil { + return res, fmt.Errorf("verify start: %w", err) + } + code, err := cfg.Code.Code(ctx) + if err != nil { + return res, fmt.Errorf("obtain verification code: %w", err) + } + if err := c.VerifyCheck(ctx, cfg.Slug, cfg.SessionID, strings.TrimSpace(code)); err != nil { + return res, fmt.Errorf("verify check: %w", err) + } + res.Verified = true + + start, err := c.ConformanceStart(ctx, cfg.Slug) + if err != nil { + return res, fmt.Errorf("conformance start: %w", err) + } + res.RunID = start.RunID + answers, err := AnswerChallenges(cfg.Slug, cfg.Keyset, start) + if err != nil { + return res, err + } + grade, err := c.ConformanceSubmit(ctx, cfg.Slug, start.RunID, answers) + if err != nil { + return res, fmt.Errorf("conformance submit: %w", err) + } + res.ConformancePassed = grade.Passed + res.Activated = grade.Activated + if !grade.Passed { + return res, fmt.Errorf("conformance failed: %s", failedDetail(grade.Results)) + } + return res, nil +} + +func failedDetail(results []ChallengeResult) string { + var b strings.Builder + for _, r := range results { + if !r.Passed { + if b.Len() > 0 { + b.WriteString("; ") + } + fmt.Fprintf(&b, "%s/%s: %s", r.Suite, r.ChallengeID, r.Detail) + } + } + if b.Len() == 0 { + return "no per-challenge detail" + } + return b.String() +} diff --git a/internal/enroll/enroll_test.go b/internal/enroll/enroll_test.go new file mode 100644 index 0000000..e042c79 --- /dev/null +++ b/internal/enroll/enroll_test.go @@ -0,0 +1,274 @@ +package enroll + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NTARI-RAND/Cloudy/internal/opcred" + "github.com/NTARI-RAND/sohocloud-protocol/operator" +) + +// mockCoordinator is a minimal stand-in for SoHoLINK's operator onboarding +// surface. It mirrors the real handlers' routes and JSON shapes, and — the +// point of the test — grades conformance with the REAL protocol operator +// package (byte-equality oracle for suite A, ConformanceResponse.Verify for A, +// OperatorTransmission.Verify for B). If the Cloudy client produces answers the +// real verifier rejects, this test fails. +type mockCoordinator struct { + keymap map[int]operator.KeyRecord + chA map[string]opcred.ChallengeA // by challenge_id + chB map[string]opcred.ChallengeB + issuedCode string + verified bool + confPass bool +} + +func newMockCoordinator() *mockCoordinator { + return &mockCoordinator{ + chA: map[string]opcred.ChallengeA{}, + chB: map[string]opcred.ChallengeB{}, + issuedCode: "424242", + } +} + +func (m *mockCoordinator) server() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("POST /operators/apply", func(w http.ResponseWriter, r *http.Request) { + var req applyRequest + _ = json.NewDecoder(r.Body).Decode(&req) + writeJSON(w, 201, ApplyResponse{OperatorID: req.Slug, OnboardingState: "pending_verification"}) + }) + + mux.HandleFunc("POST /operators/{id}/keys", func(w http.ResponseWriter, r *http.Request) { + var req registerKeysRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"bad json"}`, 400) + return + } + if len(req.PublicKeys) != operator.KeyIndexCount { + http.Error(w, `{"error":"need exactly 7 keys"}`, 400) + return + } + m.keymap = map[int]operator.KeyRecord{} + for i, b64 := range req.PublicKeys { + raw, err := base64.StdEncoding.DecodeString(b64) + if err != nil || len(raw) != 32 { + http.Error(w, `{"error":"bad key"}`, 400) + return + } + m.keymap[i] = operator.KeyRecord{PublicKey: raw, Algo: operator.AlgoEd25519} + } + m.confPass = false // re-registering clears a prior pass + writeJSON(w, 200, registerKeysResponse{Registered: len(req.PublicKeys)}) + }) + + mux.HandleFunc("POST /operators/{id}/verify/start", func(w http.ResponseWriter, r *http.Request) { + var req verifyStartRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.SessionID == "" { + http.Error(w, `{"error":"session_id required"}`, 400) + return + } + writeJSON(w, 200, statusResponse{Status: "sent"}) + }) + + mux.HandleFunc("POST /operators/{id}/verify/check", func(w http.ResponseWriter, r *http.Request) { + var req verifyCheckRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.Code != m.issuedCode { + http.Error(w, `{"error":"code mismatch"}`, 401) + return + } + m.verified = true + writeJSON(w, 200, statusResponse{Status: "verified"}) + }) + + mux.HandleFunc("POST /operators/{id}/conformance/start", func(w http.ResponseWriter, r *http.Request) { + if len(m.keymap) != operator.KeyIndexCount { + http.Error(w, `{"error":"register all seven keys before starting conformance"}`, 400) + return + } + id := r.PathValue("id") + a := opcred.ChallengeA{ + ChallengeID: "a-1", OperatorID: id, TsUnixNano: 1_700_000_000_000_000_000, + Nonce: bytes.Repeat([]byte{0xA1}, operator.MinNonceLen), Seq: 1, + Algo: operator.AlgoEd25519, Idx0: 0, Idx1: 1, + } + b := opcred.ChallengeB{ + ChallengeID: "b-1", OperatorID: id, TsUnixNano: 1_700_000_000_000_000_001, + Nonce: bytes.Repeat([]byte{0xB2}, operator.MinNonceLen), Seq: 2, + Algo: operator.AlgoEd25519, + } + m.chA[a.ChallengeID] = a + m.chB[b.ChallengeID] = b + writeJSON(w, 200, conformanceStartResponse{ + RunID: "run-1", ChallengesA: []opcred.ChallengeA{a}, ChallengesB: []opcred.ChallengeB{b}, + }) + }) + + mux.HandleFunc("POST /operators/{id}/conformance/{run}/submit", func(w http.ResponseWriter, r *http.Request) { + var req conformanceSubmitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"bad json"}`, 400) + return + } + var results []ChallengeResult + allPass := true + + for _, ra := range req.SuiteA { + ch, ok := m.chA[ra.ChallengeID] + pass := false + detail := "unknown challenge" + if ok { + // Oracle: the canonical bytes the coordinator expects. + oracle := operator.OperatorTransmission{ + OperatorID: ch.OperatorID, TsUnixNano: ch.TsUnixNano, Nonce: ch.Nonce, + Seq: ch.Seq, Algo: ch.Algo, Idx0: ch.Idx0, Idx1: ch.Idx1, + }.CanonicalBytes() + if !bytes.Equal(ra.CanonicalBytes, oracle) { + detail = "canonical bytes mismatch" + } else { + cr := operator.ConformanceResponse{ + OperatorID: ch.OperatorID, Challenge: ra.CanonicalBytes, Algo: ch.Algo, + Idx0: ra.Idx0, Idx1: ra.Idx1, Sig0: ra.Sig0, Sig1: ra.Sig1, + } + if err := cr.Verify(m.keymap); err != nil { + detail = "verify: " + err.Error() + } else { + pass, detail = true, "ok" + } + } + } + allPass = allPass && pass + results = append(results, ChallengeResult{ChallengeID: ra.ChallengeID, Suite: "A", Passed: pass, Detail: detail}) + } + + for _, rb := range req.SuiteB { + ch, ok := m.chB[rb.ChallengeID] + pass := false + detail := "unknown challenge" + if ok { + tx := operator.OperatorTransmission{ + OperatorID: ch.OperatorID, TsUnixNano: ch.TsUnixNano, Nonce: ch.Nonce, + Seq: ch.Seq, Algo: ch.Algo, Idx0: rb.Idx0, Idx1: rb.Idx1, Sig0: rb.Sig0, Sig1: rb.Sig1, + } + if err := tx.Verify(m.keymap); err != nil { + detail = "verify: " + err.Error() + } else { + pass, detail = true, "ok" + } + } + allPass = allPass && pass + results = append(results, ChallengeResult{ChallengeID: rb.ChallengeID, Suite: "B", Passed: pass, Detail: detail}) + } + + // Suite C is graded coordinator-side and always evaluated here. + results = append(results, ChallengeResult{ChallengeID: "c-1", Suite: "C", Passed: true, Detail: "server-side"}) + + m.confPass = allPass + activated := allPass && m.verified + writeJSON(w, 200, conformanceSubmitResponse{Results: results, Passed: allPass, Activated: activated}) + }) + + return httptest.NewServer(mux) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func TestEnroll_HappyPath(t *testing.T) { + mock := newMockCoordinator() + srv := mock.server() + defer srv.Close() + + ks, err := opcred.GenerateKeyset() + if err != nil { + t.Fatalf("GenerateKeyset: %v", err) + } + + c := NewClient(srv.URL) + res, err := c.Enroll(context.Background(), Config{ + Slug: "cloudy-test", + Name: "Cloudy Test", + Email: "ops@cloudy.example", + SessionID: "sess-abc", + Keyset: ks, + Code: CodeSourceFunc(func(context.Context) (string, error) { return mock.issuedCode, nil }), + }) + if err != nil { + t.Fatalf("Enroll: %v", err) + } + if res.KeysRegistered != operator.KeyIndexCount { + t.Errorf("KeysRegistered = %d, want %d", res.KeysRegistered, operator.KeyIndexCount) + } + if !res.Verified { + t.Error("Verified = false, want true") + } + if !res.ConformancePassed { + t.Error("ConformancePassed = false, want true") + } + if !res.Activated { + t.Error("Activated = false, want true") + } + if res.RunID != "run-1" { + t.Errorf("RunID = %q, want run-1", res.RunID) + } + if !mock.verified || !mock.confPass { + t.Errorf("coordinator state: verified=%v confPass=%v", mock.verified, mock.confPass) + } +} + +// TestEnroll_ForeignKeysRejected proves the mock's grading is real, not a +// rubber stamp: answers signed by a keyset OTHER than the registered one must +// fail the coordinator's protocol Verify. +func TestEnroll_ForeignKeysRejected(t *testing.T) { + mock := newMockCoordinator() + srv := mock.server() + defer srv.Close() + + registered, _ := opcred.GenerateKeyset() + foreign, _ := opcred.GenerateKeyset() + c := NewClient(srv.URL) + ctx := context.Background() + + if _, err := c.RegisterKeys(ctx, "op", registered); err != nil { + t.Fatalf("RegisterKeys: %v", err) + } + start, err := c.ConformanceStart(ctx, "op") + if err != nil { + t.Fatalf("ConformanceStart: %v", err) + } + // Answer with the WRONG keyset. + answers, err := AnswerChallenges("op", foreign, start) + if err != nil { + t.Fatalf("AnswerChallenges: %v", err) + } + grade, err := c.ConformanceSubmit(ctx, "op", start.RunID, answers) + if err != nil { + t.Fatalf("ConformanceSubmit: %v", err) + } + if grade.Passed { + t.Fatal("conformance passed with foreign keys; grading is not verifying signatures") + } +} + +func TestApply_ConflictIsTyped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"operator exists"}`, http.StatusConflict) + })) + defer srv.Close() + c := NewClient(srv.URL) + _, err := c.Apply(context.Background(), Config{Slug: "dup"}) + if Status(err) != http.StatusConflict { + t.Fatalf("Status(err) = %d, want 409 (err=%v)", Status(err), err) + } +}