// Package doorkey is the credential for the edge-local door (instance-moves // design ยง8, ruling Q3): an app-scoped key a box verifies offline. It says // which app's processes are calling and confines them to that app's hosts. // The door grants no privilege over the public door (P7 plan, Security // model), so this is identity, not a security boundary, but it is still kept // unforgeable by a bucket writer (discussion #171): the console's database // is the authority, and the bucket holds a signed, revisioned, expiring // projection of it. package doorkey import ( "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "regexp" "strings" "time" "github.com/carlosframework/platform/internal/release" ) const ( // Header carries the key on a door request; the door deletes it before // the request goes anywhere. Header = "X-Carlos-Door-Key" // Prefix is where the signed records live in the deployment bucket. Prefix = "control/door-keys/" // MaxKeys bounds one app's live keys: enough for a rotation per // process, few enough that a record stays small. MaxKeys = 8 // Lifetime is how long a signed record is honoured after signing. The // console re-signs well inside it; a console down for longer closes the // door rather than leaving a revoked key working indefinitely. Lifetime = 72 * time.Hour scheme = "cdk1" // domain separates a door-key signature from every other record the // root release key signs (release pointers, pipelines, system pointers, // grants), all of which sign the bare base64 payload. domain = "carlos door-keys v1\n" ) var ( appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) idRe = regexp.MustCompile(`^[0-9a-f]{16}$`) secretRe = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) ) // RecordKey is one app's record in the deployment bucket. func RecordKey(account, app string) string { return Prefix + account + "/" + app + ".json" } // Token is a parsed door key: cdk1..... type Token struct{ Account, App, ID, Secret string } // Entry is one key as the record stores it: the secret only as its digest. type Entry struct { ID string `json:"id"` SHA256 string `json:"sha256"` Label string `json:"label,omitempty"` CreatedAt time.Time `json:"created_at"` } // Record is one app's signed set of live keys. type Record struct { V int `json:"v"` Account string `json:"account"` App string `json:"app"` Revision uint64 `json:"revision"` Keys []Entry `json:"keys"` SignedAt time.Time `json:"signed_at"` ExpiresAt time.Time `json:"expires_at"` } type signedWrapper struct { Payload string `json:"payload"` Sig string `json:"sig"` } // Digest is the stored form of a key's secret. func Digest(secret string) string { sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } // NewToken mints a key for account/app. raw is shown to the minter once; // entry is what the console stores. func NewToken(account, app string) (raw string, entry Entry, err error) { if !release.ValidAccount(account) || !appRe.MatchString(app) { return "", Entry{}, fmt.Errorf("doorkey: invalid account or app %q/%q", account, app) } var id [8]byte var secret [32]byte if _, err := rand.Read(id[:]); err != nil { return "", Entry{}, err } if _, err := rand.Read(secret[:]); err != nil { return "", Entry{}, err } t := Token{Account: account, App: app, ID: hex.EncodeToString(id[:]), Secret: base64.RawURLEncoding.EncodeToString(secret[:])} raw = strings.Join([]string{scheme, t.Account, t.App, t.ID, t.Secret}, ".") return raw, Entry{ID: t.ID, SHA256: Digest(t.Secret), CreatedAt: time.Now().UTC()}, nil } // ParseToken splits a raw key. No field can contain a dot, so exactly five // parts is unambiguous. func ParseToken(raw string) (Token, error) { f := strings.Split(raw, ".") if len(f) != 5 || f[0] != scheme || !release.ValidAccount(f[1]) || !appRe.MatchString(f[2]) || !idRe.MatchString(f[3]) || !secretRe.MatchString(f[4]) { return Token{}, errors.New("doorkey: malformed door key") } return Token{Account: f[1], App: f[2], ID: f[3], Secret: f[4]}, nil } // Admits answers whether t is one of this record's keys. The record's own // account and app must be the token's, so a key can never pass against a // record filed for another app. func (r Record) Admits(t Token) bool { if r.Account != t.Account || r.App != t.App { return false } want := Digest(t.Secret) for _, e := range r.Keys { if e.ID == t.ID && subtle.ConstantTimeCompare([]byte(e.SHA256), []byte(want)) == 1 { return true } } return false } func (r Record) valid() error { if r.V != 1 || !release.ValidAccount(r.Account) || !appRe.MatchString(r.App) || r.Revision == 0 || r.ExpiresAt.IsZero() || !r.ExpiresAt.After(r.SignedAt) || r.ExpiresAt.Sub(r.SignedAt) > Lifetime { return errors.New("doorkey: record shape") } if len(r.Keys) > MaxKeys { return fmt.Errorf("doorkey: %d keys, at most %d", len(r.Keys), MaxKeys) } for _, e := range r.Keys { if !idRe.MatchString(e.ID) || len(e.SHA256) != 64 { return errors.New("doorkey: record entry shape") } } return nil } // Sign produces the bucket object for rec. func Sign(priv ed25519.PrivateKey, rec Record) ([]byte, error) { if len(priv) != ed25519.PrivateKeySize { return nil, errors.New("doorkey: no signing key") } if err := rec.valid(); err != nil { return nil, err } payload, err := json.Marshal(rec) if err != nil { return nil, err } b64 := base64.StdEncoding.EncodeToString(payload) return json.MarshalIndent(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(domain+b64)))}, "", " ") } // open checks the signature and the shape, not the expiry. func open(pub ed25519.PublicKey, wire []byte) (Record, error) { if len(pub) != ed25519.PublicKeySize { return Record{}, errors.New("doorkey: no public key") } var w signedWrapper if err := json.Unmarshal(wire, &w); err != nil { return Record{}, err } sig, err := base64.StdEncoding.DecodeString(w.Sig) if err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(domain+w.Payload), sig) { return Record{}, errors.New("doorkey: bad signature") } payload, err := base64.StdEncoding.DecodeString(w.Payload) if err != nil { return Record{}, err } var rec Record if err := json.Unmarshal(payload, &rec); err != nil { return Record{}, err } if err := rec.valid(); err != nil { return Record{}, err } return rec, nil } // Verify is the edge's read: signature, shape, and now before ExpiresAt. func Verify(pub ed25519.PublicKey, wire []byte, now time.Time) (Record, error) { rec, err := open(pub, wire) if err != nil { return Record{}, err } if !now.Before(rec.ExpiresAt) { return Record{}, errors.New("doorkey: record expired") } // A record dated in the future would otherwise stay valid for Lifetime // from that date. Only the root key signs, but the bound belongs here. if rec.SignedAt.After(now.Add(MaxClockSkew)) { return Record{}, errors.New("doorkey: record signed in the future") } return rec, nil } // MaxClockSkew is how far ahead of a verifier's clock a record's SignedAt // may be before Verify refuses it. const MaxClockSkew = 5 * time.Minute // VerifyRevision is the publisher's read: the revision a validly signed // record carries, expired or not, so the next publish can stay above it. func VerifyRevision(pub ed25519.PublicKey, wire []byte) (uint64, error) { rec, err := open(pub, wire) if err != nil { return 0, err } return rec.Revision, nil }