diff --git a/docs/security/C292_PHASE3_OAA_KV_READ_SPLIT.md b/docs/security/C292_PHASE3_OAA_KV_READ_SPLIT.md new file mode 100644 index 0000000..6ae0968 --- /dev/null +++ b/docs/security/C292_PHASE3_OAA_KV_READ_SPLIT.md @@ -0,0 +1,90 @@ +# C-292 Phase 3 — OAA KV Public / Private Read Split + +## Scope + +Phase 3 hardens OAA sovereign memory reads. + +The previous behavior allowed: + +```txt +GET /api/oaa/kv?prefix= +``` + +to return broad raw memory entries publicly. + +## New behavior + +### Public summary read + +```txt +GET /api/oaa/kv?prefix=&limit=<1-25> +``` + +Returns sanitized metadata only: + +```txt +type +hash +previous_hash +ts +agent +cycle +intent +key +``` + +It does **not** return raw `value` payloads. + +### Private raw read + +```txt +GET /api/oaa/kv?private=true&prefix=&limit=<1-100> +``` + +Requires read auth. + +Accepted auth headers: + +```txt +Authorization: Bearer +x-oaa-read-token: +x-oaa-service-token: +x-agent-service-token: +``` + +Configured env token sources: + +```txt +OAA_READ_TOKEN +OAA_SERVICE_TOKEN +AGENT_SERVICE_TOKEN +KV_HMAC_SECRET +MEMORY_HMAC_SECRET +OAA_HMAC_SECRET +``` + +## Guardrails + +Private reads require: + +```txt +prefix length >= 3 +``` + +Unscoped private reads are blocked unless explicitly enabled: + +```txt +OAA_ALLOW_UNSCOPED_PRIVATE_READS=true +``` + +## Why this matters + +OAA is sovereign memory. Public endpoints may expose proofs and summaries, but raw memory payloads must be intentional, authenticated, scoped, and capped. + +## Canon + +Public reads show proof metadata. +Private reads require authorization. +Sovereign memory must not leak raw payloads. + +We heal as we walk. diff --git a/lib/http/auth.ts b/lib/http/auth.ts new file mode 100644 index 0000000..b973d07 --- /dev/null +++ b/lib/http/auth.ts @@ -0,0 +1,75 @@ +import type { NextApiRequest } from "next"; +import { createHash, timingSafeEqual } from "crypto"; + +const READ_TOKEN_ENVS = [ + "OAA_READ_TOKEN", + "OAA_SERVICE_TOKEN", + "AGENT_SERVICE_TOKEN", + "KV_HMAC_SECRET", + "MEMORY_HMAC_SECRET", + "OAA_HMAC_SECRET" +] as const; + +function normalizeTokenMaterial(value?: string | null): string | null { + if (!value) return null; + let normalized = value.trim(); + if (!normalized) return null; + + const bearerMatch = normalized.match(/^Bearer\s+(.+)$/i); + if (bearerMatch) normalized = bearerMatch[1].trim(); + + if ( + (normalized.startsWith('"') && normalized.endsWith('"')) || + (normalized.startsWith("'") && normalized.endsWith("'")) + ) { + normalized = normalized.slice(1, -1).trim(); + } + + return normalized || null; +} + +function configuredReadTokens(): string[] { + return READ_TOKEN_ENVS + .map((key) => normalizeTokenMaterial(process.env[key])) + .filter((value): value is string => Boolean(value)); +} + +function digest(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +export function hasConfiguredReadToken(): boolean { + return configuredReadTokens().length > 0; +} + +export function verifyReadToken(candidate?: string | null): boolean { + const normalizedCandidate = normalizeTokenMaterial(candidate); + if (!normalizedCandidate) return false; + const tokens = configuredReadTokens(); + if (tokens.length === 0) return false; + const candidateDigest = digest(normalizedCandidate); + return tokens.some((token) => timingSafeEqual(candidateDigest, digest(token))); +} + +export function extractReadToken(req: NextApiRequest): string | null { + const auth = req.headers.authorization; + if (auth?.startsWith("Bearer ")) return auth.slice(7).trim(); + + const headerValue = + req.headers["x-oaa-read-token"] ?? + req.headers["x-oaa-service-token"] ?? + req.headers["x-agent-service-token"]; + + if (Array.isArray(headerValue)) return headerValue[0] ?? null; + return headerValue ?? null; +} + +export function requireReadAuth(req: NextApiRequest): { ok: true } | { ok: false; status: number; code: string } { + if (!hasConfiguredReadToken()) { + return { ok: false, status: 503, code: "read_auth_not_configured" }; + } + if (!verifyReadToken(extractReadToken(req))) { + return { ok: false, status: 401, code: "read_auth_required" }; + } + return { ok: true }; +} diff --git a/pages/api/oaa/kv.ts b/pages/api/oaa/kv.ts index d2956ed..0231ed6 100644 --- a/pages/api/oaa/kv.ts +++ b/pages/api/oaa/kv.ts @@ -3,7 +3,15 @@ import { appendMemoryEntry, getLatestHash, readMemoryEntriesByPrefix } from "../ import { verifyHmac } from "../../../lib/crypto/hmac"; import { canonicalJson } from "../../../lib/crypto/canonicalJson"; import { sealToLedger } from "../../../lib/ledger/bridge"; -import type { AgentKVPayload } from "../../../types/oaa-kv"; +import { requireReadAuth } from "../../../lib/http/auth"; +import type { AgentKVPayload, OaaMemoryEntry } from "../../../types/oaa-kv"; + +const DEFAULT_PUBLIC_LIMIT = 10; +const MAX_PUBLIC_LIMIT = 25; +const DEFAULT_PRIVATE_LIMIT = 50; +const MAX_PRIVATE_LIMIT = 100; +const MIN_PRIVATE_PREFIX_LENGTH = 3; +const ALLOW_UNSCOPED_PRIVATE_READS = process.env.OAA_ALLOW_UNSCOPED_PRIVATE_READS === "true"; function resolveKvSecret(): string { return ( @@ -14,6 +22,31 @@ function resolveKvSecret(): string { ); } +function parseLimit(value: string | string[] | undefined, fallback: number, max: number): number { + const raw = Array.isArray(value) ? value[0] : value; + const parsed = Number(raw ?? fallback); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(1, Math.min(max, Math.floor(parsed))); +} + +function boolQuery(value: string | string[] | undefined): boolean { + const raw = Array.isArray(value) ? value[0] : value; + return raw === "1" || raw === "true" || raw === "yes"; +} + +function sanitizeEntry(entry: OaaMemoryEntry) { + return { + type: entry.type, + hash: entry.hash, + previous_hash: entry.previous_hash ?? null, + ts: entry.ts, + agent: entry.agent, + cycle: entry.cycle, + intent: entry.intent ?? null, + key: entry.data.key, + }; +} + function isPayload(body: unknown): body is AgentKVPayload { if (!body || typeof body !== "object") return false; const b = body as AgentKVPayload; @@ -26,11 +59,63 @@ function isPayload(body: unknown): body is AgentKVPayload { ); } +async function handleGet(req: NextApiRequest, res: NextApiResponse) { + const privateRead = boolQuery(req.query.private) || boolQuery(req.query.raw); + const prefix = typeof req.query.prefix === "string" ? req.query.prefix.trim() : ""; + + if (!privateRead) { + const limit = parseLimit(req.query.limit, DEFAULT_PUBLIC_LIMIT, MAX_PUBLIC_LIMIT); + const notes = await readMemoryEntriesByPrefix(prefix); + const publicNotes = notes + .sort((a, b) => b.ts - a.ts) + .slice(0, limit) + .map(sanitizeEntry); + + return res.status(200).json({ + ok: true, + mode: "public_summary", + notes: publicNotes, + count: publicNotes.length, + raw_available: false, + raw_requires: "private=true plus read auth", + }); + } + + const auth = requireReadAuth(req); + if (!auth.ok) { + return res.status(auth.status).json({ ok: false, error: auth.code }); + } + + if (!prefix && !ALLOW_UNSCOPED_PRIVATE_READS) { + return res.status(400).json({ + ok: false, + error: "prefix_required", + message: "Private memory reads require a prefix unless OAA_ALLOW_UNSCOPED_PRIVATE_READS=true.", + }); + } + + if (prefix && prefix.length < MIN_PRIVATE_PREFIX_LENGTH) { + return res.status(400).json({ + ok: false, + error: "prefix_too_short", + minPrefixLength: MIN_PRIVATE_PREFIX_LENGTH, + }); + } + + const limit = parseLimit(req.query.limit, DEFAULT_PRIVATE_LIMIT, MAX_PRIVATE_LIMIT); + const notes = await readMemoryEntriesByPrefix(prefix); + return res.status(200).json({ + ok: true, + mode: "private_raw", + notes: notes.sort((a, b) => b.ts - a.ts).slice(0, limit), + count: Math.min(notes.length, limit), + prefix, + }); +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") { - const prefix = typeof req.query.prefix === "string" ? req.query.prefix : ""; - const notes = await readMemoryEntriesByPrefix(prefix); - return res.status(200).json({ ok: true, notes }); + return handleGet(req, res); } if (req.method !== "POST") {