Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/security/C292_PHASE3_OAA_KV_READ_SPLIT.md
Original file line number Diff line number Diff line change
@@ -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=<optional>&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=<required>&limit=<1-100>
```

Requires read auth.

Accepted auth headers:

```txt
Authorization: Bearer <token>
x-oaa-read-token: <token>
x-oaa-service-token: <token>
x-agent-service-token: <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.
75 changes: 75 additions & 0 deletions lib/http/auth.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
93 changes: 89 additions & 4 deletions pages/api/oaa/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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;
Expand All @@ -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") {
Expand Down
Loading