Add bounded signed operator input and owner receipt verification
6 files changed,
+590
−0
go.mod+1 −0go.sum+2 −0internal/operator/owner.go+109 −0internal/operator/owner_test.go+71 −0internal/operator/protocol.go+259 −0internal/operator/protocol_test.go+148 −0
CI failed — run details
diff --git a/go.mod b/go.mod| index 96144de..3ed1c6c 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -23,6 +23,7 @@ require ( |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + github.com/sqids/sqids-go v0.4.1 // indirect |
| golang.org/x/sys v0.47.0 // indirect |
| golang.org/x/text v0.41.0 // indirect |
| gorm.io/gorm v1.31.2 // indirect |
diff --git a/go.sum b/go.sum| index d96b208..b63cfca 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -48,6 +48,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/sqids/sqids-go v0.4.1 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw= |
| +github.com/sqids/sqids-go v0.4.1/go.mod h1:EMwHuPQgSNFS0A49jESTfIQS+066XQTVhukrzEPScl8= |
| golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
diff --git a/internal/operator/owner.go b/internal/operator/owner.go| new file mode 100644 |
| index 0000000..533acc4 |
| --- /dev/null |
| +++ b/internal/operator/owner.go |
| @@ -0,0 +1,109 @@ |
| +package operator |
| + |
| +import ( |
| + "encoding/base64" |
| + "strings" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/sqids/sqids-go" |
| +) |
| + |
| +const OwnerLimit = 8 << 10 |
| + |
| +var teamCodec, teamCodecError = sqids.New() |
| + |
| +func TeamSQID(id int64) (string, error) { |
| + if id <= 0 || teamCodecError != nil { |
| + return "", ErrRefused |
| + } |
| + s, e := teamCodec.Encode([]uint64{uint64(id)}) |
| + if e != nil || len(s) == 0 || len(s) > 64 { |
| + return "", ErrRefused |
| + } |
| + return s, nil |
| +} |
| +func validTeam(id int64, s string) bool { |
| + expected, e := TeamSQID(id) |
| + return e == nil && s == expected |
| +} |
| +func PersonRef(s string) bool { |
| + if len(s) != 29 || !strings.HasPrefix(s, "pr_") { |
| + return false |
| + } |
| + for _, c := range s[3:] { |
| + if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +type OwnerReceipt struct { |
| + Version int `json:"version"` |
| + Purpose string `json:"purpose"` |
| + OperationID string `json:"operation_id"` |
| + Issuer string `json:"issuer"` |
| + Directory string `json:"directory"` |
| + TeamID int64 `json:"team_id"` |
| + TeamSQID string `json:"team_sqid"` |
| + PersonRef string `json:"person_ref"` |
| + Role string `json:"role"` |
| + Issued int64 `json:"issued_at"` |
| + Expires int64 `json:"expires_at"` |
| +} |
| + |
| +func (p OwnerReceipt) valid() bool { |
| + return p.Version == 1 && p.Purpose == "owner-seed" && CanonicalToken(p.OperationID) && Origin(p.Issuer) && Origin(p.Directory) && validTeam(p.TeamID, p.TeamSQID) && PersonRef(p.PersonRef) && p.Role == "owner" && p.Issued > 0 && p.Expires > p.Issued && p.Expires-p.Issued <= 300 |
| +} |
| + |
| +func SignOwner(k *family.Keypair, p OwnerReceipt) ([]byte, error) { |
| + if !signingKey(k) || !p.valid() { |
| + return nil, ErrRefused |
| + } |
| + payload, e := encode(p) |
| + if e != nil { |
| + return nil, e |
| + } |
| + sig, e := family.Sign(k, OwnerContext, payload) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + out, e := encode(envelope{1, keyID(k.SignPub()), base64.RawURLEncoding.EncodeToString(payload), base64.RawURLEncoding.EncodeToString(sig)}) |
| + if e != nil || len(out) > OwnerLimit { |
| + return nil, ErrRefused |
| + } |
| + return out, nil |
| +} |
| + |
| +type OwnerVerifier struct { |
| + key []byte |
| + kid, issuer, directory string |
| +} |
| + |
| +func NewOwnerVerifier(publicKey, issuer, directory string) (*OwnerVerifier, error) { |
| + k, e := public(publicKey) |
| + if e != nil || !Origin(issuer) || !Origin(directory) || issuer == directory { |
| + return nil, ErrRefused |
| + } |
| + return &OwnerVerifier{k, keyID(k), issuer, directory}, nil |
| +} |
| +func (v *OwnerVerifier) Verify(raw []byte, now time.Time) (OwnerReceipt, error) { |
| + if v == nil || len(raw) == 0 || len(raw) > OwnerLimit { |
| + return OwnerReceipt{}, ErrRefused |
| + } |
| + var env envelope |
| + if strictCanonical(raw, &env) != nil || env.Version != 1 || env.KeyID != v.kid { |
| + return OwnerReceipt{}, ErrRefused |
| + } |
| + p, ok := canonical(env.Payload, -1) |
| + sig, ok2 := canonical(env.Signature, 64) |
| + if !ok || !ok2 || len(p) > OwnerLimit || !family.Verify(v.key, OwnerContext, p, sig) { |
| + return OwnerReceipt{}, ErrRefused |
| + } |
| + var receipt OwnerReceipt |
| + if strictCanonical(p, &receipt) != nil || !receipt.valid() || receipt.Issuer != v.issuer || receipt.Directory != v.directory || now.Unix() <= 0 || receipt.Issued > now.Unix() || now.Unix() >= receipt.Expires { |
| + return OwnerReceipt{}, ErrRefused |
| + } |
| + return receipt, nil |
| +} |
diff --git a/internal/operator/owner_test.go b/internal/operator/owner_test.go| new file mode 100644 |
| index 0000000..57f8881 |
| --- /dev/null |
| +++ b/internal/operator/owner_test.go |
| @@ -0,0 +1,71 @@ |
| +package operator |
| + |
| +import ( |
| + "encoding/base64" |
| + "math" |
| + "testing" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +func TestPinnedSqidVectorsAndReservedInteger(t *testing.T) { |
| + for n, want := range map[int64]string{1: "Uk", 2: "gb", 3: "Ef", 1000: "pnd", math.MaxInt64: "AqkYhRmbHpEX"} { |
| + got, e := TeamSQID(n) |
| + if e != nil || got != want { |
| + t.Fatal(n, got, e) |
| + } |
| + decoded := teamCodec.Decode(got) |
| + if len(decoded) != 1 || decoded[0] != uint64(n) { |
| + t.Fatal("roundtrip") |
| + } |
| + } |
| + for _, n := range []int64{0, -1, math.MinInt64} { |
| + if _, e := TeamSQID(n); e != ErrRefused { |
| + t.Fatal("invalid ID") |
| + } |
| + } |
| + if validTeam(1, "gb") || validTeam(1, "Ukextra") { |
| + t.Fatal("alternate mapping") |
| + } |
| +} |
| +func TestOwnerReceiptBoundIdentityAndFreshness(t *testing.T) { |
| + k, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + p := OwnerReceipt{1, "owner-seed", token(7), "https://id.oficina.works", "https://oficina.works", 1, "Uk", "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", "owner", 2000000000, 2000000300} |
| + raw, e := SignOwner(k, p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + v, e := NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(k.SignPub()), p.Issuer, p.Directory) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + got, e := v.Verify(raw, time.Unix(p.Issued, 0)) |
| + if e != nil || got != p { |
| + t.Fatal(e) |
| + } |
| + for _, n := range []int64{p.Issued - 1, p.Expires, p.Expires + 1} { |
| + if _, e := v.Verify(raw, time.Unix(n, 0)); e != ErrRefused { |
| + t.Fatal("freshness") |
| + } |
| + } |
| + foreign, e := NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(k.SignPub()), p.Issuer, "https://other.oficina.works") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = foreign.Verify(raw, time.Unix(p.Issued, 0)); e != ErrRefused { |
| + t.Fatal("foreign directory") |
| + } |
| + p.TeamSQID = "gb" |
| + if _, e = SignOwner(k, p); e != ErrRefused { |
| + t.Fatal("mismatched team") |
| + } |
| + p.TeamSQID = "Uk" |
| + p.Role = "member" |
| + if _, e = SignOwner(k, p); e != ErrRefused { |
| + t.Fatal("nonowner") |
| + } |
| +} |
diff --git a/internal/operator/protocol.go b/internal/operator/protocol.go| new file mode 100644 |
| index 0000000..d793d7a |
| --- /dev/null |
| +++ b/internal/operator/protocol.go |
| @@ -0,0 +1,259 @@ |
| +// Package operator verifies private administrative requests independently of |
| +// browser identities and instance bearer credentials. |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "crypto/elliptic" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "errors" |
| + "net/url" |
| + "strings" |
| + "time" |
| + "unicode/utf8" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| + "github.com/go-json-experiment/json/jsontext" |
| +) |
| + |
| +const ( |
| + BodyLimit = 64 << 10 |
| + InputLimit = 48 << 10 |
| + AuthorizationLimit = 16 << 10 |
| + InputContext = "oficina/home/operator-input/v1" |
| + OwnerContext = "oficina/home/owner-seed/v1" |
| +) |
| + |
| +var ( |
| + ErrRefused = errors.New("operator: refused") |
| + ErrUnavailable = errors.New("operator: unavailable") |
| +) |
| + |
| +type Authorization struct { |
| + Version int `json:"version"` |
| + Purpose string `json:"purpose"` |
| + Role string `json:"role"` |
| + Origin string `json:"origin"` |
| + Installation string `json:"installation"` |
| + Registry string `json:"registry"` |
| + Device string `json:"device"` |
| + Sequence int64 `json:"sequence"` |
| + Initial bool `json:"initial_authority"` |
| + Previous string `json:"previous"` |
| + JournalSequence int64 `json:"journal_sequence"` |
| + OperationID string `json:"operation_id"` |
| + Action string `json:"action"` |
| + InputDigest string `json:"input_digest"` |
| + Issued int64 `json:"issued_at"` |
| + Expires int64 `json:"expires_at"` |
| +} |
| + |
| +type Pins struct { |
| + Role, Origin, Installation, Registry, Device string |
| + PublicKey string |
| + Actions []string |
| +} |
| + |
| +type envelope struct { |
| + Version int `json:"v"` |
| + KeyID string `json:"kid"` |
| + Payload string `json:"payload"` |
| + Signature string `json:"signature"` |
| +} |
| + |
| +type wrapper struct { |
| + Version int `json:"version"` |
| + Authorization jsontext.Value `json:"signed_authorization"` |
| + Input jsontext.Value `json:"operation_input"` |
| +} |
| + |
| +type Verifier struct { |
| + pins Pins |
| + key []byte |
| + kid string |
| + actions map[string]bool |
| +} |
| + |
| +// Request's authority and input bytes can only be produced by Verify. Returned |
| +// copies cannot change the verified request retained by the transaction layer. |
| +type Request struct { |
| + authorization Authorization |
| + input []byte |
| + head string |
| + valid bool |
| +} |
| + |
| +func (r Request) Authorization() Authorization { return r.authorization } |
| +func (r Request) Input() []byte { return append([]byte(nil), r.input...) } |
| +func (r Request) Head() string { return r.head } |
| +func (r Request) Valid() bool { return r.valid } |
| +func (r Request) Fresh(now time.Time) bool { |
| + n := now.Unix() |
| + a := r.authorization |
| + return r.valid && n > 0 && a.Issued <= n && n < a.Expires |
| +} |
| + |
| +func CanonicalToken(s string) bool { _, ok := canonical(s, 32); return ok } |
| +func canonical(s string, n int) ([]byte, bool) { |
| + if len(s) > BodyLimit { |
| + return nil, false |
| + } |
| + b, e := base64.RawURLEncoding.Strict().DecodeString(s) |
| + return b, e == nil && (n < 0 || len(b) == n) && base64.RawURLEncoding.EncodeToString(b) == s |
| +} |
| +func Origin(s string) bool { |
| + if len(s) == 0 || len(s) > 253 { |
| + return false |
| + } |
| + u, e := url.Parse(s) |
| + 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 |
| +} |
| +func digest(b []byte) string { |
| + h := sha256.Sum256(b) |
| + return base64.RawURLEncoding.EncodeToString(h[:]) |
| +} |
| +func keyID(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } |
| +func public(encoded string) ([]byte, error) { |
| + b, ok := canonical(encoded, 65) |
| + if !ok { |
| + return nil, ErrRefused |
| + } |
| + x, _ := elliptic.Unmarshal(elliptic.P256(), b) |
| + if x == nil { |
| + return nil, ErrRefused |
| + } |
| + return b, nil |
| +} |
| +func allowedAction(role, a string) bool { |
| + if role == "credential" { |
| + return a == "credential.owner-seed" |
| + } |
| + if role == "directory" { |
| + switch a { |
| + case "directory.initialize", "directory.seed-team", "directory.provision-consumer": |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| +func NewVerifier(p Pins) (*Verifier, error) { |
| + if !Origin(p.Origin) || !CanonicalToken(p.Installation) || !CanonicalToken(p.Registry) || !CanonicalToken(p.Device) || len(p.Actions) == 0 || len(p.Actions) > 8 { |
| + return nil, ErrRefused |
| + } |
| + k, e := public(p.PublicKey) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + v := &Verifier{pins: p, key: k, kid: keyID(k), actions: map[string]bool{}} |
| + v.pins.Actions = append([]string(nil), p.Actions...) |
| + for _, a := range p.Actions { |
| + if !allowedAction(p.Role, a) || v.actions[a] { |
| + return nil, ErrRefused |
| + } |
| + v.actions[a] = true |
| + } |
| + return v, nil |
| +} |
| +func strict(b []byte, d any) error { |
| + if !utf8.Valid(b) || json.Unmarshal(b, d, json.RejectUnknownMembers(true)) != nil { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func encode(v any) ([]byte, error) { |
| + b, e := json.Marshal(v, json.Deterministic(true)) |
| + if e != nil { |
| + return nil, ErrRefused |
| + } |
| + return b, nil |
| +} |
| +func strictCanonical(b []byte, d any) error { |
| + if strict(b, d) != nil { |
| + return ErrRefused |
| + } |
| + canonical, e := encode(d) |
| + if e != nil || !bytes.Equal(b, canonical) { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| +func (a Authorization) valid() bool { |
| + 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 |
| +} |
| + |
| +// Verify deliberately retains an expired but authentic request for exact result |
| +// recovery. A storage mutation must also require Fresh after checking replay. |
| +func (v *Verifier) Verify(raw []byte) (Request, error) { |
| + if v == nil || len(raw) == 0 || len(raw) > BodyLimit { |
| + return Request{}, ErrRefused |
| + } |
| + var w wrapper |
| + if strictCanonical(raw, &w) != nil || w.Version != 1 || len(w.Authorization) == 0 || len(w.Authorization) > AuthorizationLimit || len(w.Input) == 0 || len(w.Input) > InputLimit { |
| + return Request{}, ErrRefused |
| + } |
| + var e envelope |
| + if strictCanonical(w.Authorization, &e) != nil || e.Version != 1 || e.KeyID != v.kid { |
| + return Request{}, ErrRefused |
| + } |
| + payload, ok := canonical(e.Payload, -1) |
| + sig, ok2 := canonical(e.Signature, 64) |
| + if !ok || !ok2 || len(payload) > AuthorizationLimit || !family.Verify(v.key, InputContext, payload, sig) { |
| + return Request{}, ErrRefused |
| + } |
| + var a Authorization |
| + 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) { |
| + return Request{}, ErrRefused |
| + } |
| + return Request{a, append([]byte(nil), w.Input...), digest(w.Authorization), true}, nil |
| +} |
| + |
| +func DecodeInput(r Request, dst any) error { |
| + if !r.valid || strictCanonical(r.input, dst) != nil { |
| + return ErrRefused |
| + } |
| + return nil |
| +} |
| + |
| +// SignRequest is used by the separate provisioner. It does not grant execution |
| +// authority; the platform still requires fresh owner approval and a held fence. |
| +func SignRequest(k *family.Keypair, a Authorization, input any) ([]byte, error) { |
| + if !signingKey(k) { |
| + return nil, ErrRefused |
| + } |
| + b, e := encode(input) |
| + if e != nil || len(b) > InputLimit { |
| + return nil, ErrRefused |
| + } |
| + a.InputDigest = digest(b) |
| + if !a.valid() { |
| + return nil, ErrRefused |
| + } |
| + p, e := encode(a) |
| + if e != nil { |
| + return nil, e |
| + } |
| + signature, e := family.Sign(k, InputContext, p) |
| + if e != nil { |
| + return nil, ErrUnavailable |
| + } |
| + env, e := encode(envelope{1, keyID(k.SignPub()), base64.RawURLEncoding.EncodeToString(p), base64.RawURLEncoding.EncodeToString(signature)}) |
| + if e != nil || len(env) > AuthorizationLimit { |
| + return nil, ErrRefused |
| + } |
| + out, e := encode(wrapper{1, env, b}) |
| + if e != nil || len(out) > BodyLimit { |
| + return nil, ErrRefused |
| + } |
| + return out, nil |
| +} |
| + |
| +func signingKey(k *family.Keypair) bool { |
| + if k == nil || k.SignPriv == nil { |
| + return false |
| + } |
| + p := k.SignPriv |
| + 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) |
| +} |
diff --git a/internal/operator/protocol_test.go b/internal/operator/protocol_test.go| new file mode 100644 |
| index 0000000..b4bf95e |
| --- /dev/null |
| +++ b/internal/operator/protocol_test.go |
| @@ -0,0 +1,148 @@ |
| +package operator |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +func token(b byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) } |
| +func fixture(t *testing.T) (*family.Keypair, *Verifier, Authorization) { |
| + t.Helper() |
| + k, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + a := Authorization{Version: 1, Purpose: "operator-input", Role: "directory", Origin: "https://oficina.works", Installation: token(1), Registry: token(2), Device: token(3), Sequence: 1, Initial: true, JournalSequence: 1, OperationID: token(4), Action: "directory.initialize", Issued: 2000000000, Expires: 2000000300} |
| + v, e := NewVerifier(Pins{a.Role, a.Origin, a.Installation, a.Registry, a.Device, base64.RawURLEncoding.EncodeToString(k.SignPub()), []string{a.Action}}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return k, v, a |
| +} |
| +func signed(t *testing.T, k *family.Keypair, a Authorization) []byte { |
| + t.Helper() |
| + b, e := SignRequest(k, a, struct { |
| + Generation string `json:"generation"` |
| + }{token(8)}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + return b |
| +} |
| +func TestVerifiedRequestRetainsExpiredRecoveryButNoFreshAuthority(t *testing.T) { |
| + k, v, a := fixture(t) |
| + raw := signed(t, k, a) |
| + r, e := v.Verify(raw) |
| + if e != nil || !r.Valid() { |
| + t.Fatal(e) |
| + } |
| + for _, test := range []struct { |
| + now int64 |
| + want bool |
| + }{{a.Issued - 1, false}, {a.Issued, true}, {a.Expires - 1, true}, {a.Expires, false}, {a.Expires + 1, false}} { |
| + if r.Fresh(time.Unix(test.now, 0)) != test.want { |
| + t.Fatalf("boundary %d", test.now) |
| + } |
| + } |
| + if (Request{}).Valid() || (Request{}).Fresh(time.Unix(a.Issued, 0)) { |
| + t.Fatal("zero authority") |
| + } |
| + copy := r.Input() |
| + copy[0] = '[' |
| + auth := r.Authorization() |
| + auth.Action = "directory.activate-consumer" |
| + var input struct { |
| + Generation string `json:"generation"` |
| + } |
| + if DecodeInput(r, &input) != nil || input.Generation != token(8) || r.Authorization().Action != a.Action { |
| + t.Fatal("mutable verified input") |
| + } |
| + if r.Head() == "" { |
| + t.Fatal("missing stream head") |
| + } |
| +} |
| +func TestSignedScopeCannotCrossRoleOriginOrRegistry(t *testing.T) { |
| + k, v, a := fixture(t) |
| + for _, mutate := range []func(*Authorization){func(a *Authorization) { a.Origin = "https://other.oficina.works" }, func(a *Authorization) { a.Registry = token(9) }, func(a *Authorization) { a.Device = token(9) }, func(a *Authorization) { a.Installation = token(9) }, func(a *Authorization) { a.Action = "directory.seed-team" }, func(a *Authorization) { a.Role = "credential"; a.Action = "credential.owner-seed" }} { |
| + foreign := a |
| + mutate(&foreign) |
| + if _, e := v.Verify(signed(t, k, foreign)); e != ErrRefused { |
| + t.Fatal("foreign scope accepted", e) |
| + } |
| + } |
| + other, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = v.Verify(signed(t, other, a)); e != ErrRefused { |
| + t.Fatal("foreign signer") |
| + } |
| +} |
| +func TestStrictWireTamperPurposeAndErrorPrivacy(t *testing.T) { |
| + k, v, a := fixture(t) |
| + raw := signed(t, k, a) |
| + cases := [][]byte{nil, append(append([]byte{}, raw...), raw...), append([]byte(" "), raw...), []byte(strings.Repeat("x", BodyLimit+1)), bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1,"unknown":"private@example.test"`), 1), bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1,"version":1`), 1), bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1e0`), 1), bytes.Replace(raw, []byte(token(8)), []byte(token(9)), 1)} |
| + var w wrapper |
| + if json.Unmarshal(raw, &w) != nil { |
| + t.Fatal("fixture") |
| + } |
| + var env envelope |
| + if json.Unmarshal(w.Authorization, &env) != nil { |
| + t.Fatal("fixture") |
| + } |
| + payload, _ := canonical(env.Payload, -1) |
| + wrong, e := family.Sign(k, OwnerContext, payload) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + env.Signature = base64.RawURLEncoding.EncodeToString(wrong) |
| + w.Authorization, _ = encode(env) |
| + wrongRaw, _ := encode(w) |
| + cases = append(cases, wrongRaw) |
| + for i, b := range cases { |
| + if _, e := v.Verify(b); e != ErrRefused { |
| + t.Fatalf("case %d accepted or leaked: %v", i, e) |
| + } |
| + } |
| + var dst struct { |
| + Different string `json:"different"` |
| + } |
| + r, _ := v.Verify(raw) |
| + if DecodeInput(r, &dst) != ErrRefused { |
| + t.Fatal("input unknown fields") |
| + } |
| + if strings.Contains(ErrRefused.Error(), "private@") { |
| + t.Fatal("error leaked input") |
| + } |
| +} |
| +func TestConfigurationAndUnsignedActionsRefuse(t *testing.T) { |
| + k, v, a := fixture(t) |
| + _ = k |
| + for _, o := range []string{"http://oficina.works", "https://oficina.works/", "https://oficina.works:443", "https://OFICINA.works", "https://oficina.works?", "https://user@oficina.works", "https://oficina.works."} { |
| + p := v.pins |
| + p.Origin = o |
| + if _, e := NewVerifier(p); e != ErrRefused { |
| + t.Fatal("origin accepted", o) |
| + } |
| + } |
| + for _, action := range []string{"directory.activate-consumer", "directory.verify-consumer", "directory.restore", "raw.sql"} { |
| + p := v.pins |
| + p.Actions = []string{action} |
| + if _, e := NewVerifier(p); e != ErrRefused { |
| + t.Fatal("unapproved action", action) |
| + } |
| + } |
| + for _, mutate := range []func(*Authorization){func(a *Authorization) { a.Sequence = 0 }, func(a *Authorization) { a.Sequence = 2 }, func(a *Authorization) { a.Expires = a.Issued + 301 }, func(a *Authorization) { a.Issued = 0 }, func(a *Authorization) { a.OperationID = "" }, func(a *Authorization) { a.Previous = token(1) }} { |
| + bad := a |
| + mutate(&bad) |
| + if _, e := SignRequest(k, bad, struct{}{}); e != ErrRefused { |
| + t.Fatal("bad claims") |
| + } |
| + } |
| +} |