diff --git a/docs/UCAN_SCHEMA_PROPOSAL.md b/docs/UCAN_SCHEMA_PROPOSAL.md new file mode 100644 index 0000000..b7405ca --- /dev/null +++ b/docs/UCAN_SCHEMA_PROPOSAL.md @@ -0,0 +1,311 @@ +# UCAN v1.0.0-rc.1 Schema Proposal + +## Overview + +This document proposes schema changes to migrate from JWT-based UCAN to v1.0.0-rc.1 envelope format. + +## Design Principles + +1. **Single Database** - All data in one SQLite for WASM portability +2. **CID-based Lookup** - Primary key is content identifier (immutable) +3. **Binary Storage** - DAG-CBOR envelopes stored as BLOBs +4. **Indexed Fields** - Extract key fields for efficient queries +5. **DID Ownership** - Foreign key to did_documents for access control + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ WASM Plugin (Enclave) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ WebAuthn │ │ DID │ │ UCAN │ │ +│ │ (AuthN) │ │ (Identity) │ │ (AuthZ) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ SQLite Database │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌─────────────────────┐ │ │ +│ │ │credentials │ │did_documents│ │ ucan_delegations │ │ │ +│ │ │ │ │ │ │ ucan_invocations │ │ │ +│ │ │ │ │ │ │ ucan_revocations │ │ │ +│ │ └────────────┘ └────────────┘ └─────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Schema Changes + +### 1. Replace `ucan_tokens` with `ucan_delegations` + +```sql +-- DROP TABLE IF EXISTS ucan_tokens; -- Migration step + +-- UCAN Delegations: v1.0.0-rc.1 delegation envelopes +CREATE TABLE IF NOT EXISTS ucan_delegations ( + id INTEGER PRIMARY KEY, + did_id INTEGER NOT NULL REFERENCES did_documents(id) ON DELETE CASCADE, + + -- Content Identifier (immutable, unique) + cid TEXT NOT NULL UNIQUE, + + -- Sealed envelope (DAG-CBOR encoded) + envelope BLOB NOT NULL, + + -- Extracted fields for indexing/queries + iss TEXT NOT NULL, -- Issuer DID + aud TEXT NOT NULL, -- Audience DID + sub TEXT, -- Subject DID (null = powerline) + cmd TEXT NOT NULL, -- Command (e.g., "/vault/read") + + -- Policy stored as JSON for inspection (actual evaluation uses envelope) + pol TEXT DEFAULT '[]', -- Policy JSON + + -- Temporal fields + nbf TEXT, -- Not before (ISO8601) + exp TEXT, -- Expiration (ISO8601, null = never) + + -- Metadata + is_root INTEGER NOT NULL DEFAULT 0, -- iss == sub + is_powerline INTEGER NOT NULL DEFAULT 0, -- sub IS NULL + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_ucan_delegations_cid ON ucan_delegations(cid); +CREATE INDEX idx_ucan_delegations_did_id ON ucan_delegations(did_id); +CREATE INDEX idx_ucan_delegations_iss ON ucan_delegations(iss); +CREATE INDEX idx_ucan_delegations_aud ON ucan_delegations(aud); +CREATE INDEX idx_ucan_delegations_sub ON ucan_delegations(sub); +CREATE INDEX idx_ucan_delegations_cmd ON ucan_delegations(cmd); +CREATE INDEX idx_ucan_delegations_exp ON ucan_delegations(exp); +``` + +### 2. Add `ucan_invocations` Table + +```sql +-- UCAN Invocations: v1.0.0-rc.1 invocation envelopes (audit log) +CREATE TABLE IF NOT EXISTS ucan_invocations ( + id INTEGER PRIMARY KEY, + did_id INTEGER NOT NULL REFERENCES did_documents(id) ON DELETE CASCADE, + + -- Content Identifier + cid TEXT NOT NULL UNIQUE, + + -- Sealed envelope (DAG-CBOR encoded) + envelope BLOB NOT NULL, + + -- Extracted fields for indexing + iss TEXT NOT NULL, -- Invoker DID + sub TEXT NOT NULL, -- Subject DID + aud TEXT, -- Executor DID (if different from sub) + cmd TEXT NOT NULL, -- Command invoked + + -- Proof chain (JSON array of delegation CIDs) + prf TEXT NOT NULL DEFAULT '[]', + + -- Temporal + exp TEXT, -- Expiration + iat TEXT, -- Issued at + + -- Execution tracking + executed_at TEXT, -- When actually executed + result_cid TEXT, -- CID of receipt (if executed) + + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_ucan_invocations_cid ON ucan_invocations(cid); +CREATE INDEX idx_ucan_invocations_iss ON ucan_invocations(iss); +CREATE INDEX idx_ucan_invocations_sub ON ucan_invocations(sub); +CREATE INDEX idx_ucan_invocations_cmd ON ucan_invocations(cmd); +``` + +### 3. Update `ucan_revocations` (Minor Changes) + +```sql +-- UCAN Revocations: Track revoked delegations +-- (Mostly unchanged, but add invocation reference) +CREATE TABLE IF NOT EXISTS ucan_revocations ( + id INTEGER PRIMARY KEY, + delegation_cid TEXT NOT NULL UNIQUE, -- CID of revoked delegation + revoked_by TEXT NOT NULL, -- Revoker DID + invocation_cid TEXT, -- CID of revocation invocation + reason TEXT, + revoked_at TEXT NOT NULL DEFAULT (datetime('now')), + + FOREIGN KEY (delegation_cid) REFERENCES ucan_delegations(cid) ON DELETE CASCADE +); + +CREATE INDEX idx_ucan_revocations_delegation_cid ON ucan_revocations(delegation_cid); +CREATE INDEX idx_ucan_revocations_revoked_by ON ucan_revocations(revoked_by); +``` + +### 4. Remove `delegations` Table + +The old `delegations` table extracted fields from `ucan_tokens`. In v1.0.0-rc.1, the delegation IS the token. The `ucan_delegations` table replaces both. + +```sql +-- DROP TABLE IF EXISTS delegations; -- Migration step +``` + +## Query Examples + +### Get Delegation by CID (for go-ucan Loader) + +```sql +-- name: GetDelegationByCID :one +SELECT envelope FROM ucan_delegations WHERE cid = ? LIMIT 1; +``` + +### List Delegations Granted TO a DID (audience) + +```sql +-- name: ListDelegationsToAudience :many +SELECT * FROM ucan_delegations +WHERE aud = ? AND (exp IS NULL OR exp > datetime('now')) +ORDER BY created_at DESC; +``` + +### List Delegations Granted BY a DID (issuer) + +```sql +-- name: ListDelegationsByIssuer :many +SELECT * FROM ucan_delegations +WHERE iss = ? AND (exp IS NULL OR exp > datetime('now')) +ORDER BY created_at DESC; +``` + +### Find Delegations for a Command + +```sql +-- name: ListDelegationsForCommand :many +SELECT * FROM ucan_delegations +WHERE did_id = ? + AND (cmd = ? OR cmd = '/' OR ? LIKE cmd || '/%') + AND (exp IS NULL OR exp > datetime('now')) +ORDER BY created_at DESC; +``` + +### Check if Delegation is Revoked + +```sql +-- name: IsDelegationRevoked :one +SELECT EXISTS(SELECT 1 FROM ucan_revocations WHERE delegation_cid = ?) as revoked; +``` + +## Go Integration + +### Delegation Loader Implementation + +```go +// internal/keybase/ucan_loader.go + +package keybase + +import ( + "context" + "fmt" + + "github.com/ipfs/go-cid" + "github.com/ucan-wg/go-ucan/token/delegation" +) + +// DelegationLoader implements delegation.Loader for go-ucan +type DelegationLoader struct { + queries *Queries +} + +func NewDelegationLoader(queries *Queries) *DelegationLoader { + return &DelegationLoader{queries: queries} +} + +// GetDelegation implements delegation.Loader +func (l *DelegationLoader) GetDelegation(c cid.Cid) (*delegation.Token, error) { + ctx := context.Background() + + envelope, err := l.queries.GetDelegationEnvelopeByCID(ctx, c.String()) + if err != nil { + return nil, fmt.Errorf("delegation not found: %s", c) + } + + // Decode DAG-CBOR envelope to delegation token + return delegation.FromSealed(envelope) +} +``` + +### Action Manager Integration + +```go +// internal/keybase/actions_delegation_v2.go + +type DelegationV2Result struct { + CID string `json:"cid"` + Issuer string `json:"iss"` + Audience string `json:"aud"` + Subject string `json:"sub,omitempty"` + Command string `json:"cmd"` + Policy string `json:"pol"` + ExpiresAt string `json:"exp,omitempty"` + IsRoot bool `json:"is_root"` + CreatedAt string `json:"created_at"` +} + +func (am *ActionManager) StoreDelegation(ctx context.Context, sealed []byte, c cid.Cid) (*DelegationV2Result, error) { + // Decode to extract indexed fields + token, err := delegation.FromSealed(sealed) + if err != nil { + return nil, err + } + + // Store in database + result, err := am.kb.queries.CreateDelegationV2(ctx, CreateDelegationV2Params{ + DidID: am.kb.didID, + Cid: c.String(), + Envelope: sealed, + Iss: token.Issuer().String(), + Aud: token.Audience().String(), + Sub: didToNullable(token.Subject()), + Cmd: token.Command().String(), + Pol: policyToJSON(token.Policy()), + Exp: timeToNullable(token.Expiration()), + Nbf: timeToNullable(token.NotBefore()), + IsRoot: boolToInt(token.IsRoot()), + IsPowerline: boolToInt(token.IsPowerline()), + }) + + return delegationV2ToResult(result), nil +} +``` + +## Migration Path + +1. **Create new tables** alongside old ones +2. **Migrate existing data** (if any JWT tokens exist) + - Parse old `raw_token` + - Re-encode as v1.0.0-rc.1 envelope (requires re-signing) + - Or: Mark old tokens as legacy, start fresh with v1.0.0-rc.1 +3. **Update ActionManager** to use new tables +4. **Drop old tables** after migration verified + +## Benefits + +| Aspect | Old (JWT) | New (v1.0.0-rc.1) | +|--------|-----------|-------------------| +| Storage | JSON text | Binary CBOR (smaller) | +| Verification | Parse JWT, verify sig | go-ucan handles it | +| Proof Chain | JSON array in token | Separate CID references | +| Policy | `capabilities` JSON | Structured `pol` field | +| Interop | Non-standard | Spec-compliant | + +## Conclusion + +Integrating UCAN v1.0.0-rc.1 into the keybase schema: + +1. **Maintains single-database portability** for WASM plugin +2. **Leverages existing infrastructure** (SQLC, ActionManager) +3. **Enables foreign key relationships** with DID documents +4. **Provides efficient queries** via indexed fields +5. **Supports go-ucan integration** via `delegation.Loader`