carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/binary"
7 "encoding/hex"
8 "errors"
9 "strings"
10)
11
12// Signed verdict tags: why the security property is authenticity and not
13// write-permission.
14//
15// The design began with "the app cannot write the carlos-scan tag", enforced
16// by keeping s3:PutObjectTagging out of the app's IAM policy. That rule was
17// necessary, correctly enforced, and — three times over — not the property.
18// There are several ways to attach a tag to an object, and the IAM condition
19// key that covers them (s3:RequestObjectTagKeys) is not populated by every
20// gateway for every route. A browser-direct POST-policy upload against MinIO
21// sets carlos-scan freely with the Deny in place; no condition key describes
22// "this request is a form upload", so IAM cannot be made to reach it.
23//
24// SO WE STOP TRYING TO MAKE THE TAG UNWRITABLE AND MAKE IT UNFORGEABLE. Every
25// verdict carries an HMAC over the facts it asserts, keyed by a secret only
26// the console holds. A tag that does not verify is not a verdict — it reads as
27// unknown, and the object is scanned. An app can still write the bytes; it
28// cannot make them mean anything.
29//
30// This is gateway-independent, which is the point: it closes the routes we
31// found, the ones we did not, and the ones a future S3 implementation adds.
32// The IAM Denys stay, demoted to defence in depth, which is the job they were
33// always suited to.
34//
35// WHAT IT DOES NOT DO. An app can still forge `infected` on its own object and
36// have `scan enforce on` deny reads of it. That is a tenant harming only
37// itself with a credential it already holds, and it is not worth a second
38// mechanism.
39
40// TagSignature is the fourth tag: the one that makes the other three mean
41// something. It is in isScanTag, so the scanner owns and replaces it.
42const TagSignature = "carlos-scan-sig"
43
44// sigFormat is the version prefix on the tag value. A format change gets a new
45// prefix rather than a new parser guess: `v2:...` read by this build is an
46// unknown signature, which reads as no verdict, which rescans. Safe direction.
47const sigFormat = "v1"
48
49// scanFacts is exactly what a verdict asserts, and therefore exactly what the
50// signature covers. Anything a reader will later trust has to be in here — a
51// field left out is a field an attacker may vary freely while keeping a
52// legitimately issued signature.
53type scanFacts struct {
54 bucket string
55 key string
56 version string
57 verdict string
58 at string
59}
60
61// TagSigner signs and verifies verdict tags. It holds the deployment's tag
62// key, and optionally the previous one so a rotation does not invalidate every
63// stored verdict at once.
64type TagSigner struct {
65 current tagKey
66 previous *tagKey
67}
68
69type tagKey struct {
70 secret []byte
71 id string
72}
73
74// ErrNoTagKey is what a deployment gets for asking to scan without a key to
75// sign verdicts with. It is a refusal and not a fallback: signing with nothing
76// would verify every forgery ever written.
77var ErrNoTagKey = errors.New("objstore: no scan tag key (CARLOS_SCAN_TAG_KEY)")
78
79// NewTagSigner builds the signer from the deployment's current key and,
80// optionally, the previous one. Both come from the environment and neither
81// ever leaves the console — unlike the callback key, which is derived per app
82// and delivered, this one is never given to anybody.
83func NewTagSigner(current, previous string) (*TagSigner, error) {
84 if strings.TrimSpace(current) == "" {
85 return nil, ErrNoTagKey
86 }
87 if previous != "" && previous == current {
88 // A rotation that rotated to itself. Accepting it would leave an
89 // operator believing a key had been retired while every signature
90 // still verifies under it.
91 return nil, errors.New("objstore: the previous scan tag key is identical to the current one — that is not a rotation")
92 }
93 s := &TagSigner{current: newTagKey(current)}
94 if previous != "" {
95 p := newTagKey(previous)
96 s.previous = &p
97 }
98 return s, nil
99}
100
101// newTagKey derives the public identifier for a key. It is a domain-separated
102// hash rather than any part of the key itself, so a tag can name which key
103// signed it without the tag becoming a place the key leaks.
104func newTagKey(secret string) tagKey {
105 h := hmac.New(sha256.New, []byte("carlos-scan-tag-key-id"))
106 h.Write([]byte(secret))
107 return tagKey{secret: []byte(secret), id: hex.EncodeToString(h.Sum(nil))[:16]}
108}
109
110// Sign returns the value for the TagSignature tag: v1:<key-id>:<mac>.
111func (s *TagSigner) Sign(f scanFacts) string {
112 return sigFormat + ":" + s.current.id + ":" + hex.EncodeToString(mac(s.current.secret, f))
113}
114
115// Verify reports whether sig is a signature this deployment issued over f, and
116// whether it was issued under the PREVIOUS key — which is valid, and wants
117// re-signing so the old key can eventually be dropped.
118//
119// Every failure mode is the same answer: false. A malformed value, an unknown
120// key id, a truncated MAC and a deliberate forgery are indistinguishable to a
121// caller and should be, because the response to all of them is identical —
122// treat it as no verdict and scan the object.
123func (s *TagSigner) Verify(f scanFacts, sig string) (ok bool, stale bool) {
124 parts := strings.Split(sig, ":")
125 if len(parts) != 3 || parts[0] != sigFormat {
126 return false, false
127 }
128 got, err := hex.DecodeString(parts[2])
129 if err != nil || len(got) != sha256.Size {
130 return false, false
131 }
132 switch parts[1] {
133 case s.current.id:
134 return hmac.Equal(got, mac(s.current.secret, f)), false
135 }
136 if s.previous != nil && parts[1] == s.previous.id {
137 return hmac.Equal(got, mac(s.previous.secret, f)), true
138 }
139 return false, false
140}
141
142// mac is the signature over the facts, under one key.
143//
144// THE ENCODING IS LENGTH-PREFIXED, NOT DELIMITED, and that is not fastidiousness.
145// An S3 object key may contain any byte a delimiter could be — `|`, `:`, a
146// newline, a NUL. With a delimiter, an app that controls its own object keys
147// can arrange for two different (bucket, key, version, verdict, at) tuples to
148// serialise identically, and then a signature legitimately issued for one
149// object is a valid signature for another. Length prefixes make that
150// impossible rather than unlikely.
151//
152// The format label goes in first, so a future encoding cannot collide with
153// this one either.
154func mac(secret []byte, f scanFacts) []byte {
155 h := hmac.New(sha256.New, secret)
156 for _, s := range []string{sigFormat, f.bucket, f.key, f.version, f.verdict, f.at} {
157 var n [4]byte
158 binary.BigEndian.PutUint32(n[:], uint32(len(s)))
159 h.Write(n[:])
160 h.Write([]byte(s))
161 }
162 return h.Sum(nil)
163}
164