| 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. |
| 9 | package doorkey |
| 10 | |
| 11 | import ( |
| 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 | |
| 28 | const ( |
| 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 | |
| 49 | var ( |
| 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. |
| 56 | func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" } |
| 57 | |
| 58 | // Token is a parsed door key: cdk1.<account>.<app>.<id>.<secret>. |
| 59 | type Token struct{ Account, App, ID, Secret string } |
| 60 | |
| 61 | // Entry is one key as the record stores it: the secret only as its digest. |
| 62 | type 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. |
| 70 | type 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 | |
| 80 | type 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. |
| 86 | func 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. |
| 93 | func 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. |
| 113 | func 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. |
| 125 | func (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 | |
| 138 | func (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. |
| 155 | func 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. |
| 172 | func 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. |
| 199 | func 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. |
| 217 | const 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. |
| 221 | func 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 | |