oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1// Package operator verifies private administrative requests independently of
2// browser identities and instance bearer credentials.
3package operator
4
5import (
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
22const (
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
30var (
31 ErrRefused = errors.New("operator: refused")
32 ErrUnavailable = errors.New("operator: unavailable")
33)
34
35type 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
54type Pins struct {
55 Role, Origin, Installation, Registry, Device string
56 PublicKey string
57 Actions []string
58}
59
60type envelope struct {
61 Version int `json:"v"`
62 KeyID string `json:"kid"`
63 Payload string `json:"payload"`
64 Signature string `json:"signature"`
65}
66
67type wrapper struct {
68 Version int `json:"version"`
69 Authorization jsontext.Value `json:"signed_authorization"`
70 Input jsontext.Value `json:"operation_input"`
71}
72
73type 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.
82type Request struct {
83 authorization Authorization
84 input []byte
85 head string
86 valid bool
87}
88
89func (r Request) Authorization() Authorization { return r.authorization }
90func (r Request) Input() []byte { return append([]byte(nil), r.input...) }
91func (r Request) Head() string { return r.head }
92func (r Request) Valid() bool { return r.valid }
93func (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
99func CanonicalToken(s string) bool { _, ok := canonical(s, 32); return ok }
100func 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}
107func 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}
114func digest(b []byte) string {
115 h := sha256.Sum256(b)
116 return base64.RawURLEncoding.EncodeToString(h[:])
117}
118func keyID(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
119func 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}
130func 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}
142func 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}
160func 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}
166func 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}
173func 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}
183func (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.
189func (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
213func 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.
222func 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
253func 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