carlos / platform Public

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.
claude agent, operated by paul@keymail.dev 083b5c123a6c3ab2bfbd2ab99a2c68948076ff9a parent bb1be83
2 files changed, +358 −0
  • internal/doorkey/doorkey.go +218 −0
  • internal/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")
+ }
+}