carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package doorkey is the credential for the edge-local door (instance-moves
2// design §8, ruling Q3): an app-scoped key a box verifies offline. It says
3// which app's processes are calling and confines them to that app's hosts.
4// The door grants no privilege over the public door (P7 plan, Security
5// model), so this is identity, not a security boundary, but it is still kept
6// unforgeable by a bucket writer (discussion #171): the console's database
7// is the authority, and the bucket holds a signed, revisioned, expiring
8// projection of it.
9package doorkey
10
11import (
12 "crypto/ed25519"
13 "crypto/rand"
14 "crypto/sha256"
15 "crypto/subtle"
16 "encoding/base64"
17 "encoding/hex"
18 "encoding/json"
19 "errors"
20 "fmt"
21 "regexp"
22 "strings"
23 "time"
24
25 "github.com/carlosframework/platform/internal/release"
26)
27
28const (
29 // Header carries the key on a door request; the door deletes it before
30 // the request goes anywhere.
31 Header = "X-Carlos-Door-Key"
32 // Prefix is where the signed records live in the deployment bucket.
33 Prefix = "control/door-keys/"
34 // MaxKeys bounds one app's live keys: enough for a rotation per
35 // process, few enough that a record stays small.
36 MaxKeys = 8
37 // Lifetime is how long a signed record is honoured after signing. The
38 // console re-signs well inside it; a console down for longer closes the
39 // door rather than leaving a revoked key working indefinitely.
40 Lifetime = 72 * time.Hour
41
42 scheme = "cdk1"
43 // domain separates a door-key signature from every other record the
44 // root release key signs (release pointers, pipelines, system pointers,
45 // grants), all of which sign the bare base64 payload.
46 domain = "carlos door-keys v1\n"
47)
48
49var (
50 appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
51 idRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
52 secretRe = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
53)
54
55// RecordKey is one app's record in the deployment bucket.
56func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" }
57
58// Token is a parsed door key: cdk1.<account>.<app>.<id>.<secret>.
59type Token struct{ Account, App, ID, Secret string }
60
61// Entry is one key as the record stores it: the secret only as its digest.
62type Entry struct {
63 ID string `json:"id"`
64 SHA256 string `json:"sha256"`
65 Label string `json:"label,omitempty"`
66 CreatedAt time.Time `json:"created_at"`
67}
68
69// Record is one app's signed set of live keys.
70type Record struct {
71 V int `json:"v"`
72 Account string `json:"account"`
73 App string `json:"app"`
74 Revision uint64 `json:"revision"`
75 Keys []Entry `json:"keys"`
76 SignedAt time.Time `json:"signed_at"`
77 ExpiresAt time.Time `json:"expires_at"`
78}
79
80type signedWrapper struct {
81 Payload string `json:"payload"`
82 Sig string `json:"sig"`
83}
84
85// Digest is the stored form of a key's secret.
86func Digest(secret string) string {
87 sum := sha256.Sum256([]byte(secret))
88 return hex.EncodeToString(sum[:])
89}
90
91// NewToken mints a key for account/app. raw is shown to the minter once;
92// entry is what the console stores.
93func NewToken(account, app string) (raw string, entry Entry, err error) {
94 if !release.ValidAccount(account) || !appRe.MatchString(app) {
95 return "", Entry{}, fmt.Errorf("doorkey: invalid account or app %q/%q", account, app)
96 }
97 var id [8]byte
98 var secret [32]byte
99 if _, err := rand.Read(id[:]); err != nil {
100 return "", Entry{}, err
101 }
102 if _, err := rand.Read(secret[:]); err != nil {
103 return "", Entry{}, err
104 }
105 t := Token{Account: account, App: app, ID: hex.EncodeToString(id[:]),
106 Secret: base64.RawURLEncoding.EncodeToString(secret[:])}
107 raw = strings.Join([]string{scheme, t.Account, t.App, t.ID, t.Secret}, ".")
108 return raw, Entry{ID: t.ID, SHA256: Digest(t.Secret), CreatedAt: time.Now().UTC()}, nil
109}
110
111// ParseToken splits a raw key. No field can contain a dot, so exactly five
112// parts is unambiguous.
113func ParseToken(raw string) (Token, error) {
114 f := strings.Split(raw, ".")
115 if len(f) != 5 || f[0] != scheme || !release.ValidAccount(f[1]) || !appRe.MatchString(f[2]) ||
116 !idRe.MatchString(f[3]) || !secretRe.MatchString(f[4]) {
117 return Token{}, errors.New("doorkey: malformed door key")
118 }
119 return Token{Account: f[1], App: f[2], ID: f[3], Secret: f[4]}, nil
120}
121
122// Admits answers whether t is one of this record's keys. The record's own
123// account and app must be the token's, so a key can never pass against a
124// record filed for another app.
125func (r Record) Admits(t Token) bool {
126 if r.Account != t.Account || r.App != t.App {
127 return false
128 }
129 want := Digest(t.Secret)
130 for _, e := range r.Keys {
131 if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 {
132 return true
133 }
134 }
135 return false
136}
137
138func (r Record) valid() error {
139 if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) || r.Revision == 0 ||
140 r.ExpiresAt.IsZero() || !r.ExpiresAt.After(r.SignedAt) || r.ExpiresAt.Sub(r.SignedAt) > Lifetime {
141 return errors.New("doorkey: record shape")
142 }
143 if len(r.Keys) > MaxKeys {
144 return fmt.Errorf("doorkey: %d keys, at most %d", len(r.Keys), MaxKeys)
145 }
146 for _, e := range r.Keys {
147 if !idRe.MatchString(e.ID) || len(e.SHA256) != 64 {
148 return errors.New("doorkey: record entry shape")
149 }
150 }
151 return nil
152}
153
154// Sign produces the bucket object for rec.
155func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) {
156 if len(priv) != ed25519.PrivateKeySize {
157 return nil, errors.New("doorkey: no signing key")
158 }
159 if err := rec.valid(); err != nil {
160 return nil, err
161 }
162 payload, err := json.Marshal(rec)
163 if err != nil {
164 return nil, err
165 }
166 b64 := base64.StdEncoding.EncodeToString(payload)
167 return json.MarshalIndent(signedWrapper{Payload: b64,
168 Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(domain+b64)))}, "", " ")
169}
170
171// open checks the signature and the shape, not the expiry.
172func open(pub ed25519.PublicKey, wire []byte) (Record, error) {
173 if len(pub) != ed25519.PublicKeySize {
174 return Record{}, errors.New("doorkey: no public key")
175 }
176 var w signedWrapper
177 if err := json.Unmarshal(wire, &w); err != nil {
178 return Record{}, err
179 }
180 sig, err := base64.StdEncoding.DecodeString(w.Sig)
181 if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(domain+w.Payload), sig) {
182 return Record{}, errors.New("doorkey: bad signature")
183 }
184 payload, err := base64.StdEncoding.DecodeString(w.Payload)
185 if err != nil {
186 return Record{}, err
187 }
188 var rec Record
189 if err := json.Unmarshal(payload, &rec); err != nil {
190 return Record{}, err
191 }
192 if err := rec.valid(); err != nil {
193 return Record{}, err
194 }
195 return rec, nil
196}
197
198// Verify is the edge's read: signature, shape, and now before ExpiresAt.
199func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) {
200 rec, err := open(pub, wire)
201 if err != nil {
202 return Record{}, err
203 }
204 if !now.Before(rec.ExpiresAt) {
205 return Record{}, errors.New("doorkey: record expired")
206 }
207 // A record dated in the future would otherwise stay valid for Lifetime
208 // from that date. Only the root key signs, but the bound belongs here.
209 if rec.SignedAt.After(now.Add(MaxClockSkew)) {
210 return Record{}, errors.New("doorkey: record signed in the future")
211 }
212 return rec, nil
213}
214
215// MaxClockSkew is how far ahead of a verifier's clock a record's SignedAt
216// may be before Verify refuses it.
217const MaxClockSkew = 5 * time.Minute
218
219// VerifyRevision is the publisher's read: the revision a validly signed
220// record carries, expired or not, so the next publish can stay above it.
221func VerifyRevision(pub ed25519.PublicKey, wire []byte) (uint64, error) {
222 rec, err := open(pub, wire)
223 if err != nil {
224 return 0, err
225 }
226 return rec.Revision, nil
227}
228