Auth System
Chronos uses two-layer auth: a shared transport bearer proves 'a legitimate Matrix daemon', and an ed25519 agent-DID principal token proves WHICH owner, so alarms are owner-scoped.
Chronos uses two-layer auth. A shared transport bearer proves "a legitimate Matrix daemon", and an ed25519 agent-DID principal token proves which owner, so alarms are owner-scoped and the wake target resolves from the DID alone (invariant i2).
Source files: internal/auth/identity.go, internal/auth/token.go, internal/server/server.go.
Layer 1: Transport auth
Every request to Chronos (except /healthz and /) must carry a Bearer token in the Authorization header. This is the shared secret between Chronos and the MCP proxy (CHRONOS_TOKEN on the daemon side, TransportToken in Chronos config).
The transport middleware uses constant-time comparison:
if subtle.ConstantTimeCompare([]byte(bearerToken(r)), []byte(s.transportToken)) != 1 {
writeFail(w, http.StatusUnauthorized, "missing or invalid transport bearer")
return
}
In dev mode (CHRONOS_DEV=1), transport auth can be disabled (empty token). This is logged as a warning.
Layer 2: Agent-DID principal auth
After transport auth passes, alarm CRUD endpoints require a principal token in the X-Chronos-Agent header. This token is minted after a successful ed25519 challenge/verify flow.
The DID format
did:matrix:<label>:<16-hex-fingerprint>
Where <label> is typically the Supabase user UUID and <16-hex-fingerprint> is the first 16 hex characters of the agent's ed25519 public key.
type DID struct {
Raw string
Label string
KeyFP string // hex(pubkey)[:16]
}
Challenge/Verify flow
1. Agent POSTs /v1/agent/auth/challenge with {"did": "did:matrix:..."}
Chronos generates a random nonce, stores it in-memory with TTL (default 120s)
Returns {did, nonce, message, expires_in}
2. Agent signs message = "matrix-chronos-auth:<did>:<nonce>" with its ed25519 key
Agent POSTs /v1/agent/auth/verify with {did, public_key, nonce, signature}
3. Chronos verifies:
- Nonce exists, not expired, not already consumed (single-use)
- Public key matches the DID fingerprint (hex(pubkey)[:16] == keyfp)
- ed25519.Verify(pubkey, challenge_message, signature) succeeds
Returns {token, owner_user_id, expires_in}
Principal tokens
On successful verify, Chronos mints a short-lived, stateless HMAC token:
type Tokens struct {
key []byte // sha256(agent_auth_secret)
ttl time.Duration // default 24h
}
type Claims struct {
DID string // full agent DID
Owner string // Supabase user UUID
}
Token format: base64url(payload) . base64url(mac) where payload = "<did>|<owner>|<expUnix>".
The HMAC key is derived from AgentAuthSecret via SHA-256. Tokens are stateless (no session store), so they work across Chronos instances.
Token verification
The principal method on the server extracts and verifies the token from X-Chronos-Agent:
func (s *Server) principal(w http.ResponseWriter, r *http.Request) (auth.Claims, bool) {
tok := r.Header.Get("X-Chronos-Agent")
claims, err := s.tokens.Verify(tok)
// ...
}
The verified claims.DID is used for owner-scoping all alarm operations. The claims.Owner (Supabase user UUID) is the wake target.
Owner derivation
When the DID label is a UUID (the standard case), OwnerFromDID returns the lowercased UUID. For non-UUID labels (e.g., dev "executor"), it falls back to the raw label:
func OwnerFromDID(d DID) string {
if IsUUID(d.Label) {
return strings.ToLower(d.Label)
}
return d.Label
}
Two-layer summary
| Layer | Header | Validates | Failure mode |
|---|---|---|---|
| Transport | Authorization: Bearer <token> | "Is this a legitimate Matrix daemon?" | 401 unauthorized |
| Principal | X-Chronos-Agent: <hmac-token> | "Which agent/owner is this?" | 401 unauthorized |
Both layers must pass for alarm CRUD endpoints. Health/root endpoints are public.
Security properties
| Property | Mechanism |
|---|---|
| Nonce single-use | Challenges.Consume atomically deletes the nonce on first use |
| Nonce TTL | Expired nonces are rejected; Purge drops them from memory |
| Fingerprint binding | Public key must match the DID's embedded fingerprint |
| Constant-time comparison | Transport token comparison uses subtle.ConstantTimeCompare |
| Stateless tokens | No session store to compromise; HMAC integrity is sufficient |
| Short token TTL | Default 24h; limits blast radius of token leakage |
