clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat(access/runtime): shared-field key directory and account identity (#160)

FelineStateMachine committed 0beece2
Stage 2 of shared-field encryption (#154). Every account derives an
x25519 identity from its account secret at boot (distinct HKDF info —
no key material shared with the encrypted-column hierarchy) and can
self-publish its public key in a key-directory table declared with
sharedFieldDirectoryTable and compiled by the sharedFieldAccess
template: world-readable, self-only writes, both enforced against the
real server. A self row holding a different key is surfaced as a
conflict and never overwritten.

Peer fingerprints pin trust-on-first-sight per device, and sharing
identities upgrade to lofi2 with a fingerprint segment once the local
identity exists — members added by identity string are pinned
person-to-person, with no server first-sight window. lofi1 identities
keep decoding.

Also deflakes the encrypted-json no-plaintext probe (short digit runs
occur by chance in base64url ciphertext).
commit
0beece2705d2958e52046734f6e89a11f157c8f7
parent
554498eb174e5a876a7634edbad9dd93a1bad4a4
author
FelineStateMachine <dami.etoile@gmail.com>
13 files changed, +719 -24
Mpackage/access/access_operations_test.ts28++++
Mpackage/access/identity.ts71+++++++---
Mpackage/access/mod.ts5+
Mpackage/access/policies.ts41+++++-
Mpackage/access/schema.ts21+++
Apackage/access/shared_directory_sync_test.ts135++++++++++++++++++
Mpackage/astro/mod.ts2+
Mpackage/runtime/mod.ts11++
Mpackage/runtime/runtime.ts2+
Apackage/runtime/shared-field-keys.ts219++++++++++++++++++++++++++++++
Apackage/runtime/shared_field_keys_test.ts133++++++++++++++++++
Mpackage/schema/encrypted_test.ts13+-
Apackage/schema/shared-keyring.ts62++++++++
M package/access/access_operations_test.ts +28 -0
@@ -3,6 +3,7 @@ import {
33 AccessError,44 createSharingOperations,55 decodeSharingIdentity,6+ decodeSharingIdentityDetails,67 encodeSharingIdentity,78 sharedGrantTable,89 } from "./mod.ts";
@@ -62,3 +63,30 @@ Deno.test("shared operations reject managed-sync work before touching a local-on
6263 }6364 assert(code === "sync-required", "local-only sharing did not report its sync precondition");6465 });66+67+Deno.test("fingerprint-bearing identities round-trip and coexist with plain ones", () => {68+ const plain = encodeSharingIdentity("stable-jazz-principal");69+ assert(plain.startsWith("lofi1:"), "a fingerprintless identity stays v1");70+ const pinned = encodeSharingIdentity("stable-jazz-principal", "fp-abc123");71+ assert(pinned.startsWith("lofi2:"), "a fingerprint upgrades the identity to v2");72+ const details = decodeSharingIdentityDetails(pinned);73+ assert(74+ details.userId === "stable-jazz-principal" && details.fingerprint === "fp-abc123",75+ "v2 identity did not round-trip its parts",76+ );77+ assert(78+ decodeSharingIdentity(pinned) === "stable-jazz-principal",79+ "the principal-only decoder must accept v2",80+ );81+ assert(82+ decodeSharingIdentityDetails(plain).fingerprint === undefined,83+ "a v1 identity carries no fingerprint",84+ );85+ let code = "";86+ try {87+ decodeSharingIdentityDetails(`${plain}:unexpected-extra`);88+ } catch (error) {89+ code = error instanceof AccessError ? error.code : "unexpected";90+ }91+ assert(code === "invalid-identity", "a v1 identity with a trailing segment was accepted");92+});
M package/access/identity.ts +52 -19
@@ -1,43 +1,74 @@
11 import { activeAppId } from "../runtime/config.ts";22 import { getRuntime } from "../runtime/runtime.ts";3+import { sharedFieldIdentityOrNull } from "../schema/shared-keyring.ts";34 import { AccessError } from "./errors.ts";45 56 /** App-scoped, non-secret Jazz principal identifier safe to copy between users. */67 export type SharingIdentity = string & { readonly __lofiSharingIdentity: true };78 89 const prefix = "lofi1";10+const prefixWithFingerprint = "lofi2";11+12+/** A decoded sharing identity: the raw principal, and — when the identity13+ * was minted by a shared-field-capable app — the account's public-key14+ * fingerprint. A carried fingerprint pins the peer's key out-of-band, so the15+ * sync server never gets a first-sight window for that relationship. */16+export type SharingIdentityDetails = {17+ userId: string;18+ fingerprint?: string;19+};920 1021 /**1122 * Encodes a raw Jazz principal as a versioned identity scoped to the active1223 * sync location's app. Identities are exchanged between users of the same1324 * store, so they carry the store's app id — the declared sink's when one is14- * enrolled, the compiled managed app's otherwise.25+ * enrolled, the compiled managed app's otherwise. With a fingerprint, the26+ * identity also carries the account's shared-field key fingerprint.1527 */16-export function encodeSharingIdentity(userId: string): SharingIdentity {28+export function encodeSharingIdentity(userId: string, fingerprint?: string): SharingIdentity {1729 if (!userId || /[\s:]/.test(userId)) {1830 throw new AccessError("invalid-identity", "Jazz returned an invalid sharing identity.");1931 }20- return `${prefix}:${activeAppId()}:${userId}` as SharingIdentity;32+ if (fingerprint === undefined) {33+ return `${prefix}:${activeAppId()}:${userId}` as SharingIdentity;34+ }35+ if (!fingerprint || /[\s:]/.test(fingerprint)) {36+ throw new AccessError("invalid-identity", "The identity fingerprint is malformed.");37+ }38+ return `${prefixWithFingerprint}:${activeAppId()}:${userId}:${fingerprint}` as SharingIdentity;2139 }2240 2341 /**24- * Validates an app-scoped sharing identity and returns its raw Jazz principal.25- * Surrounding whitespace from copy/paste is tolerated; whitespace inside the26- * principal is rejected — a grant for a padded principal would look active to27- * the owner while never matching the recipient's actual account.42+ * Validates an app-scoped sharing identity and returns its parts. Both43+ * identity versions decode: `lofi1` yields the principal alone, `lofi2` adds44+ * the fingerprint. Surrounding whitespace from copy/paste is tolerated;45+ * whitespace inside the principal is rejected — a grant for a padded46+ * principal would look active to the owner while never matching the47+ * recipient's actual account.2848 */29-export function decodeSharingIdentity(identity: string): string {30- const [version, identityAppId, userId, extra] = identity.trim().split(":");31- if (32- version !== prefix || identityAppId !== activeAppId() || !userId || /\s/.test(userId) ||33- extra !== undefined34- ) {35- throw new AccessError(36- "invalid-identity",37- "That sharing identity belongs to another app or is malformed. Ask the recipient to copy it again from this app.",38- );49+export function decodeSharingIdentityDetails(identity: string): SharingIdentityDetails {50+ const [version, identityAppId, userId, fingerprint, extra] = identity.trim().split(":");51+ const malformed = new AccessError(52+ "invalid-identity",53+ "That sharing identity belongs to another app or is malformed. Ask the recipient to copy it again from this app.",54+ );55+ if (identityAppId !== activeAppId() || !userId || /\s/.test(userId) || extra !== undefined) {56+ throw malformed;57+ }58+ if (version === prefix) {59+ if (fingerprint !== undefined) throw malformed;60+ return { userId };61+ }62+ if (version === prefixWithFingerprint) {63+ if (!fingerprint || /\s/.test(fingerprint)) throw malformed;64+ return { userId, fingerprint };3965 }40- return userId;66+ throw malformed;67+}68+69+/** Validates a sharing identity and returns its raw Jazz principal. */70+export function decodeSharingIdentity(identity: string): string {71+ return decodeSharingIdentityDetails(identity).userId;4172 }4273 4374 /** Returns the non-secret, app-scoped identity users may copy for shares. */
@@ -50,5 +81,7 @@ export async function sharingIdentity(): Promise<SharingIdentity> {
5081 "The current Jazz principal is not ready. Wait for account startup and try again.",5182 );5283 }53- return encodeSharingIdentity(userId);84+ // Once the shared-field identity is installed, minted identities carry the85+ // fingerprint so recipients pin this account's key person-to-person.86+ return encodeSharingIdentity(userId, sharedFieldIdentityOrNull()?.fingerprint);5487 }
M package/access/mod.ts +5 -0
M package/access/policies.ts +40 -1
@@ -25,8 +25,17 @@ export type GroupAccessTemplate = {
2525 readonly resources: readonly AccessTable[];2626 readonly groupId: string;2727 };28+/** Shared-field key-directory policy template. */29+export type SharedFieldAccessTemplate = {30+ readonly kind: "shared-field-directory";31+ readonly directory: AccessTable;32+};2833 /** Any built-in access policy template accepted by {@link defineAccessPolicies}. */29-export type AccessTemplate = PrivateAccessTemplate | SharedAccessTemplate | GroupAccessTemplate;34+export type AccessTemplate =35+ | PrivateAccessTemplate36+ | SharedAccessTemplate37+ | GroupAccessTemplate38+ | SharedFieldAccessTemplate;3039 3140 /**3241 * Declares owner-only read and mutation policy for one resource table.
@@ -111,6 +120,22 @@ export function groupAccess(config: {
111120 };112121 }113122 123+/**124+ * Declares the shared-field key directory policy: every authenticated125+ * account reads the directory (public keys are public — integrity comes from126+ * fingerprint pinning), and each account writes only its own row, so the127+ * store cannot be used to impersonate a publisher through the policy layer.128+ *129+ * The directory table must be declared with the `sharedFieldDirectoryTable`130+ * helper (or match its column shape).131+ *132+ * @param config The key directory table.133+ * @returns A template for {@link defineAccessPolicies}.134+ */135+export function sharedFieldAccess(config: { directory: AccessTable }): SharedFieldAccessTemplate {136+ return { kind: "shared-field-directory", ...config };137+}138+114139 type Column = {115140 name?: string;116141 references?: string;
@@ -152,6 +177,12 @@ function validateTemplate(template: AccessTemplate): void {
152177 requireColumn(template.grants, "user_id", "Text");153178 requireColumn(template.grants, "can_edit", "Boolean");154179 }180+ if (template.kind === "shared-field-directory") {181+ requireColumn(template.directory, "user_id", "Text");182+ requireColumn(template.directory, "algo", "Text");183+ requireColumn(template.directory, "public_key", "Text");184+ requireColumn(template.directory, "fingerprint", "Text");185+ }155186 if (template.kind === "group") {156187 requireColumn(template.members, "groupId", "Uuid", template.groups._table);157188 requireColumn(template.members, "user_id", "Text");
@@ -307,6 +338,14 @@ export function defineAccessPolicies<TApp extends object>(
307338 resource.allowDelete.where({ $createdBy: session.user_id });308339 continue;309340 }341+ if (template.kind === "shared-field-directory") {342+ const directory = policy[template.directory._table];343+ directory.allowRead.always();344+ directory.allowInsert.where({ user_id: session.user_id });345+ directory.allowUpdate.where({ user_id: session.user_id });346+ directory.allowDelete.where({ user_id: session.user_id });347+ continue;348+ }310349 if (template.kind === "shared") {311350 const resource = policy[template.resource._table];312351 const grants = policy[template.grants._table];
M package/access/schema.ts +21 -0
A package/access/shared_directory_sync_test.ts +135 -0
@@ -0,0 +1,135 @@
1+// The sharedFieldAccess template against the real pinned server: the2+// compiled directory policy is world-readable with self-only writes, and3+// encrypted columns are refused in the directory's template positions.4+5+import { createDb, schema as s } from "jazz-tools";6+import { deploy, startLocalJazzServer } from "jazz-tools/testing";7+import { defineAccessPolicies, sharedFieldAccess, sharedFieldDirectoryTable } from "./mod.ts";8+import { s as lofiSchema } from "../schema/mod.ts";9+import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";10+import { assert } from "../runtime/test-assert.ts";11+12+const app = s.defineApp({13+ keyDirectory: sharedFieldDirectoryTable(),14+});15+const permissions = defineAccessPolicies(app, [16+ sharedFieldAccess({ directory: app.keyDirectory }),17+]);18+19+function secret(fill: number): string {20+ let binary = "";21+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);22+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");23+}24+25+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {26+ let timer: ReturnType<typeof setTimeout> | undefined;27+ try {28+ return await Promise.race([29+ operation,30+ new Promise<never>((_resolve, reject) => {31+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);32+ }),33+ ]);34+ } finally {35+ if (timer !== undefined) clearTimeout(timer);36+ }37+}38+39+Deno.test("the compiled directory policy is world-readable with self-only writes", async () => {40+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });41+ await deploy({42+ appId: server.appId,43+ serverUrl: server.url,44+ adminSecret: server.adminSecret,45+ schema: app,46+ permissions,47+ });48+ const alice = await createDb({49+ appId: server.appId,50+ serverUrl: server.url,51+ secret: secret(1),52+ userBranch: "main",53+ driver: { type: "memory" },54+ });55+ const bob = await createDb({56+ appId: server.appId,57+ serverUrl: server.url,58+ secret: secret(2),59+ userBranch: "main",60+ driver: { type: "memory" },61+ });62+ try {63+ const aliceId = alice.getAuthState().session?.user_id as string;64+ const bobId = bob.getAuthState().session?.user_id as string;65+ assert(Boolean(aliceId && bobId && aliceId !== bobId), "test principals were not isolated");66+67+ await within(68+ alice.insert(app.keyDirectory, {69+ user_id: aliceId,70+ algo: "x25519-v1",71+ public_key: "alice-pub",72+ fingerprint: "alice-fp",73+ }).wait({ tier: "global" }),74+ "alice publishes",75+ );76+ const seenByBob = await within(77+ bob.all(app.keyDirectory.where({ user_id: aliceId })),78+ "bob reads alice's row",79+ );80+ assert(81+ seenByBob.length === 1 && seenByBob[0].public_key === "alice-pub",82+ `the directory must be world-readable (saw ${seenByBob.length})`,83+ );84+85+ let denied = false;86+ try {87+ await within(88+ bob.insert(app.keyDirectory, {89+ user_id: aliceId,90+ algo: "x25519-v1",91+ public_key: "evil-pub",92+ fingerprint: "evil-fp",93+ }).wait({ tier: "global" }),94+ "bob impersonates alice",95+ );96+ } catch {97+ denied = true;98+ }99+ assert(denied, "a foreign user_id insert must be denied");100+ } finally {101+ await Promise.allSettled([102+ within(alice.logout(), "Alice cleanup", 3_000),103+ within(bob.logout(), "Bob cleanup", 3_000),104+ ]);105+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);106+ }107+});108+109+Deno.test("a directory with a sealed column fails configuration", () => {110+ clearEncryptedColumnRegistry();111+ try {112+ const sealedApp = lofiSchema.defineApp({113+ keyDirectory: lofiSchema.table({114+ user_id: lofiSchema.encryptedText("keyDirectory.user_id"),115+ algo: lofiSchema.string(),116+ public_key: lofiSchema.string(),117+ fingerprint: lofiSchema.string(),118+ }),119+ });120+ let message = "";121+ try {122+ defineAccessPolicies(sealedApp, [123+ sharedFieldAccess({ directory: sealedApp.keyDirectory }),124+ ]);125+ } catch (error) {126+ message = error instanceof Error ? error.message : String(error);127+ }128+ assert(129+ message.includes("encrypted") && message.includes("user_id"),130+ `a sealed directory column compiled: ${message || "no error"}`,131+ );132+ } finally {133+ clearEncryptedColumnRegistry();134+ }135+});
M package/astro/mod.ts +2 -0
M package/runtime/mod.ts +11 -0
M package/runtime/runtime.ts +2 -0
A package/runtime/shared-field-keys.ts +219 -0
@@ -0,0 +1,219 @@
1+/**2+ * Runtime custody of shared-field identity: derives the account's x255193+ * keypair from the account secret at boot, self-publishes the public key in4+ * the app's key directory, and pins peers' fingerprints on first sight so a5+ * later substitution — the one active attack a key-relaying server has — is6+ * a detected failure, not a silent read.7+ *8+ * @module9+ */10+import {11+ publicKeyFingerprint,12+ SharedFieldError,13+ sharedFieldPublicKey,14+} from "../schema/shared-crypto.ts";15+import {16+ clearSharedFieldIdentity,17+ installSharedFieldIdentity,18+ type SharedFieldIdentity,19+} from "../schema/shared-keyring.ts";20+21+export { clearSharedFieldIdentity };22+23+/**24+ * Derives the account's shared-field identity from the account secret: the25+ * same IKM convention as the encrypted-column master key with a distinct26+ * HKDF info string, so the two hierarchies never share key material.27+ */28+export async function deriveSharedFieldIdentity(secret: string): Promise<SharedFieldIdentity> {29+ const material = await crypto.subtle.importKey(30+ "raw",31+ new TextEncoder().encode(secret) as BufferSource,32+ "HKDF",33+ false,34+ ["deriveBits"],35+ );36+ const bits = await crypto.subtle.deriveBits(37+ {38+ name: "HKDF",39+ hash: "SHA-256",40+ salt: new Uint8Array(0) as BufferSource,41+ info: new TextEncoder().encode("lofi:shared-fields:x25519:v1") as BufferSource,42+ },43+ material,44+ 256,45+ );46+ const identitySecret = new Uint8Array(bits);47+ const publicKey = sharedFieldPublicKey(identitySecret);48+ return { secret: identitySecret, publicKey, fingerprint: publicKeyFingerprint(publicKey) };49+}50+51+/** Derives and installs the identity; the boot path calls this per client. */52+export async function installSharedFieldIdentityFromSecret(secret: string): Promise<void> {53+ installSharedFieldIdentity(await deriveSharedFieldIdentity(secret));54+}55+56+// --- Fingerprint pinning -----------------------------------------------------57+//58+// Trust-on-first-sight per device: the first fingerprint observed for a peer59+// (or the one carried out-of-band in a lofi2 sharing identity, which never60+// gives the server a first-sight window) is pinned in localStorage. A61+// directory row that later disagrees is refused until the user explicitly62+// re-trusts, which is the detection the threat model promises.63+64+function pinStorageKey(appId: string): string {65+ return `lofi:shared-fields:pins:${appId}`;66+}67+68+function readPins(appId: string): Record<string, string> {69+ try {70+ const raw = localStorage.getItem(pinStorageKey(appId));71+ if (!raw) return {};72+ const parsed = JSON.parse(raw) as unknown;73+ if (typeof parsed !== "object" || parsed === null) return {};74+ return parsed as Record<string, string>;75+ } catch {76+ return {};77+ }78+}79+80+function writePins(appId: string, pins: Record<string, string>): void {81+ try {82+ localStorage.setItem(pinStorageKey(appId), JSON.stringify(pins));83+ } catch {84+ // Storage failures degrade to first-sight trust on the next read; the85+ // unwrap path still authenticates cryptographically.86+ }87+}88+89+/** The pinned fingerprint for a peer, if this device has seen one. */90+export function pinnedFingerprint(appId: string, userId: string): string | undefined {91+ return readPins(appId)[userId];92+}93+94+/**95+ * Verifies a peer's observed fingerprint against this device's pin, pinning96+ * on first sight. Returns false — and leaves the pin untouched — when a pin97+ * exists and disagrees; callers refuse the key and surface the mismatch.98+ */99+export function verifyAndPinFingerprint(100+ appId: string,101+ userId: string,102+ observed: string,103+): boolean {104+ const pins = readPins(appId);105+ const pinned = pins[userId];106+ if (pinned === undefined) {107+ pins[userId] = observed;108+ writePins(appId, pins);109+ return true;110+ }111+ return pinned === observed;112+}113+114+/**115+ * Replaces a peer's pin after out-of-band verification — the explicit user116+ * action that resolves a `peer-key-changed` refusal.117+ */118+export function trustPeerKey(appId: string, userId: string, fingerprint: string): void {119+ const pins = readPins(appId);120+ pins[userId] = fingerprint;121+ writePins(appId, pins);122+}123+124+/** Forgets every pin for an app; tests call this between scenarios. */125+export function clearFingerprintPins(appId: string): void {126+ try {127+ localStorage.removeItem(pinStorageKey(appId));128+ } catch {129+ // Nothing to clear.130+ }131+}132+133+// --- Directory self-publication ----------------------------------------------134+135+type DirectoryRow = {136+ id: string;137+ user_id: string;138+ algo: string;139+ public_key: string;140+ fingerprint: string;141+};142+143+type DirectoryDb = {144+ all(query: unknown): Promise<unknown[]>;145+ insert(146+ table: unknown,147+ values: Record<string, unknown>,148+ ): { wait(options: { tier: "local" | "global" }): Promise<unknown> };149+};150+151+type DirectoryTable = {152+ where(condition: Record<string, unknown>): unknown;153+};154+155+function toBase64Url(bytes: Uint8Array): string {156+ let binary = "";157+ for (const byte of bytes) binary += String.fromCharCode(byte);158+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");159+}160+161+/** The outcome of a directory self-publication attempt. */162+export type DirectoryPublication =163+ | { state: "published" }164+ | { state: "existing" }165+ | { state: "self-key-conflict"; expected: string; observed: string };166+167+/**168+ * Ensures the current account's public key is published in the app's key169+ * directory: inserts on first sync, verifies on every later boot. A row that170+ * exists with a *different* key is never overwritten — that shape means171+ * directory tampering or a derivation bug, and silently replacing it would172+ * hide exactly the event pinning exists to expose.173+ */174+export async function ensureDirectoryEntry(input: {175+ db: DirectoryDb;176+ directory: DirectoryTable;177+ userId: string;178+ identity: SharedFieldIdentity;179+}): Promise<DirectoryPublication> {180+ const rows = await input.db.all(181+ input.directory.where({ user_id: input.userId }),182+ ) as DirectoryRow[];183+ const publicKey = toBase64Url(input.identity.publicKey);184+ const existing = rows[0];185+ if (existing !== undefined) {186+ if (existing.public_key === publicKey) return { state: "existing" };187+ return {188+ state: "self-key-conflict",189+ expected: publicKey,190+ observed: existing.public_key,191+ };192+ }193+ await input.db.insert(input.directory as never, {194+ user_id: input.userId,195+ algo: "x25519-v1",196+ public_key: publicKey,197+ fingerprint: input.identity.fingerprint,198+ }).wait({ tier: "global" });199+ return { state: "published" };200+}201+202+/** Decodes a directory row's public key, refusing malformed entries. */203+export function directoryPublicKey(row: { public_key: string; algo: string }): Uint8Array {204+ if (row.algo !== "x25519-v1") {205+ throw new SharedFieldError(206+ "wrap-invalid",207+ `directory entry carries unsupported algorithm "${row.algo}"`,208+ );209+ }210+ const base64 = row.public_key.replaceAll("-", "+").replaceAll("_", "/");211+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);212+ const binary = atob(padded);213+ const bytes = new Uint8Array(binary.length);214+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);215+ if (bytes.length !== 32) {216+ throw new SharedFieldError("wrap-invalid", "directory entry holds a malformed public key");217+ }218+ return bytes;219+}
A package/runtime/shared_field_keys_test.ts +133 -0
@@ -0,0 +1,133 @@
1+// Shared-field identity custody: deterministic derivation with key2+// separation, trust-on-first-sight pinning, and directory self-publication3+// against a stub database.4+import {5+ clearFingerprintPins,6+ deriveSharedFieldIdentity,7+ directoryPublicKey,8+ ensureDirectoryEntry,9+ pinnedFingerprint,10+ trustPeerKey,11+ verifyAndPinFingerprint,12+} from "./shared-field-keys.ts";13+import { SharedFieldError } from "../schema/shared-crypto.ts";14+import { assert } from "./test-assert.ts";15+16+const APP = "pin-test-app";17+18+Deno.test("identity derivation is deterministic and separated from the column key", async () => {19+ const first = await deriveSharedFieldIdentity("account-secret-a");20+ const second = await deriveSharedFieldIdentity("account-secret-a");21+ assert(22+ first.fingerprint === second.fingerprint &&23+ first.publicKey.every((byte, index) => byte === second.publicKey[index]),24+ "the same account secret must derive the same identity",25+ );26+ const other = await deriveSharedFieldIdentity("account-secret-b");27+ assert(other.fingerprint !== first.fingerprint, "distinct secrets must derive distinct keys");28+29+ // Key separation: the x25519 secret must differ from the encrypted-column30+ // master key derived from the same account secret (distinct HKDF info).31+ const material = await crypto.subtle.importKey(32+ "raw",33+ new TextEncoder().encode("account-secret-a"),34+ "HKDF",35+ false,36+ ["deriveBits"],37+ );38+ const columnKey = new Uint8Array(39+ await crypto.subtle.deriveBits(40+ {41+ name: "HKDF",42+ hash: "SHA-256",43+ salt: new Uint8Array(0),44+ info: new TextEncoder().encode("lofi:encrypted-columns:v1"),45+ },46+ material,47+ 256,48+ ),49+ );50+ assert(51+ !first.secret.every((byte, index) => byte === columnKey[index]),52+ "the identity secret must not equal the column master key",53+ );54+});55+56+Deno.test("pins are trust-on-first-sight and refuse silent substitution", () => {57+ clearFingerprintPins(APP);58+ try {59+ assert(pinnedFingerprint(APP, "alice") === undefined, "no pin before first sight");60+ assert(verifyAndPinFingerprint(APP, "alice", "fp-1"), "first sight must pin");61+ assert(pinnedFingerprint(APP, "alice") === "fp-1", "the pin must persist");62+ assert(verifyAndPinFingerprint(APP, "alice", "fp-1"), "a matching key must verify");63+ assert(!verifyAndPinFingerprint(APP, "alice", "fp-2"), "a changed key must refuse");64+ assert(pinnedFingerprint(APP, "alice") === "fp-1", "a refusal must not move the pin");65+ trustPeerKey(APP, "alice", "fp-2");66+ assert(verifyAndPinFingerprint(APP, "alice", "fp-2"), "explicit trust must re-pin");67+ } finally {68+ clearFingerprintPins(APP);69+ }70+});71+72+Deno.test("directory rows decode strictly", async () => {73+ const identity = await deriveSharedFieldIdentity("account-secret-a");74+ let stored = "";75+ {76+ let binary = "";77+ for (const byte of identity.publicKey) binary += String.fromCharCode(byte);78+ stored = btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");79+ }80+ const decoded = directoryPublicKey({ algo: "x25519-v1", public_key: stored });81+ assert(82+ decoded.every((byte, index) => byte === identity.publicKey[index]),83+ "a published key must decode to the derived key",84+ );85+ for (86+ const [row, label] of [87+ [{ algo: "rsa", public_key: stored }, "unsupported algorithm"],88+ [{ algo: "x25519-v1", public_key: "AAAA" }, "truncated key"],89+ ] as const90+ ) {91+ let refused = false;92+ try {93+ directoryPublicKey(row);94+ } catch (error) {95+ refused = error instanceof SharedFieldError && error.code === "wrap-invalid";96+ }97+ assert(refused, `${label} must refuse with wrap-invalid`);98+ }99+});100+101+Deno.test("directory publication inserts once and reports conflicts", async () => {102+ const identity = await deriveSharedFieldIdentity("account-secret-a");103+ const rows: Record<string, unknown>[] = [];104+ const db = {105+ all: (_query: unknown) => Promise.resolve([...rows]),106+ insert: (_table: unknown, values: Record<string, unknown>) => ({107+ wait: (_options: { tier: "local" | "global" }) => {108+ rows.push(values);109+ return Promise.resolve(values);110+ },111+ }),112+ };113+ const directory = { where: (condition: Record<string, unknown>) => condition };114+115+ const first = await ensureDirectoryEntry({ db, directory, userId: "alice", identity });116+ assert(first.state === "published", "first boot must publish");117+ assert(118+ rows.length === 1 && rows[0].algo === "x25519-v1" &&119+ rows[0].fingerprint === identity.fingerprint,120+ "the published row must carry the key material",121+ );122+123+ const second = await ensureDirectoryEntry({ db, directory, userId: "alice", identity });124+ assert(second.state === "existing" && rows.length === 1, "a later boot must not republish");125+126+ rows[0] = { ...rows[0], public_key: "tampered-key" };127+ const third = await ensureDirectoryEntry({ db, directory, userId: "alice", identity });128+ assert(129+ third.state === "self-key-conflict",130+ "a mismatched self row must surface as a conflict, never be overwritten",131+ );132+ assert(rows.length === 1 && rows[0].public_key === "tampered-key", "no overwrite occurred");133+});
M package/schema/encrypted_test.ts +9 -4
A package/schema/shared-keyring.ts +62 -0
@@ -0,0 +1,62 @@
1+/**2+ * The in-memory keyring for shared fields, mirroring the module-slot design3+ * of the encrypted-column master key: synchronous column transforms can only4+ * consult memory, so everything they need — the account's x25519 identity5+ * now, unwrapped field keys as the read side lands — is installed by the6+ * runtime before any database operation and read synchronously here.7+ *8+ * @module9+ */10+import { SharedFieldError } from "./shared-crypto.ts";11+12+/** The account's shared-field identity, derived from the account secret. */13+export type SharedFieldIdentity = {14+ /** 32-byte x25519 static secret; never leaves memory. */15+ secret: Uint8Array;16+ /** The matching public key, self-published in the key directory. */17+ publicKey: Uint8Array;18+ /** base64url SHA-256 of the public key — the pinnable identity. */19+ fingerprint: string;20+};21+22+let identity: SharedFieldIdentity | null = null;23+24+/**25+ * Installs the account's shared-field identity. The runtime calls this at26+ * boot (after the encrypted-column key) and again whenever the account27+ * secret changes; tests inject fixed identities.28+ */29+export function installSharedFieldIdentity(next: SharedFieldIdentity): void {30+ if (next.secret.length !== 32 || next.publicKey.length !== 32) {31+ throw new SharedFieldError(32+ "identity-missing",33+ "a shared-field identity needs 32-byte x25519 keys",34+ );35+ }36+ identity = {37+ secret: new Uint8Array(next.secret),38+ publicKey: new Uint8Array(next.publicKey),39+ fingerprint: next.fingerprint,40+ };41+}42+43+/** The installed identity; throws `identity-missing` before boot installs it. */44+export function requireSharedFieldIdentity(): SharedFieldIdentity {45+ if (identity === null) {46+ throw new SharedFieldError(47+ "identity-missing",48+ "shared fields were used before the account identity was installed",49+ );50+ }51+ return identity;52+}53+54+/** The installed identity, or null before boot installs one. */55+export function sharedFieldIdentityOrNull(): SharedFieldIdentity | null {56+ return identity;57+}58+59+/** Forgets the installed identity; shared fields fail closed afterwards. */60+export function clearSharedFieldIdentity(): void {61+ identity = null;62+}