diff --git a/packages/client/src/discovery.ts b/packages/client/src/discovery.ts index 1215cdb..163d8ab 100644 --- a/packages/client/src/discovery.ts +++ b/packages/client/src/discovery.ts @@ -40,16 +40,18 @@ */ import * as inbox from "./inbox"; -import { ensureAccount } from "./store-registry"; +import { ensureAccount, reservedAccount } from "./store-registry"; import type { Nuri, PrincipalId } from "./types"; /** * The reserved SPECIAL ACCOUNT that owns the global discovery index in the - * polyfill. A normal shim account (username), so its scope documents are created - * on first sight like any other account — but it is never a real user; it only - * hosts the index document. Disappears at migration (see file header). + * polyfill. It hosts the index document but is never a real user. It lives in + * the registry's RESERVED namespace ({@link reservedAccount}), whose key + * `normalizeUser` can never produce — so a user named "index"/"@index" can NOT + * hijack it (they would normalize to "index", a disjoint key). Disappears at + * migration (see file header). */ -export const INDEX_ACCOUNT = "@index"; +export const INDEX_ACCOUNT = reservedAccount("index"); /** One entry as materialized from the discovery index. */ export interface IndexEntry { diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 7029178..3b0ec4b 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -65,8 +65,43 @@ function accountSubject(username: string): string { // The username is UNTRUSTED and lands in an IRI position. Percent-encode it // (escapeIri) so no `>` / `"` / whitespace / control char can break out of // the `<...>` and inject triples into the shim graph (the account→doc trust - // root). normalize() runs first so the subject stays stable per shim key. - return `${SHIM}:account:${escapeIri(normalize(username))}`; + // root). accountKey() runs first so the subject stays stable per shim key. + return `${SHIM}:account:${escapeIri(accountKey(username))}`; +} + +// --- reserved accounts ----------------------------------------------------- +// +// Some accounts are internal to the lib (e.g. the discovery index owner) and +// must NOT collide with any user-chosen username. A reserved account is created +// via {@link reservedAccount}, which marks the name with a sentinel PREFIX that +// `normalizeUser` (consumer-injected) can never produce: it strips a leading +// `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved +// keys therefore live in a disjoint namespace from every normalized username — +// a real user named "index"/"@index" can never resolve to the reserved +// `reservedAccount("index")` account. +const RESERVED_PREFIX = "\u0000reserved:"; + +/** + * Wrap an internal account name so it occupies a key that no user input can + * produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount} + * (and the other registry calls) instead of a bare username. + */ +export function reservedAccount(name: string): string { + return `${RESERVED_PREFIX}${name}`; +} + +/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */ +function isReserved(username: string): boolean { + return username.startsWith(RESERVED_PREFIX); +} + +/** + * The shim/cache key for an account. Reserved accounts bypass `normalizeUser` + * entirely and key on their sentinel-prefixed name, so they cannot collide with + * a normalized username; everyone else normalizes as usual. + */ +function accountKey(username: string): string { + return isReserved(username) ? username : normalize(username); } // --- session / normalization access (injected by the consumer) ------------ @@ -147,7 +182,7 @@ export async function loadShim(): Promise> { for (const row of readBindings(result)) { const username = bindingValue(row, "username"); if (!username) continue; - map.set(normalize(username), { + map.set(accountKey(username), { username, docPublic: bindingValue(row, "docPublic"), docProtected: bindingValue(row, "docProtected"), @@ -180,7 +215,7 @@ async function createDoc(): Promise { */ export async function ensureAccount(username: string): Promise { const map = await loadShim(); - const key = normalize(username); + const key = accountKey(username); const existing = map.get(key); if (existing) return existing; diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index 234a8a5..ddf1b06 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -8,7 +8,7 @@ import { resetConfig, setCurrentUser, } from "../src/polyfill"; -import { resetRegistryCache } from "../src/store-registry"; +import { resetRegistryCache, ensureAccount } from "../src/store-registry"; import type { RegistrySession } from "../src/store-registry"; // discovery.ts submits to / reads from a global index owned by a RESERVED @@ -209,8 +209,34 @@ test("from: null makes an anonymous submission", async () => { expect(entries[0]!.from).toBeNull(); }); -test("INDEX_ACCOUNT is the reserved special account", () => { - expect(INDEX_ACCOUNT).toBe("@index"); +test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => { + // The index account occupies a key no user input can produce: it is prefixed + // with a NUL control char, which a user cannot type into a username field and + // which no `normalizeUser` output (a typeable value) contains. So it is + // disjoint from the keys "index" / "@index" a hostile user would submit. + expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel + expect(INDEX_ACCOUNT).not.toBe("index"); + expect(INDEX_ACCOUNT).not.toBe("@index"); +}); + +test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => { + // The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who + // registers as "index" or "@index" normalizes to key "index" — which must be + // a DISJOINT key from the reserved index account, so they get their own + // documents and cannot hijack / read-write the global index document. + const indexRecord = await ensureAccount(INDEX_ACCOUNT); + + // A real user "index" — same normalized form as "@index". + const userIndex = await ensureAccount("index"); + expect(userIndex.docPublic).not.toBe(indexRecord.docPublic); + expect(userIndex.docProtected).not.toBe(indexRecord.docProtected); + expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate); + + // "@index" must land on the SAME account as "index" (both normalize to + // "index") — and still NOT on the reserved index account. + const userAtIndex = await ensureAccount("@index"); + expect(userAtIndex.docPublic).toBe(userIndex.docPublic); + expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic); }); test("watchIndex fires immediately then when a submission arrives", async () => {