|
| 1 | +package runner |
| 2 | + |
| 3 | +import ( |
| 4 | + "errors" |
| 5 | + "fmt" |
| 6 | + "time" |
| 7 | + |
| 8 | + "github.com/gammazero/nexus/v3/router/auth" |
| 9 | + "github.com/gammazero/nexus/v3/wamp" |
| 10 | + "github.com/gammazero/nexus/v3/wamp/crsign" |
| 11 | +) |
| 12 | + |
| 13 | +// ComputeSpec is a non-wire side-step that derives a value from |
| 14 | +// captures and stores it back into captures. Currently only |
| 15 | +// `wampcra_sign` is implemented; future helpers (e.g. cryptosign, |
| 16 | +// hex/base64 transforms) can extend this struct. |
| 17 | +type ComputeSpec struct { |
| 18 | + WAMPCRASign *WAMPCRASignSpec `yaml:"wampcra_sign,omitempty"` |
| 19 | +} |
| 20 | + |
| 21 | +// WAMPCRASignSpec computes an HMAC-SHA256 signature over a previously |
| 22 | +// captured challenge string, using the given secret. The result |
| 23 | +// (base64-encoded) lands in captures under `Capture` so a later |
| 24 | +// `send: [5, "{{$signature}}", {}]` AUTHENTICATE step can use it. |
| 25 | +// |
| 26 | +// Example: |
| 27 | +// |
| 28 | +// - expect: [4, "wampcra", {challenge: "{{$challenge:string}}"}] |
| 29 | +// - compute: |
| 30 | +// wampcra_sign: |
| 31 | +// challenge: "{{$challenge}}" |
| 32 | +// secret: "alice-secret" |
| 33 | +// capture: signature |
| 34 | +// - send: [5, "{{$signature}}", {}] |
| 35 | +type WAMPCRASignSpec struct { |
| 36 | + // Challenge is the challenge string emitted by the router in |
| 37 | + // CHALLENGE.Extra.challenge. Use a placeholder like "{{$challenge}}" |
| 38 | + // to pull a previously captured value. |
| 39 | + Challenge string `yaml:"challenge"` |
| 40 | + // Secret is the user's WAMP-CRA secret as bytes (utf-8 string). |
| 41 | + Secret string `yaml:"secret"` |
| 42 | + // Capture is the placeholder name to store the computed signature |
| 43 | + // under. The signature is the base64 HMAC-SHA256 string. |
| 44 | + Capture string `yaml:"capture"` |
| 45 | +} |
| 46 | + |
| 47 | +// runCompute executes a Compute step, resolving placeholders in its |
| 48 | +// inputs against captures and writing the output back. |
| 49 | +func runCompute(spec *ComputeSpec, captures map[string]any) error { |
| 50 | + if spec == nil { |
| 51 | + return errors.New("nil compute spec") |
| 52 | + } |
| 53 | + if spec.WAMPCRASign != nil { |
| 54 | + s := spec.WAMPCRASign |
| 55 | + if s.Capture == "" { |
| 56 | + return errors.New("wampcra_sign requires `capture` name") |
| 57 | + } |
| 58 | + challenge, err := resolveStringPlaceholder(s.Challenge, captures) |
| 59 | + if err != nil { |
| 60 | + return fmt.Errorf("wampcra_sign challenge: %w", err) |
| 61 | + } |
| 62 | + captures[s.Capture] = crsign.SignChallenge(challenge, []byte(s.Secret)) |
| 63 | + return nil |
| 64 | + } |
| 65 | + return errors.New("compute step has no operation set") |
| 66 | +} |
| 67 | + |
| 68 | +// resolveStringPlaceholder yields s as-is, OR if s is a "{{$name}}" |
| 69 | +// placeholder, the captured value (must be a string). |
| 70 | +func resolveStringPlaceholder(s string, captures map[string]any) (string, error) { |
| 71 | + if !isPlaceholder(s) { |
| 72 | + return s, nil |
| 73 | + } |
| 74 | + body := s[3 : len(s)-2] // strip {{$ and }} |
| 75 | + name := body |
| 76 | + if i := indexByte(body, ':'); i != -1 { |
| 77 | + name = body[:i] |
| 78 | + } |
| 79 | + val, ok := captures[name] |
| 80 | + if !ok { |
| 81 | + return "", fmt.Errorf("placeholder %q references uncaptured name", s) |
| 82 | + } |
| 83 | + str, ok := val.(string) |
| 84 | + if !ok { |
| 85 | + return "", fmt.Errorf("placeholder %q resolved to non-string %T", s, val) |
| 86 | + } |
| 87 | + return str, nil |
| 88 | +} |
| 89 | + |
| 90 | +func indexByte(s string, c byte) int { |
| 91 | + for i := 0; i < len(s); i++ { |
| 92 | + if s[i] == c { |
| 93 | + return i |
| 94 | + } |
| 95 | + } |
| 96 | + return -1 |
| 97 | +} |
| 98 | + |
| 99 | +// AuthSpec is the on-disk YAML auth declaration for a plan. All fields |
| 100 | +// are optional. If the whole block is absent (Auth==nil), the runner |
| 101 | +// uses the legacy default (anonymous-only, role="anonymous"). |
| 102 | +// |
| 103 | +// Example: |
| 104 | +// |
| 105 | +// auth: |
| 106 | +// anonymous: { role: anonymous } |
| 107 | +// ticket: |
| 108 | +// users: |
| 109 | +// tester: { ticket: "ticket-X-1234", role: "user" } |
| 110 | +// wampcra: |
| 111 | +// users: |
| 112 | +// jdoe: { secret: "squeemishosafradge", role: "user" } |
| 113 | +type AuthSpec struct { |
| 114 | + Anonymous *AnonymousSpec `yaml:"anonymous,omitempty"` |
| 115 | + Ticket *TicketSpec `yaml:"ticket,omitempty"` |
| 116 | + WAMPCRA *WAMPCRASpec `yaml:"wampcra,omitempty"` |
| 117 | +} |
| 118 | + |
| 119 | +// AnonymousSpec configures anonymous auth. Empty role defaults to "anonymous". |
| 120 | +type AnonymousSpec struct { |
| 121 | + Role string `yaml:"role,omitempty"` |
| 122 | +} |
| 123 | + |
| 124 | +// TicketSpec configures ticket-based dynamic CR auth with a static |
| 125 | +// authid → ticket+role map (see test/spec_auth_ticket_test.go for the |
| 126 | +// runtime semantics). |
| 127 | +type TicketSpec struct { |
| 128 | + Users map[string]TicketUser `yaml:"users"` |
| 129 | +} |
| 130 | + |
| 131 | +type TicketUser struct { |
| 132 | + Ticket string `yaml:"ticket"` |
| 133 | + Role string `yaml:"role"` |
| 134 | +} |
| 135 | + |
| 136 | +// WAMPCRASpec configures WAMP-CRA auth with a static authid → secret+role map. |
| 137 | +type WAMPCRASpec struct { |
| 138 | + Users map[string]WAMPCRAUser `yaml:"users"` |
| 139 | +} |
| 140 | + |
| 141 | +type WAMPCRAUser struct { |
| 142 | + Secret string `yaml:"secret"` |
| 143 | + Role string `yaml:"role"` |
| 144 | +} |
| 145 | + |
| 146 | +// staticKeyStore implements auth.KeyStore over an in-memory user map |
| 147 | +// loaded from a plan's AuthSpec. It supports both ticket and wampcra |
| 148 | +// authmethods on the same authid, picking the right key by method. |
| 149 | +type staticKeyStore struct { |
| 150 | + tickets map[string]TicketUser // authid → ticket user |
| 151 | + wampcras map[string]WAMPCRAUser // authid → wampcra user |
| 152 | +} |
| 153 | + |
| 154 | +func (ks *staticKeyStore) AuthKey(authid, authmethod string) ([]byte, error) { |
| 155 | + switch authmethod { |
| 156 | + case "ticket": |
| 157 | + u, ok := ks.tickets[authid] |
| 158 | + if !ok { |
| 159 | + return nil, errors.New("no ticket user: " + authid) |
| 160 | + } |
| 161 | + return []byte(u.Ticket), nil |
| 162 | + case "wampcra": |
| 163 | + u, ok := ks.wampcras[authid] |
| 164 | + if !ok { |
| 165 | + return nil, errors.New("no wampcra user: " + authid) |
| 166 | + } |
| 167 | + return []byte(u.Secret), nil |
| 168 | + } |
| 169 | + return nil, errors.New("unsupported authmethod: " + authmethod) |
| 170 | +} |
| 171 | + |
| 172 | +func (ks *staticKeyStore) AuthRole(authid string) (string, error) { |
| 173 | + if u, ok := ks.tickets[authid]; ok { |
| 174 | + return u.Role, nil |
| 175 | + } |
| 176 | + if u, ok := ks.wampcras[authid]; ok { |
| 177 | + return u.Role, nil |
| 178 | + } |
| 179 | + return "", errors.New("no such user: " + authid) |
| 180 | +} |
| 181 | + |
| 182 | +func (ks *staticKeyStore) PasswordInfo(string) (string, int, int) { return "", 0, 0 } |
| 183 | +func (ks *staticKeyStore) Provider() string { return "spec-runner-static" } |
| 184 | + |
| 185 | +// buildAuthenticators converts an AuthSpec into the list of |
| 186 | +// authenticators to wire into the per-plan router. Returns whether |
| 187 | +// anonymous auth should be enabled on the realm and the role to assign |
| 188 | +// to anonymous sessions (empty = use realm's default). |
| 189 | +func buildAuthenticators(spec *AuthSpec) (anonOn bool, anonRole string, auths []auth.Authenticator) { |
| 190 | + if spec == nil { |
| 191 | + // Legacy default: anonymous auth, anonymous role. |
| 192 | + return true, "", nil |
| 193 | + } |
| 194 | + |
| 195 | + ks := &staticKeyStore{ |
| 196 | + tickets: map[string]TicketUser{}, |
| 197 | + wampcras: map[string]WAMPCRAUser{}, |
| 198 | + } |
| 199 | + hasTicketUsers := spec.Ticket != nil && len(spec.Ticket.Users) > 0 |
| 200 | + hasWAMPCRAUsers := spec.WAMPCRA != nil && len(spec.WAMPCRA.Users) > 0 |
| 201 | + |
| 202 | + if hasTicketUsers { |
| 203 | + for id, u := range spec.Ticket.Users { |
| 204 | + ks.tickets[id] = u |
| 205 | + } |
| 206 | + auths = append(auths, auth.NewTicketAuthenticator(ks, time.Second)) |
| 207 | + } |
| 208 | + if hasWAMPCRAUsers { |
| 209 | + for id, u := range spec.WAMPCRA.Users { |
| 210 | + ks.wampcras[id] = u |
| 211 | + } |
| 212 | + auths = append(auths, auth.NewCRAuthenticator(ks, time.Second)) |
| 213 | + } |
| 214 | + if spec.Anonymous != nil { |
| 215 | + anonOn = true |
| 216 | + anonRole = spec.Anonymous.Role |
| 217 | + } |
| 218 | + return anonOn, anonRole, auths |
| 219 | +} |
| 220 | + |
| 221 | +// roleListForRealm returns the roles a per-plan realm needs to declare |
| 222 | +// so that authenticated sessions can be assigned to them. Pulls every |
| 223 | +// role mentioned in the auth spec, plus "anonymous" if anonymous auth |
| 224 | +// is enabled (defaulting role for anon). |
| 225 | +func roleListForRealm(spec *AuthSpec) []string { |
| 226 | + if spec == nil { |
| 227 | + return []string{"anonymous"} |
| 228 | + } |
| 229 | + seen := map[string]struct{}{} |
| 230 | + add := func(r string) { |
| 231 | + if r == "" { |
| 232 | + r = "anonymous" |
| 233 | + } |
| 234 | + seen[r] = struct{}{} |
| 235 | + } |
| 236 | + if spec.Anonymous != nil { |
| 237 | + add(spec.Anonymous.Role) |
| 238 | + } |
| 239 | + if spec.Ticket != nil { |
| 240 | + for _, u := range spec.Ticket.Users { |
| 241 | + add(u.Role) |
| 242 | + } |
| 243 | + } |
| 244 | + if spec.WAMPCRA != nil { |
| 245 | + for _, u := range spec.WAMPCRA.Users { |
| 246 | + add(u.Role) |
| 247 | + } |
| 248 | + } |
| 249 | + out := make([]string, 0, len(seen)) |
| 250 | + for r := range seen { |
| 251 | + out = append(out, r) |
| 252 | + } |
| 253 | + return out |
| 254 | +} |
| 255 | + |
| 256 | +// suppress unused warnings for wamp import if these helpers grow. |
| 257 | +var _ = wamp.URI("") |
0 commit comments