diff --git a/crates/agentkeys-mock-server/src/handlers/auth_request.rs b/crates/agentkeys-mock-server/src/handlers/auth_request.rs index 5de9917..a3bd68e 100644 --- a/crates/agentkeys-mock-server/src/handlers/auth_request.rs +++ b/crates/agentkeys-mock-server/src/handlers/auth_request.rs @@ -321,7 +321,7 @@ pub async fn approve_auth_request( let (child_session_json, child_wallet) = if request_type == "Pair" { let child_wallet = crate::auth::generate_wallet_address(); let child_token = generate_token(); - let ttl = 3600u64; + let ttl: u64 = 2_592_000; // 30 days per wiki/session-token.md policy // Parse scope from request_details (canonical CBOR contains it) // For mock: create a session with no scope restriction (full access to child wallet) @@ -381,7 +381,7 @@ pub async fn approve_auth_request( if let Some(wallet) = recovered_wallet { let child_token = generate_token(); - let ttl = 3600u64; + let ttl: u64 = 2_592_000; // 30 days per wiki/session-token.md policy // Preserve scope from the most recent session for this wallet let scope_json: Option = db diff --git a/crates/agentkeys-mock-server/src/handlers/session.rs b/crates/agentkeys-mock-server/src/handlers/session.rs index 36b44f6..a174e74 100644 --- a/crates/agentkeys-mock-server/src/handlers/session.rs +++ b/crates/agentkeys-mock-server/src/handlers/session.rs @@ -15,6 +15,15 @@ use crate::{ use agentkeys_types::{AuthToken, Scope}; use ed25519_dalek::SigningKey; +/// Session token TTL in seconds — 30 days. +/// +/// Canonical AgentKeys policy per `wiki/session-token.md`: the bearer token +/// (master CLI or agent daemon) is a **30-day credential**. Agent/child +/// sessions share the same TTL as master for v0. Shorter TTLs for agent +/// sessions may be introduced later as a defense-in-depth tweak, but they +/// MUST align with the policy doc before being applied here. +const DEFAULT_SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60; + #[derive(Deserialize)] pub struct CreateSessionRequest { pub auth_token: String, @@ -57,7 +66,7 @@ pub async fn create_session( db.execute( "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) VALUES (?1, ?2, NULL, NULL, ?3, ?4, 0)", - params![session_token, wallet_address, now, 86400u64], + params![session_token, wallet_address, now, DEFAULT_SESSION_TTL_SECONDS], ) .map_err(|e| AppError::internal(e.to_string()))?; return Ok(Json(CreateSessionResponse { session: session_token, wallet: wallet_address })); @@ -81,7 +90,7 @@ pub async fn create_session( db.execute( "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) VALUES (?1, ?2, NULL, NULL, ?3, ?4, 0)", - params![session_token, wallet_address, now, 86400u64], + params![session_token, wallet_address, now, DEFAULT_SESSION_TTL_SECONDS], ) .map_err(|e| AppError::internal(e.to_string()))?; @@ -144,7 +153,7 @@ pub async fn create_child_session( db.execute( "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)", - params![child_token, child_wallet, parent.token, scope_json, now, 3600u64], + params![child_token, child_wallet, parent.token, scope_json, now, DEFAULT_SESSION_TTL_SECONDS], ) .map_err(|e| AppError::internal(e.to_string()))?; @@ -242,7 +251,7 @@ pub async fn recover_session( db.execute( "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) VALUES (?1, ?2, NULL, ?3, ?4, ?5, 0)", - params![session_token, wallet_address, scope_json, now, 86400u64], + params![session_token, wallet_address, scope_json, now, DEFAULT_SESSION_TTL_SECONDS], ) .map_err(|e| AppError::internal(e.to_string()))?; diff --git a/crates/agentkeys-mock-server/src/test_client.rs b/crates/agentkeys-mock-server/src/test_client.rs index 3bf71a1..c4f8337 100644 --- a/crates/agentkeys-mock-server/src/test_client.rs +++ b/crates/agentkeys-mock-server/src/test_client.rs @@ -168,7 +168,7 @@ impl CredentialBackend for InProcessBackend { wallet: wallet.clone(), scope: None, created_at: 0, - ttl_seconds: 86400, + ttl_seconds: 2_592_000, // 30 days per wiki/session-token.md policy }; Ok((session, wallet)) } @@ -197,7 +197,7 @@ impl CredentialBackend for InProcessBackend { wallet: wallet.clone(), scope: Some(scope), created_at: 0, - ttl_seconds: 3600, + ttl_seconds: 2_592_000, // 30 days per wiki/session-token.md policy }; Ok((session, wallet)) } @@ -585,7 +585,7 @@ impl CredentialBackend for InProcessBackend { let session = body["session"].as_object().map(|_| { let token = body["session"]["token"].as_str().unwrap_or("").to_string(); let wallet = body["session"]["wallet"].as_str().unwrap_or("").to_string(); - let ttl = body["session"]["ttl_seconds"].as_u64().unwrap_or(3600); + let ttl = body["session"]["ttl_seconds"].as_u64().unwrap_or(2_592_000); let created = body["session"]["created_at"].as_u64().unwrap_or(0); Session { token, @@ -650,7 +650,7 @@ impl CredentialBackend for InProcessBackend { wallet: wallet.clone(), scope: None, created_at: 0, - ttl_seconds: 86400, + ttl_seconds: 2_592_000, // 30 days per wiki/session-token.md policy }; Ok((session, wallet)) } diff --git a/docs/spec/1-step-analysis.md b/docs/spec/1-step-analysis.md index 2779416..58f32c4 100644 --- a/docs/spec/1-step-analysis.md +++ b/docs/spec/1-step-analysis.md @@ -121,7 +121,7 @@ AgentKeys' answer is structurally different from 1Password: **we don't hand user | Tier | Lifetime | Storage (original spec) | Storage (corrected, JWT model) | Usage | | --------------------- | ---------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| **Master auth token** | Short (15 min – 24 h, configurable via `AuthOptions.expires_at`) | OS keychain | Plain file or env var (JWT string, not a private key) | Management commands: `agentkeys init`, `store`, `usage`, `teardown`, `approve`. Never used by running agents. | +| **Master auth token** | 30 days (canonical AgentKeys policy per `wiki/session-token.md`; `AuthOptions.expires_at` can shorten per-session) | OS keychain | Plain file or env var (JWT string, not a private key) | Management commands: `agentkeys init`, `store`, `usage`, `teardown`, `approve`. Never used by running agents. | | **Agent auth token** | Long (hours to days) | Sandbox filesystem (`~/.agentkeys/session`, 0600) | Same (JWT string in file, 0600) | MCP Credential Server authentication. Scoped to specific credentials for a specific agent. | @@ -781,10 +781,10 @@ This section explicitly reconciles any points where earlier rounds of this sub-i | **Canonical account name (Round 6)** | **x402 wallet address (EVM), minted in Heima TEE on account creation. Same primary key for master and each child.** | | **Billing model (Round 6)** | **Each account's wallet holds its own USDC. Master funds children. Empty wallet = agent stops. No on-chain spend-limit code needed — the balance IS the limit.** | | Master session storage | OS keychain (Keychain Services / Credential Manager / libsecret), biometric-gated | -| Master session TTL | Short (15 min - 24 h idle, 1P/Enpass style) | +| Master session TTL | 30 days (canonical AgentKeys policy per `wiki/session-token.md`) | | **Agent session storage** | **On stock sandbox: `/home/gem/.agentkeys/session`** (mode 0600, owner gem) + memfd_secret runtime pages + seccomp-bpf process restrictions + daemon with Unix socket (ssh-agent model). **On cloud LLM or custom sandbox: `$HOME/.agentkeys/session`** with the same hardening stack. *(Original Round 6 design specified `/var/lib/agentkeys/session` with dedicated UID + LSM + Landlock — see §3.3a for historical reference, §3.3c for what ships.)* | | **Storage stack order (Round 6)** | **S1 (this Round 6 hardening) → S2 (rolling ratchet) → S3 (provider attestation). S4 and S5 rejected.** | -| Agent session TTL | Long (4 h default, up to 24 h for v0) | +| Agent session TTL | 30 days (same policy as master CLI per `wiki/session-token.md`; may be shortened in a future defense-in-depth tweak) | | Scope | Each agent session bound to its specific service credentials only | | Revocation | Instant via master CLI (`agentkeys revoke 0x...`) | | Recovery | New sandbox runs `agentkeys pair` → master runs `agentkeys approve ` (mints new session for same wallet address). *(Original design used `agentkeys attach agent-A` with direct HTTP push — superseded by rendezvous model.)* | diff --git a/wiki/blockchain-tee-architecture.md b/wiki/blockchain-tee-architecture.md index c8e4408..b2c8600 100644 --- a/wiki/blockchain-tee-architecture.md +++ b/wiki/blockchain-tee-architecture.md @@ -54,7 +54,7 @@ The TEE is a **stateless computation oracle**. It reads chain state, performs cr | Data | Lifetime | How generated | Purpose | | -------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Shielding keypair | Permanent (sealed storage, pubkey registered on chain via `register_enclave()`) | Generated at enclave startup | Encrypt/decrypt credential blobs | -| RSA JWT signing key | Permanent (stored as PKCS#1 DER file) | `RsaPrivateKey::new(&mut rng, 2048)` — randomly generated, NOT derived from a master seed | Sign auth token JWTs issued to clients | +| RSA JWT signing key | Permanent (stored as PKCS#1 DER file) | `RsaPrivateKey::new(&mut rng, 2048)` — randomly generated, NOT derived from a master seed | Sign session tokens (JWT format) issued to clients | | Per-user custodial wallet keys (BTC/ETH/TON) | Permanent (sealed, per `pallet-bitacross` pattern) | Generated per account creation, independently per user | Sign on-chain extrinsics on behalf of user wallets. Private key never leaves the enclave. | | AES response keys | Ephemeral (per-request) | From `RequestAesKey` parameter | Encrypt sensitive responses to specific clients | | Chain state cache (optional) | ≤ 1 block (~6s) | Read from chain | Performance optimization. Not authoritative — chain is truth. | @@ -65,19 +65,19 @@ The TEE is a **stateless computation oracle**. It reads chain state, performs cr **What it does:** - **Decrypt credential blobs** — reads encrypted ciphertext from chain state, decrypts with shielding key, returns plaintext to authorized callers -- **Issue auth tokens (JWTs)** — on successful authentication (Passkey/OAuth/Web3 signature), the TEE signs a JWT containing `{sub: omni_account, typ: ACCESS, exp: timestamp, aud: client_id}` with its RSA private key. The client holds this JWT as a bearer token. Verification is stateless (RSA pubkey check + expiration check). -- **Verify auth tokens** — on every subsequent call, the TEE validates the client's JWT signature and expiration. No session table needed — JWT verification is stateless. +- **Issue session tokens** — on successful authentication (Passkey/OAuth/Web3 signature), the TEE signs a session token (JWT format) containing `{sub: omni_account, typ: ACCESS, exp: timestamp, aud: client_id}` with its RSA private key. The client holds this session token as a bearer credential. Verification is stateless (RSA pubkey check + expiration check). +- **Verify session tokens** — on every subsequent call, the TEE validates the client's session token signature and expiration. No session table needed — verification is stateless. - **Enforce scope** — reads session/account scope from chain, rejects requests outside the scope - **Sign extrinsics** — signs audit events, pair requests, approvals, session management using the user's wallet private key (TEE-held), submits to chain via paymaster - **Rate limit** — enforces per-session read rate caps (connection-level state, not persistent) **What it does NOT do:** -- Store session records (chain does; JWT is stateless) +- Store session records (chain does; session tokens are stateless) - Store credential blobs (chain does) - Store pair requests or approvals (chain does) - Maintain an audit log (chain does) -- Return private keys to clients (clients receive JWTs, not keypairs) +- Return private keys to clients (clients receive session tokens, not keypairs) **Properties the TEE provides:** @@ -102,7 +102,7 @@ The TEE is a **stateless computation oracle**. It reads chain state, performs cr │ - Wallet balances │ │ Does: │ │ │────►│ - Reads chain state │ │ Enforces: │ │ - Decrypts credential blobs │ -│ - TTL (valid_until checks) │ │ - Issues + verifies JWTs │ +│ - TTL (valid_until checks) │ │ - Issues + verifies session tokens│ │ - Replay protection (nonces) │ │ - Signs extrinsics (as user) │ │ - Revocation (flag checks) │ │ - Rate limits │ │ - Immutability (finalized) │ │ - Submits extrinsics async │ @@ -137,9 +137,9 @@ This is the most common operation. An agent daemon needs an API key to call Open ``` 1. daemon → TEE: read_credential(agent=0x44d3, service=openrouter) - authenticated by: JWT (bearer token, issued by TEE on pairing) + authenticated by: session token (bearer credential, issued by TEE on pairing) -2. TEE verifies JWT: +2. TEE verifies session token: - RSA signature valid against TEE's public key? ✅ - exp > current time? ✅ (not expired) - sub = 0x44d3 (matches the requesting agent)? ✅ @@ -330,7 +330,7 @@ v0.1 does **not** keep OTP: with on-chain pair transport, there is no `auth_requ > > **Correction (2026-04-12):** An earlier version of this section described a "session keypair" model where the TEE mints a session keypair and returns the private key to the client. Verification against the actual Heima source (`tee-worker/omni-executor/core/src/auth/auth_token.rs`) shows that Heima uses **JWT-based stateless bearer tokens**, not session keypairs. The client holds a signed JWT string, not a private key. This section has been rewritten to match the actual implementation. -Auth tokens (JWTs) are the connective tissue between client identity and TEE operations. They are **stateless** — the TEE verifies them cryptographically on every call without maintaining a session table. +Session tokens are the connective tissue between client identity and TEE operations. They are **stateless** — the TEE verifies them cryptographically on every call without maintaining a session table. The underlying wire format is JWT (see `AuthTokenClaims` in Heima source). ### Token issuance @@ -345,7 +345,7 @@ TEE verifies client's identity signature TEE creates/looks up OmniAccount (address deterministically derived: OmniAccountConverter::convert(&identity, &client_id)) ↓ -TEE signs a JWT with its RSA private key: +TEE signs a session token (JWT format) with its RSA private key: AuthTokenClaims { sub: "0x9c3e..." (omni account, hex-encoded), typ: "ACCESS", @@ -353,9 +353,9 @@ TEE signs a JWT with its RSA private key: aud: "HEIMA" (client ID) } ↓ -TEE returns JWT string to client +TEE returns session token string to client ↓ -client stores JWT locally +client stores session token locally (a plain string — NOT a private key. Can go in a file, env var, or OS keychain. No keyring-rs, no memfd_secret, no special protection beyond file permissions.) ``` @@ -363,9 +363,9 @@ client stores JWT locally ### Token verification (on every call) ``` -client sends request + JWT to TEE +client sends request + session token to TEE ↓ -TEE verifies JWT: +TEE verifies session token: 1. RSA signature valid? (RSA pubkey derived from TEE's sealed privkey) 2. exp > current time? (not expired) 3. aud matches expected client ID? (audience check) @@ -375,39 +375,39 @@ all pass → extract sub (omni account) → proceed with operation any fail → reject (401 unauthorized) ``` -**No session table, no chain read for auth.** JWT verification is a pure cryptographic check: RSA signature + field validation. The TEE does not maintain a sessions table, does not read chain state to verify the token, and does not need to look up the token in any database. This is the key difference from the session-keypair model described in earlier specs. +**No session table, no chain read for auth.** Session token verification is a pure cryptographic check: RSA signature + field validation. The TEE does not maintain a sessions table, does not read chain state to verify the token, and does not need to look up the token in any database. This is the key difference from the session-keypair model described in earlier specs. Chain state IS still read for **scope** and **credential blobs** — but not for auth token validity. ### Token expiration and refresh ``` -client's JWT expires (exp < current time) +client's session token expires (exp < current time) ↓ client must re-authenticate (Passkey / OAuth / Web3 signature) ↓ -TEE issues a new JWT +TEE issues a new session token ↓ -client replaces old JWT with new one +client replaces old session token with new one ``` There is no "refresh token" in the current Heima implementation. Expiration means re-auth. The `AuthOptions.expires_at` field controls the TTL — **AgentKeys policy is 30 days** (set via `AuthOptions.expires_at`); the Heima client SDK default is ~24h. See [#10](https://github.com/litentry/agentKeys/issues/10) for terminology context. ### Revocation -JWT-based auth has an inherent tradeoff with revocation. Since JWTs are stateless and self-contained, the TEE cannot "revoke" a JWT by flipping a flag — the token is valid until it expires. +Session-token-based auth has an inherent tradeoff with revocation. Since session tokens are stateless and self-contained, the TEE cannot "revoke" a session token by flipping a flag — the token is valid until it expires. Revocation options for AgentKeys v0.1: -1. **Short-lived JWTs + frequent re-auth.** If the JWT TTL is 15 minutes, a revoked agent's token becomes invalid within 15 minutes. No server-side state needed. -2. **On-chain revocation list.** TEE reads a revocation list from chain state on every call (adds ~1-5ms). A revoked agent's `sub` (omni account) is on the list → TEE rejects even though the JWT signature is valid. This gives ~6s revocation latency (one block to update the chain list). +1. **Short-lived session tokens + frequent re-auth.** If the session token TTL is 15 minutes, a revoked agent's token becomes invalid within 15 minutes. No server-side state needed. +2. **On-chain revocation list.** TEE reads a revocation list from chain state on every call (adds ~1-5ms). A revoked agent's `sub` (omni account) is on the list → TEE rejects even though the session token signature is valid. This gives ~6s revocation latency (one block to update the chain list). 3. **TEE-side deny list (bounded cache).** TEE holds a small in-memory deny list of revoked accounts, updated from chain events. Not persistent — survives only until TEE restart. Fastest revocation (~0ms) but weakest durability. -Option 2 (on-chain revocation list) is the most consistent with the "chain is single source of truth" architecture and meets the spec requirement from `heima-open-questions.md Q9` (revocation latency ≤ 1 block). The TEE checks `chain_state.revoked_accounts.contains(jwt.sub)` on every call, adding minimal latency. +Option 2 (on-chain revocation list) is the most consistent with the "chain is single source of truth" architecture and meets the spec requirement from `heima-open-questions.md Q9` (revocation latency ≤ 1 block). The TEE checks `chain_state.revoked_accounts.contains(token.sub)` on every call, adding minimal latency. ``` master CLI → TEE: revoke_agent(agent_account=0x44d3) - authenticated by: master's JWT + authenticated by: master's session token ↓ TEE reads chain state: master owns (is parent of) agent? ✅ ↓ @@ -417,7 +417,7 @@ TEE submits revocation extrinsic: ~6s: chain confirms ↓ next call by the revoked agent: - TEE verifies JWT → valid ✅ + TEE verifies session token → valid ✅ TEE reads chain state → 0x44d3 in revoked_accounts → REJECT ``` @@ -434,7 +434,7 @@ Two architectures for the same product. AgentKeys is choosing the left column; H | | **AgentKeys v0.1: Stateless TEE + chain** | **dexs-backend: Pure TEE backend (Heima's existing model)** | | -------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| **Session state** | Stateless JWTs (signed by TEE, verified cryptographically) + on-chain revocation list | Stateless JWTs (same mechanism — both use `auth_token.rs`) | +| **Session state** | Stateless session tokens (JWT format, signed by TEE, verified cryptographically) + on-chain revocation list | Stateless session tokens (same JWT mechanism — both use `auth_token.rs`) | | **Credential blobs** | On-chain encrypted (`pallet-secrets-vault`) | TEE-internal encrypted storage | | **Audit log** | On-chain events (signed extrinsics) | TEE-internal log or centralized DB | | **Pair state** | On-chain pallet storage | TEE-internal or centralized DB | diff --git a/wiki/data-classification.md b/wiki/data-classification.md index 70a1d90..224422e 100644 --- a/wiki/data-classification.md +++ b/wiki/data-classification.md @@ -21,7 +21,7 @@ Companion docs: | **User wallet private keys** (current model: per-user) | Never | Sealed storage (per `pallet-bitacross`) | Never | | **MSK** (target model: single master key) | Never | Sealed storage (one blob) | Never | | **Derived user private key** (target MSK model) | Never | Ephemeral memory only (derived from MSK, used, discarded) | Never | -| **JWT auth token** | Never | Signed by TEE, not stored after issuance | Plaintext file (mode 0600) or OS keychain | +| **Session token** (JWT-format bearer credential) | Never | Signed by TEE, not stored after issuance | Plaintext file (mode 0600) or OS keychain | | **OmniAccount address** | Plaintext | Derived from identity (not stored separately) | Known (printed by CLI, used in commands) | | **Identity hash** `H(identity_info)` | Plaintext (hashed — original identity is NOT on chain) | Original identity available during auth (from OAuth/Passkey/Web3 proof) | Original identity known to user only | | **Session scope** (which services an agent can access) | Plaintext | Read from chain | Known (displayed by CLI) | @@ -65,7 +65,7 @@ Everything on chain is readable by anyone with a node or block explorer. The cha **Never on chain:** - Any private key (shielding, RSA, wallet, MSK, derived user keys) -- JWT auth tokens +- Session tokens (bearer credentials) - VVC (visual verification codes) - Plaintext credentials - Original identity info (only the hash is stored) @@ -104,7 +104,7 @@ The client holds the minimum needed to authenticate and receive results. **Stored locally:** - Bearer token (formerly "JWT auth token"; rename tracked in [#10](https://github.com/litentry/agentKeys/issues/10)) — plaintext string. Storage: OS keychain when available (master CLI + desktop/Mac-mini daemons per [#12](https://github.com/litentry/agentKeys/issues/12)), plain file (`~/.agentkeys/token`, mode 0600) otherwise. NOT a private key. Leakage gives temporary access bounded by the AgentKeys 30-day policy (Heima SDK default is ~24h). Revocable via on-chain revocation list (~6s on Heima; instant on the v0 mock). -- Child session private key (current v0 model only, stored in `~/.agentkeys/session`, mode 0600). In the target JWT model, this becomes just another JWT string. +- Child session private key (current v0 model only, stored in `~/.agentkeys/session`, mode 0600). In the target session token model, this becomes just another session token string. **Ephemeral memory (during operation only):** @@ -115,7 +115,7 @@ The client holds the minimum needed to authenticate and receive results. - Any TEE private key (shielding, RSA, wallet, MSK) - Credential ciphertext (client never sees the encrypted blob — it asks the TEE, which decrypts and returns plaintext) -- Other users' data (scoped by JWT's `sub` field) +- Other users' data (scoped by the session token's `sub` field) --- @@ -127,7 +127,7 @@ The client holds the minimum needed to authenticate and receive results. | Credential blobs on chain | Encrypted to shielding key (asymmetric, scheme TBD per Heima implementation) | TEE shielding public key | Only the TEE (holds shielding private key) | | Pair approval payload on chain | Encrypted to daemon_pubkey (asymmetric) | Daemon's ephemeral public key (included in pair request) | Only the target daemon (holds its own ephemeral private key) | | TEE sealed storage (shielding key, RSA key, wallet keys, MSK) | SGX sealing (AES-GCM with CPU-derived seal key) | Derived from CPU's seal key + enclave measurement | Only the same enclave on the same CPU (or with the same seal policy) | -| JWT auth token | RSA signature (not encrypted — signed for integrity, readable by anyone) | TEE's RSA private key (signs); RSA public key (verifies) | Anyone can READ the JWT payload (it's base64, not encrypted). Only the TEE can FORGE a valid signature. | +| Session token (JWT-format) | RSA signature (not encrypted — signed for integrity, readable by anyone) | TEE's RSA private key (signs); RSA public key (verifies) | Anyone can READ the session token payload (it's base64, not encrypted). Only the TEE can FORGE a valid signature. | | AES response encryption | AES-GCM (symmetric, per-request) | `RequestAesKey` (ephemeral, per-request) | Only the requesting client (holds the AES key for that request) | | Identity hash on chain | SHA-256 (one-way hash, not encryption) | N/A (hash, not encrypted) | Anyone can read the hash. Nobody can reverse it to the original identity (preimage resistance). | @@ -159,9 +159,9 @@ Plaintext exists NOWHERE after the TEE wipes it. ### Credential read flow ``` -daemon sends: get_credential(openrouter) + JWT [CLIENT → TEE: JWT plaintext] +daemon sends: get_credential(openrouter) + session token [CLIENT → TEE: token plaintext] ↓ -TEE verifies JWT (RSA sig + expiry) [TEE: JWT in memory] +TEE verifies session token (RSA sig + expiry) [TEE: token in memory] TEE reads chain: credential blob for (owner, agent, service) [CHAIN → TEE: ciphertext] TEE decrypts with shielding key [TEE: plaintext in memory] TEE returns plaintext to daemon (over TLS/wss) [TEE → CLIENT: TLS encrypted] @@ -200,7 +200,7 @@ Chain stores: encrypted child session payload [CHAIN: encrypte ↓ Daemon reads approval from chain [CHAIN → CLIENT: encrypted payload] Daemon decrypts with daemon_privkey [CLIENT: child session plaintext] -Daemon stores session locally (JWT or session file, mode 0600) [CLIENT: stored locally] +Daemon stores session locally (session token or session file, mode 0600) [CLIENT: stored locally] ``` --- @@ -210,10 +210,10 @@ Daemon stores session locally (JWT or session file, mode 0600) [CLIENT: stored | Compromise point | What they get | What they DON'T get | Blast radius | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Chain data exfiltration** (read all on-chain state) | All plaintext: addresses, identity hashes, scopes, audit events, pair metadata. Credential ciphertext (unreadable without shielding key). | Any private key. Any plaintext credential. JWT tokens (not on chain). Original identity info (only hash). | Information disclosure only. No ability to decrypt credentials or impersonate users. | -| **Client device compromise** (laptop/sandbox) | JWT string (bearer token). Possibly plaintext credential in memory if timed during a read operation. | Any TEE key. Other users' data. Credential ciphertext. | Impersonate this user until JWT expires (~~24h) or is revoked (~~6s). If credential was in memory, one credential for one service exposed. | -| **JWT theft** | Impersonate the user for JWT's remaining TTL. Scoped by JWT's `sub` (one user) + on-chain scope (specific services). | TEE keys. Other users' sessions. Ability to forge new JWTs. Ability to sign extrinsics (TEE signs, not the client). | Bounded by TTL + scope. Revocable via on-chain revocation list (~6s). | -| **TEE compromise** (enclave breach, side-channel, insider) | All sealed keys (shielding, RSA, wallet/MSK). Can decrypt ALL credential blobs. Can forge JWTs. Can sign extrinsics as any user. | Chain history (already written, immutable). Can't rewrite past audit events. | **Total.** All users, all credentials, all operations. Recovery: rotate shielding key, re-encrypt all credentials, rotate MSK, re-issue all JWTs. The on-chain audit trail survives — forensic investigation of what happened during the breach is possible from chain data. | +| **Chain data exfiltration** (read all on-chain state) | All plaintext: addresses, identity hashes, scopes, audit events, pair metadata. Credential ciphertext (unreadable without shielding key). | Any private key. Any plaintext credential. Session tokens (not on chain). Original identity info (only hash). | Information disclosure only. No ability to decrypt credentials or impersonate users. | +| **Client device compromise** (laptop/sandbox) | Session token (bearer credential). Possibly plaintext credential in memory if timed during a read operation. | Any TEE key. Other users' data. Credential ciphertext. | Impersonate this user until session token expires (~30 days) or is revoked (~6s). If credential was in memory, one credential for one service exposed. | +| **Session token theft** | Impersonate the user for the token's remaining TTL. Scoped by the token's `sub` (one user) + on-chain scope (specific services). | TEE keys. Other users' sessions. Ability to forge new tokens. Ability to sign extrinsics (TEE signs, not the client). | Bounded by TTL + scope. Revocable via on-chain revocation list (~6s). | +| **TEE compromise** (enclave breach, side-channel, insider) | All sealed keys (shielding, RSA, wallet/MSK). Can decrypt ALL credential blobs. Can forge session tokens. Can sign extrinsics as any user. | Chain history (already written, immutable). Can't rewrite past audit events. | **Total.** All users, all credentials, all operations. Recovery: rotate shielding key, re-encrypt all credentials, rotate MSK, re-issue all session tokens. The on-chain audit trail survives — forensic investigation of what happened during the breach is possible from chain data. | | **Paymaster compromise** (treasury drained) | Can stop paying for audit extrinsic submission. Existing credentials and sessions unaffected. | Any key. Any credential. Any ability to impersonate. | Audit events stop appearing on chain. Credential reads still work (TEE serves from chain state). Degraded mode: reads work, audit is paused. | diff --git a/wiki/key-security.md b/wiki/key-security.md index 49815b2..1aab33e 100644 --- a/wiki/key-security.md +++ b/wiki/key-security.md @@ -29,7 +29,7 @@ The important implication: **user credentials never sit on the user's disk in pl ## 2. Where the auth token lives -> **Correction (2026-04-12):** An earlier version of this section was titled "Why the session key goes in the OS keychain" and described storing a session private key in the OS keychain via `keyring-rs`. After verifying against the actual Heima source (`tee-worker/omni-executor/core/src/auth/auth_token.rs`), Heima uses **JWT-based auth tokens**, not session keypairs. The client holds a signed JWT string — a bearer token, not a private key. This changes the storage requirements significantly. +> **Correction (2026-04-12):** An earlier version of this section was titled "Why the session key goes in the OS keychain" and described storing a session private key in the OS keychain via `keyring-rs`. After verifying against the actual Heima source (`tee-worker/omni-executor/core/src/auth/auth_token.rs`), Heima uses **JWT-format session tokens**, not session keypairs. The client holds a signed session token string — a bearer token, not a private key. This changes the storage requirements significantly. ### v0 (current mock): OS keychain or fallback file @@ -39,16 +39,16 @@ Implementation: `crates/agentkeys-cli/src/session_store.rs`. Keyring service is This is what caused the macOS Keychain double-prompt issue that started this investigation (see Section 4). The keychain stores the bearer token as a "generic password" item, and accessing it from a different binary triggers ACL prompts. -### v0.1 (Heima): JWT auth token (keychain recommended, plain file as fallback) +### v0.1 (Heima): session token (keychain recommended, plain file as fallback) -Under the JWT model, the client holds a signed JWT string like `eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIweD...`. This is: +Under the session token model, the client holds a signed token string like `eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIweD...` (JWT format on the wire). This is: - **Not a private key** — it's a signed bearer token. Leaking it gives the attacker temporary access (until expiration), but they cannot forge new tokens or sign extrinsics. -- **Stateless** — the TEE verifies the JWT cryptographically (RSA signature + expiration). No session table lookup needed. +- **Stateless** — the TEE verifies the session token cryptographically (RSA signature + expiration). No session table lookup needed. - **TTL** — configurable via `AuthOptions.expires_at`. **AgentKeys policy: 30 days** (Heima SDK default is ~24h — AgentKeys sets the longer TTL explicitly). A 30-day bearer is high-value and warrants keychain protection + Stage 8 memory hygiene. -- **Reissue-able** — re-authenticate and get a new JWT. +- **Reissue-able** — re-authenticate and get a new session token. -However, a JWT is still a **bearer credential** — anyone with the string can impersonate the user until it expires. The blast radius is bounded (TTL + on-chain revocation list), but it's not zero. Storage recommendations: +However, a session token is still a **bearer credential** — anyone with the string can impersonate the user until it expires. The blast radius is bounded (TTL + on-chain revocation list), but it's not zero. Storage recommendations: | Context | Storage | Why | @@ -59,7 +59,7 @@ However, a JWT is still a **bearer credential** — anyone with the string can i | **CI / testing** | **Env var or plain file** | Ephemeral environment, no keychain. Set `AGENTKEYS_SESSION_STORE=file`. | -The v0 code's dual-path structure (`session_store.rs`: try keychain first, fall back to file) is correct and should be preserved for v0.1 — just storing a JWT string instead of a session JSON blob. +The v0 code's dual-path structure (`session_store.rs`: try keychain first, fall back to file) is correct and should be preserved for v0.1 — just storing a session token string instead of a session JSON blob. What the session token model **does** eliminate (compared to the private-key model): diff --git a/wiki/session-token.md b/wiki/session-token.md index 21259d0..5db4f31 100644 --- a/wiki/session-token.md +++ b/wiki/session-token.md @@ -241,7 +241,7 @@ After revocation, the agent's session token is cryptographically valid (the RSA | Token format | Random 32-byte hex string (opaque bearer) | Signed JWT (RSA, with claims) | | Token issuer | Mock backend (`generate_token()`) | Heima TEE (`jwt::create(&claims, private_key)`) | | Verification | Bearer lookup in SQLite `sessions` table | Stateless RSA signature check (no table) | -| Expiration | TTL field in SQLite (86400s default) | `exp` claim in JWT (configurable, target 30 days) | +| Expiration | TTL field in SQLite (2_592_000s = 30 days default) | `exp` claim in JWT (configurable, target 30 days) | | Revocation | `UPDATE sessions SET revoked=1` in SQLite | On-chain revocation list, ~6s propagation | | Storage (master) | OS keychain via `keyring-rs` | OS keychain (same, storing JWT string instead of random token) | | Storage (daemon) | File fallback at `~/.agentkeys/session.json` | File at `~/.agentkeys/token` (mode 0600) |