oficina / home Public

Add bounded signed operator input and owner receipt verification

Paul Campbell pushed by paul@keymail.dev f80ea16f953accdfc48d3cc0ef105adca38160a2 parent c7f332f
6 files changed, +590 −0
  • go.mod +1 −0
  • go.sum +2 −0
  • internal/operator/owner.go +109 −0
  • internal/operator/owner_test.go +71 −0
  • internal/operator/protocol.go +259 −0
  • internal/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")
+ }
+ }
+}