doorkey: the edge-local door's app-scoped, root-signed, expiring credential (#175 P7)
Task 1 of the P7 plan. Keys are cdk1.<account>.<app>.<id>.<secret>; a record holds only their SHA-256, with a revision and a 72-hour expiry, signed with the root release key over a door-keys domain prefix so it can never verify as any other root-signed record. VerifyRevision is the publisher's read, signature and shape without expiry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Go ahead. (start P7)
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
P7 Task 1: the door-key package.
2 files changed,
+358
−0
internal/doorkey/doorkey.go+218 −0internal/doorkey/doorkey_test.go+140 −0
CI passed — run details
diff --git a/internal/doorkey/doorkey.go b/internal/doorkey/doorkey.go| new file mode 100644 |
| index 000000000..2fe6307c4 |
| --- /dev/null |
| +++ b/internal/doorkey/doorkey.go |
| @@ -0,0 +1,218 @@ |
| +// 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.<account>.<app>.<id>.<secret>. |
| +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") |
| + } |
| + return rec, nil |
| +} |
| + |
| +// 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 |
| +} |
diff --git a/internal/doorkey/doorkey_test.go b/internal/doorkey/doorkey_test.go| new file mode 100644 |
| index 000000000..e21cf880d |
| --- /dev/null |
| +++ b/internal/doorkey/doorkey_test.go |
| @@ -0,0 +1,140 @@ |
| +package doorkey |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func rec1(entry Entry) Record { |
| + now := time.Now().UTC() |
| + return Record{V: 1, Account: "bae", App: "titogo", Revision: 1, Keys: []Entry{entry}, |
| + SignedAt: now, ExpiresAt: now.Add(Lifetime)} |
| +} |
| + |
| +func TestTokenRoundTripAndAdmit(t *testing.T) { |
| + raw, entry, err := NewToken("bae", "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(raw, "cdk1.bae.titogo.") { |
| + t.Fatalf("token %q lacks its prefix", raw) |
| + } |
| + tok, err := ParseToken(raw) |
| + if err != nil || tok.ID != entry.ID { |
| + t.Fatalf("parse %q: %+v %v", raw, tok, err) |
| + } |
| + if !rec1(entry).Admits(tok) { |
| + t.Fatal("the record does not admit its own key") |
| + } |
| + tok.Secret = strings.Repeat("A", 43) |
| + if rec1(entry).Admits(tok) { |
| + t.Fatal("a wrong secret was admitted") |
| + } |
| +} |
| + |
| +func TestRecordAdmitsOnlyItsOwnAccountAndApp(t *testing.T) { |
| + raw, entry, _ := NewToken("bae", "titogo") |
| + tok, _ := ParseToken(raw) |
| + other := rec1(entry) |
| + other.App = "other" |
| + if other.Admits(tok) { |
| + t.Fatal("a record for another app admitted the key") |
| + } |
| +} |
| + |
| +func TestParseTokenRefusesMalformed(t *testing.T) { |
| + good := "0123456789abcdef." + strings.Repeat("A", 43) |
| + for _, raw := range []string{ |
| + "", "cdk1", "cdk2.bae.titogo." + good, "cdk1.BAE.titogo." + good, |
| + "cdk1.bae.titogo.0123456789ABCDEF." + strings.Repeat("A", 43), |
| + "cdk1.bae.titogo.0123456789abcdef.short", "cdk1.bae.titogo." + good + ".extra", |
| + } { |
| + if _, err := ParseToken(raw); err == nil { |
| + t.Errorf("ParseToken(%q) accepted a malformed token", raw) |
| + } |
| + } |
| +} |
| + |
| +func TestSignVerifyExpiryAndKeys(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + _, entry, _ := NewToken("bae", "titogo") |
| + wire, err := Sign(priv, rec1(entry)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := Verify(pub, wire, time.Now()) |
| + if err != nil || got.Revision != 1 || got.Keys[0].ID != entry.ID { |
| + t.Fatalf("Verify = %+v, %v", got, err) |
| + } |
| + if _, err := Verify(pub, wire, time.Now().Add(Lifetime+time.Minute)); err == nil { |
| + t.Fatal("an expired record verified") |
| + } |
| + otherPub, _, _ := ed25519.GenerateKey(rand.Reader) |
| + if _, err := Verify(otherPub, wire, time.Now()); err == nil { |
| + t.Fatal("a record signed by another key verified") |
| + } |
| +} |
| + |
| +// VerifyRevision is the publisher's read: signature and shape, not expiry, |
| +// so an expired record still yields the revision it carried. |
| +func TestVerifyRevisionIgnoresExpiry(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + _, entry, _ := NewToken("bae", "titogo") |
| + r := rec1(entry) |
| + r.Revision = 7 |
| + r.SignedAt = time.Now().Add(-100 * time.Hour) |
| + r.ExpiresAt = r.SignedAt.Add(Lifetime) |
| + wire, err := Sign(priv, r) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rev, err := VerifyRevision(pub, wire); err != nil || rev != 7 { |
| + t.Fatalf("VerifyRevision = %d, %v; want 7", rev, err) |
| + } |
| + if _, err := VerifyRevision(pub, []byte("garbage")); err == nil { |
| + t.Fatal("VerifyRevision accepted garbage") |
| + } |
| +} |
| + |
| +// The domain prefix: a wrapper signed over the bare payload (every other |
| +// root-signed record's shape) must not verify as a door-key record. |
| +func TestVerifyRefusesABareSignedWrapper(t *testing.T) { |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + _, entry, _ := NewToken("bae", "titogo") |
| + payload, _ := json.Marshal(rec1(entry)) |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + wire, _ := json.Marshal(map[string]string{"payload": b64, |
| + "sig": base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64)))}) |
| + if _, err := Verify(pub, wire, time.Now()); err == nil { |
| + t.Fatal("a bare-signed wrapper verified as a door-key record") |
| + } |
| + // And the other direction: a door-key record's signature does not |
| + // verify over its bare payload. |
| + door, _ := Sign(priv, rec1(entry)) |
| + var w struct{ Payload, Sig string } |
| + if err := json.Unmarshal(door, &w); err != nil { |
| + t.Fatal(err) |
| + } |
| + sig, _ := base64.StdEncoding.DecodeString(w.Sig) |
| + if ed25519.Verify(pub, []byte(w.Payload), sig) { |
| + t.Fatal("a door-key signature verifies over the bare payload") |
| + } |
| +} |
| + |
| +func TestSignRefusesTooManyKeys(t *testing.T) { |
| + _, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + _, e, _ := NewToken("bae", "titogo") |
| + r := rec1(e) |
| + for i := 0; i < MaxKeys; i++ { |
| + _, e, _ := NewToken("bae", "titogo") |
| + r.Keys = append(r.Keys, e) |
| + } |
| + if _, err := Sign(priv, r); err == nil { |
| + t.Fatal("signed a record over MaxKeys") |
| + } |
| +} |