Matrix logo

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

LayerHeaderValidatesFailure mode
TransportAuthorization: Bearer <token>"Is this a legitimate Matrix daemon?"401 unauthorized
PrincipalX-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

PropertyMechanism
Nonce single-useChallenges.Consume atomically deletes the nonce on first use
Nonce TTLExpired nonces are rejected; Purge drops them from memory
Fingerprint bindingPublic key must match the DID's embedded fingerprint
Constant-time comparisonTransport token comparison uses subtle.ConstantTimeCompare
Stateless tokensNo session store to compromise; HMAC integrity is sufficient
Short token TTLDefault 24h; limits blast radius of token leakage