| 1 | // Package operator verifies private administrative requests independently of |
| 2 | // browser identities and instance bearer credentials. |
| 3 | package operator |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "crypto/elliptic" |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "encoding/hex" |
| 11 | "errors" |
| 12 | "net/url" |
| 13 | "strings" |
| 14 | "time" |
| 15 | "unicode/utf8" |
| 16 | |
| 17 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 18 | "github.com/go-json-experiment/json" |
| 19 | "github.com/go-json-experiment/json/jsontext" |
| 20 | ) |
| 21 | |
| 22 | const ( |
| 23 | BodyLimit = 64 << 10 |
| 24 | InputLimit = 48 << 10 |
| 25 | AuthorizationLimit = 16 << 10 |
| 26 | InputContext = "oficina/home/operator-input/v1" |
| 27 | OwnerContext = "oficina/home/owner-seed/v1" |
| 28 | ) |
| 29 | |
| 30 | var ( |
| 31 | ErrRefused = errors.New("operator: refused") |
| 32 | ErrUnavailable = errors.New("operator: unavailable") |
| 33 | ) |
| 34 | |
| 35 | type Authorization struct { |
| 36 | Version int `json:"version"` |
| 37 | Purpose string `json:"purpose"` |
| 38 | Role string `json:"role"` |
| 39 | Origin string `json:"origin"` |
| 40 | Installation string `json:"installation"` |
| 41 | Registry string `json:"registry"` |
| 42 | Device string `json:"device"` |
| 43 | Sequence int64 `json:"sequence"` |
| 44 | Initial bool `json:"initial_authority"` |
| 45 | Previous string `json:"previous"` |
| 46 | JournalSequence int64 `json:"journal_sequence"` |
| 47 | OperationID string `json:"operation_id"` |
| 48 | Action string `json:"action"` |
| 49 | InputDigest string `json:"input_digest"` |
| 50 | Issued int64 `json:"issued_at"` |
| 51 | Expires int64 `json:"expires_at"` |
| 52 | } |
| 53 | |
| 54 | type Pins struct { |
| 55 | Role, Origin, Installation, Registry, Device string |
| 56 | PublicKey string |
| 57 | Actions []string |
| 58 | } |
| 59 | |
| 60 | type envelope struct { |
| 61 | Version int `json:"v"` |
| 62 | KeyID string `json:"kid"` |
| 63 | Payload string `json:"payload"` |
| 64 | Signature string `json:"signature"` |
| 65 | } |
| 66 | |
| 67 | type wrapper struct { |
| 68 | Version int `json:"version"` |
| 69 | Authorization jsontext.Value `json:"signed_authorization"` |
| 70 | Input jsontext.Value `json:"operation_input"` |
| 71 | } |
| 72 | |
| 73 | type Verifier struct { |
| 74 | pins Pins |
| 75 | key []byte |
| 76 | kid string |
| 77 | actions map[string]bool |
| 78 | } |
| 79 | |
| 80 | // Request's authority and input bytes can only be produced by Verify. Returned |
| 81 | // copies cannot change the verified request retained by the transaction layer. |
| 82 | type Request struct { |
| 83 | authorization Authorization |
| 84 | input []byte |
| 85 | head string |
| 86 | valid bool |
| 87 | } |
| 88 | |
| 89 | func (r Request) Authorization() Authorization { return r.authorization } |
| 90 | func (r Request) Input() []byte { return append([]byte(nil), r.input...) } |
| 91 | func (r Request) Head() string { return r.head } |
| 92 | func (r Request) Valid() bool { return r.valid } |
| 93 | func (r Request) Fresh(now time.Time) bool { |
| 94 | n := now.Unix() |
| 95 | a := r.authorization |
| 96 | return r.valid && n > 0 && a.Issued <= n && n < a.Expires |
| 97 | } |
| 98 | |
| 99 | func CanonicalToken(s string) bool { _, ok := canonical(s, 32); return ok } |
| 100 | func canonical(s string, n int) ([]byte, bool) { |
| 101 | if len(s) > BodyLimit { |
| 102 | return nil, false |
| 103 | } |
| 104 | b, e := base64.RawURLEncoding.Strict().DecodeString(s) |
| 105 | return b, e == nil && (n < 0 || len(b) == n) && base64.RawURLEncoding.EncodeToString(b) == s |
| 106 | } |
| 107 | func Origin(s string) bool { |
| 108 | if len(s) == 0 || len(s) > 253 { |
| 109 | return false |
| 110 | } |
| 111 | u, e := url.Parse(s) |
| 112 | return e == nil && u.Scheme == "https" && u.Host != "" && u.Host == strings.ToLower(u.Host) && u.User == nil && u.Path == "" && u.RawPath == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.RawFragment == "" && !strings.ContainsAny(u.Host, "%\\:") && !strings.HasSuffix(u.Host, ".") && u.String() == s |
| 113 | } |
| 114 | func digest(b []byte) string { |
| 115 | h := sha256.Sum256(b) |
| 116 | return base64.RawURLEncoding.EncodeToString(h[:]) |
| 117 | } |
| 118 | func keyID(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } |
| 119 | func public(encoded string) ([]byte, error) { |
| 120 | b, ok := canonical(encoded, 65) |
| 121 | if !ok { |
| 122 | return nil, ErrRefused |
| 123 | } |
| 124 | x, _ := elliptic.Unmarshal(elliptic.P256(), b) |
| 125 | if x == nil { |
| 126 | return nil, ErrRefused |
| 127 | } |
| 128 | return b, nil |
| 129 | } |
| 130 | func allowedAction(role, a string) bool { |
| 131 | if role == "credential" { |
| 132 | return a == "credential.owner-seed" |
| 133 | } |
| 134 | if role == "directory" { |
| 135 | switch a { |
| 136 | case "directory.inspect", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer": |
| 137 | return true |
| 138 | } |
| 139 | } |
| 140 | return false |
| 141 | } |
| 142 | func NewVerifier(p Pins) (*Verifier, error) { |
| 143 | if !Origin(p.Origin) || !CanonicalToken(p.Installation) || !CanonicalToken(p.Registry) || !CanonicalToken(p.Device) || len(p.Actions) == 0 || len(p.Actions) > 8 { |
| 144 | return nil, ErrRefused |
| 145 | } |
| 146 | k, e := public(p.PublicKey) |
| 147 | if e != nil { |
| 148 | return nil, ErrRefused |
| 149 | } |
| 150 | v := &Verifier{pins: p, key: k, kid: keyID(k), actions: map[string]bool{}} |
| 151 | v.pins.Actions = append([]string(nil), p.Actions...) |
| 152 | for _, a := range p.Actions { |
| 153 | if !allowedAction(p.Role, a) || v.actions[a] { |
| 154 | return nil, ErrRefused |
| 155 | } |
| 156 | v.actions[a] = true |
| 157 | } |
| 158 | return v, nil |
| 159 | } |
| 160 | func strict(b []byte, d any) error { |
| 161 | if !utf8.Valid(b) || json.Unmarshal(b, d, json.RejectUnknownMembers(true)) != nil { |
| 162 | return ErrRefused |
| 163 | } |
| 164 | return nil |
| 165 | } |
| 166 | func encode(v any) ([]byte, error) { |
| 167 | b, e := json.Marshal(v, json.Deterministic(true)) |
| 168 | if e != nil { |
| 169 | return nil, ErrRefused |
| 170 | } |
| 171 | return b, nil |
| 172 | } |
| 173 | func strictCanonical(b []byte, d any) error { |
| 174 | if strict(b, d) != nil { |
| 175 | return ErrRefused |
| 176 | } |
| 177 | canonical, e := encode(d) |
| 178 | if e != nil || !bytes.Equal(b, canonical) { |
| 179 | return ErrRefused |
| 180 | } |
| 181 | return nil |
| 182 | } |
| 183 | func (a Authorization) valid() bool { |
| 184 | return a.Version == 1 && a.Purpose == "operator-input" && allowedAction(a.Role, a.Action) && Origin(a.Origin) && CanonicalToken(a.Installation) && CanonicalToken(a.Registry) && CanonicalToken(a.Device) && CanonicalToken(a.OperationID) && CanonicalToken(a.InputDigest) && a.Sequence > 0 && a.Initial == (a.Sequence == 1) && a.JournalSequence > 0 && ((a.Sequence == 1 && a.Previous == "") || (a.Sequence > 1 && CanonicalToken(a.Previous))) && a.Issued > 0 && a.Expires > a.Issued && a.Expires-a.Issued <= 300 |
| 185 | } |
| 186 | |
| 187 | // Verify deliberately retains an expired but authentic request for exact result |
| 188 | // recovery. A storage mutation must also require Fresh after checking replay. |
| 189 | func (v *Verifier) Verify(raw []byte) (Request, error) { |
| 190 | if v == nil || len(raw) == 0 || len(raw) > BodyLimit { |
| 191 | return Request{}, ErrRefused |
| 192 | } |
| 193 | var w wrapper |
| 194 | if strictCanonical(raw, &w) != nil || w.Version != 1 || len(w.Authorization) == 0 || len(w.Authorization) > AuthorizationLimit || len(w.Input) == 0 || len(w.Input) > InputLimit { |
| 195 | return Request{}, ErrRefused |
| 196 | } |
| 197 | var e envelope |
| 198 | if strictCanonical(w.Authorization, &e) != nil || e.Version != 1 || e.KeyID != v.kid { |
| 199 | return Request{}, ErrRefused |
| 200 | } |
| 201 | payload, ok := canonical(e.Payload, -1) |
| 202 | sig, ok2 := canonical(e.Signature, 64) |
| 203 | if !ok || !ok2 || len(payload) > AuthorizationLimit || !family.Verify(v.key, InputContext, payload, sig) { |
| 204 | return Request{}, ErrRefused |
| 205 | } |
| 206 | var a Authorization |
| 207 | if strictCanonical(payload, &a) != nil || !a.valid() || !v.actions[a.Action] || a.Role != v.pins.Role || a.Origin != v.pins.Origin || a.Installation != v.pins.Installation || a.Registry != v.pins.Registry || a.Device != v.pins.Device || a.InputDigest != digest(w.Input) { |
| 208 | return Request{}, ErrRefused |
| 209 | } |
| 210 | return Request{a, append([]byte(nil), w.Input...), digest(w.Authorization), true}, nil |
| 211 | } |
| 212 | |
| 213 | func DecodeInput(r Request, dst any) error { |
| 214 | if !r.valid || strictCanonical(r.input, dst) != nil { |
| 215 | return ErrRefused |
| 216 | } |
| 217 | return nil |
| 218 | } |
| 219 | |
| 220 | // SignRequest is used by the separate provisioner. It does not grant execution |
| 221 | // authority; the platform still requires fresh owner approval and a held fence. |
| 222 | func SignRequest(k *family.Keypair, a Authorization, input any) ([]byte, error) { |
| 223 | if !signingKey(k) { |
| 224 | return nil, ErrRefused |
| 225 | } |
| 226 | b, e := encode(input) |
| 227 | if e != nil || len(b) > InputLimit { |
| 228 | return nil, ErrRefused |
| 229 | } |
| 230 | a.InputDigest = digest(b) |
| 231 | if !a.valid() { |
| 232 | return nil, ErrRefused |
| 233 | } |
| 234 | p, e := encode(a) |
| 235 | if e != nil { |
| 236 | return nil, e |
| 237 | } |
| 238 | signature, e := family.Sign(k, InputContext, p) |
| 239 | if e != nil { |
| 240 | return nil, ErrUnavailable |
| 241 | } |
| 242 | env, e := encode(envelope{1, keyID(k.SignPub()), base64.RawURLEncoding.EncodeToString(p), base64.RawURLEncoding.EncodeToString(signature)}) |
| 243 | if e != nil || len(env) > AuthorizationLimit { |
| 244 | return nil, ErrRefused |
| 245 | } |
| 246 | out, e := encode(wrapper{1, env, b}) |
| 247 | if e != nil || len(out) > BodyLimit { |
| 248 | return nil, ErrRefused |
| 249 | } |
| 250 | return out, nil |
| 251 | } |
| 252 | |
| 253 | func signingKey(k *family.Keypair) bool { |
| 254 | if k == nil || k.SignPriv == nil { |
| 255 | return false |
| 256 | } |
| 257 | p := k.SignPriv |
| 258 | return p.Curve == elliptic.P256() && p.D != nil && p.D.Sign() > 0 && p.D.Cmp(elliptic.P256().Params().N) < 0 && p.X != nil && p.Y != nil && elliptic.P256().IsOnCurve(p.X, p.Y) |
| 259 | } |
| 260 | |