Skip to main content

Three layers: a short note at the top, the key lines with our take in the middle, the full source at the bottom.

API route

identity-key.ts

The identity-key route. How we manage and rotate user identity keys without ever holding the private half.

Repo path apps/api/src/routes/identity-key.tsLanguage TypeScript

What this is

The endpoint that manages your identity key — the public half of the pair that locks your scans. The server holds the public key only. The private half lives on your device, derived from the phrase, and never travels.

What it proves

Backs the promise that only your recovery phrase can reopen a locked scan. The server is structurally unable to do it because it never holds the private half. Read the promise →

What to look for in the source below

  • Endpoints that accept a public key for upload, plus endpoints that return the public key for download.
  • No endpoint that accepts a private key, a phrase, or a derived secret.
  • Audit-log entries written every time a key is rotated.
Show the full file (353 lines)

352 lines

import { Hono } from "hono";
import type { Context } from "hono";
import type { Env } from "../env";
import { getCookie } from "hono/cookie";
import {
  verifyJwt,
  JWT_AUDIENCE_SESSION,
  JWT_ISSUER_DEFAULT,
} from "../lib/auth";
import {
  defaultIdentityKeyStore,
  IdentityKeyExistsError,
} from "../lib/identity-key-store";
import { D1AuditStore } from "../lib/audit-d1";
import { recordAuditEvent } from "../lib/audit";
import { log } from "../lib/logger";

/**
 * Identity-key route handlers (Wave C2, docs-only E2EE model α).
 *
 * Mount under /v1/auth/identity-key. The CLIENT does all crypto:
 * generates the X25519 keypair, derives the UMK via Argon2id from
 * the 12-word recovery phrase, wraps the secret key + canary. The
 * server is a pure custodian of the public key (which it later
 * wraps DEKs to) and the opaque wrapped blobs.
 *
 *   POST /enroll    one-time: store the new identity key. 409 if a
 *                   row already exists (a deliberate change → rotate).
 *   GET  /material  the user fetches their own material to restore
 *                   the secret key on a new device.
 *   POST /rotate    recovery-phrase change: re-wrap secret key +
 *                   canary under the new UMK; public_key UNCHANGED
 *                   so every existing wrapped DEK stays valid.
 *   POST /remove    hard delete (confirm-gated). The user accepts
 *                   that encrypted originals become unrecoverable.
 *
 * Every endpoint requires an authenticated session. Audit-chain
 * emission lives here (drained from the store, per convention).
 */

export const identityKey = new Hono<{ Bindings: Env }>();

const COOKIE_NAME = "muntin_session";

interface AuthedUser {
  user_id: string;
  org_id: string;
}

async function resolveAuthedUser(
  c: Context<{ Bindings: Env }>,
): Promise<AuthedUser | null> {
  const token = getCookie(c, COOKIE_NAME);
  if (!token) return null;
  const payload = await verifyJwt(token, c.env.JWT_SECRET, {
    audience: JWT_AUDIENCE_SESSION,
    issuer: c.env.JWT_ISSUER || JWT_ISSUER_DEFAULT,
  });
  if (!payload) return null;
  if (payload.token_use === "refresh") return null;
  return { user_id: payload.sub, org_id: payload.org_id };
}

async function emit(
  c: Context<{ Bindings: Env }>,
  opts: {
    action: string;
    actor_id: string;
    org_id: string;
    target_ref?: string;
  },
): Promise<void> {
  if (!c.env.DB) return;
  await recordAuditEvent(new D1AuditStore(c.env.DB), {
    org_id: opts.org_id,
    actor_id: opts.actor_id,
    action: opts.action,
    target_kind: "user",
    target_ref: opts.target_ref ?? opts.actor_id,
  });
}

// X25519 public key is 32 bytes → ~43 base64url chars. It is the
// ONE field the server actually consumes (the C2.3 consumer wraps
// DEKs to it), so unlike the opaque blobs it gets a format + lower
// bound: a malformed key would become a 409-protected, permanently
// broken row fixable only by a destructive rotate/remove (LOW-3).
const PUBLIC_KEY_RE = /^[A-Za-z0-9_-]{40,64}={0,2}$/;

interface KdfBody {
  memory_kib?: number;
  time?: number;
  parallelism?: number;
}

/** Shared field + KDF validation for enroll/rotate. Returns an
 * error code string, or null when valid. */
function validateKeyMaterial(
  b: {
    wrapped_secret_key?: unknown;
    wrap_nonce?: unknown;
    kdf_salt?: unknown;
    canary_ciphertext?: unknown;
    canary_nonce?: unknown;
  },
  kdf?: KdfBody,
): string | null {
  if (
    typeof b.wrapped_secret_key !== "string" ||
    typeof b.wrap_nonce !== "string" ||
    typeof b.kdf_salt !== "string" ||
    typeof b.canary_ciphertext !== "string" ||
    typeof b.canary_nonce !== "string"
  ) {
    return "missing_fields";
  }
  if (
    b.wrapped_secret_key.length > 256 ||
    b.wrap_nonce.length > 32 ||
    b.kdf_salt.length > 64 ||
    b.canary_ciphertext.length > 128 ||
    b.canary_nonce.length > 32 ||
    b.wrapped_secret_key.length === 0 ||
    b.wrap_nonce.length === 0 ||
    b.kdf_salt.length === 0 ||
    b.canary_ciphertext.length === 0 ||
    b.canary_nonce.length === 0
  ) {
    return "field_too_long";
  }
  if (kdf) {
    if (
      kdf.memory_kib !== undefined &&
      (typeof kdf.memory_kib !== "number" ||
        kdf.memory_kib < 32 * 1024 ||
        kdf.memory_kib > 4 * 1024 * 1024)
    ) {
      return "kdf_memory_out_of_range";
    }
    if (
      kdf.time !== undefined &&
      (typeof kdf.time !== "number" || kdf.time < 1 || kdf.time > 32)
    ) {
      return "kdf_time_out_of_range";
    }
    if (
      kdf.parallelism !== undefined &&
      (typeof kdf.parallelism !== "number" ||
        kdf.parallelism < 1 ||
        kdf.parallelism > 16)
    ) {
      return "kdf_parallelism_out_of_range";
    }
  }
  return null;
}

/* ============================================================== */
/* POST /enroll                                                    */
/* ============================================================== */
identityKey.post("/enroll", async (c) => {
  const user = await resolveAuthedUser(c);
  if (!user) return c.json({ error: "unauthorized" }, 401);

  let body: {
    public_key?: string;
    wrapped_secret_key?: string;
    wrap_nonce?: string;
    kdf_salt?: string;
    kdf?: KdfBody;
    canary_ciphertext?: string;
    canary_nonce?: string;
  };
  try {
    body = (await c.req.json()) as typeof body;
  } catch {
    log(c.env, {
      event: "identity_key.enroll.reject",
      level: "warn",
      fields: { user_id: user.user_id, reason: "invalid_json" },
    });
    return c.json({ error: "invalid_json" }, 400);
  }

  if (typeof body.public_key !== "string" || body.public_key.length === 0) {
    return c.json({ error: "missing_fields" }, 400);
  }
  if (!PUBLIC_KEY_RE.test(body.public_key)) {
    // Covers both too-short/long and non-base64url junk.
    return c.json({ error: "invalid_public_key" }, 400);
  }
  const bad = validateKeyMaterial(body, body.kdf);
  if (bad) return c.json({ error: bad }, 400);

  const store = defaultIdentityKeyStore(c.env);
  const now = new Date().toISOString();
  try {
    const row = await store.enroll({
      user_id: user.user_id,
      org_id: user.org_id,
      public_key: body.public_key,
      wrapped_secret_key: body.wrapped_secret_key!,
      wrap_nonce: body.wrap_nonce!,
      kdf_salt: body.kdf_salt!,
      kdf: body.kdf,
      canary_ciphertext: body.canary_ciphertext!,
      canary_nonce: body.canary_nonce!,
      created_at: now,
    });
    await emit(c, {
      action: "identity_key.enroll",
      actor_id: user.user_id,
      org_id: user.org_id,
    });
    return c.json({ generation: row.generation, created_at: row.created_at });
  } catch (e) {
    if (e instanceof IdentityKeyExistsError) {
      // A re-enrol must NOT clobber a key that already wraps live
      // documents — the user goes through /rotate explicitly. This
      // is a security-relevant rejection: emit to the chain too.
      log(c.env, {
        event: "identity_key.enroll.reject",
        level: "warn",
        fields: { user_id: user.user_id, reason: "already_exists" },
      });
      await emit(c, {
        action: "identity_key.enroll.reject",
        actor_id: user.user_id,
        org_id: user.org_id,
      });
      return c.json({ error: "identity_key_exists" }, 409);
    }
    // Any OTHER failure (transient D1, etc.) must NOT masquerade as
    // "key exists" (audit MED-1) — surface a true 500.
    log(c.env, {
      event: "identity_key.enroll.error",
      level: "error",
      fields: { user_id: user.user_id },
    });
    return c.json({ error: "internal_error" }, 500);
  }
});

/* ============================================================== */
/* GET /material  (fetch own material to restore on a new device)  */
/* ============================================================== */
identityKey.get("/material", async (c) => {
  const user = await resolveAuthedUser(c);
  if (!user) return c.json({ error: "unauthorized" }, 401);

  const store = defaultIdentityKeyStore(c.env);
  const row = await store.findForUser(user.user_id);
  if (!row) {
    return c.json({ error: "no_identity_key" }, 404);
  }
  // Audit every fetch (not only confirmed restores) for an
  // SRE-visible trail, matching recovery_seal.restore.begin.
  await emit(c, {
    action: "identity_key.restore.begin",
    actor_id: user.user_id,
    org_id: user.org_id,
  });
  return c.json({
    public_key: row.public_key,
    wrapped_secret_key: row.wrapped_secret_key,
    wrap_nonce: row.wrap_nonce,
    kdf: row.kdf,
    kdf_salt: row.kdf_salt,
    kdf_memory_kib: row.kdf_memory_kib,
    kdf_time: row.kdf_time,
    kdf_parallelism: row.kdf_parallelism,
    canary_ciphertext: row.canary_ciphertext,
    canary_nonce: row.canary_nonce,
    generation: row.generation,
  });
});

/* ============================================================== */
/* POST /rotate  (recovery-phrase change; public_key unchanged)    */
/* ============================================================== */
identityKey.post("/rotate", async (c) => {
  const user = await resolveAuthedUser(c);
  if (!user) return c.json({ error: "unauthorized" }, 401);

  let body: {
    wrapped_secret_key?: string;
    wrap_nonce?: string;
    kdf_salt?: string;
    kdf?: KdfBody;
    canary_ciphertext?: string;
    canary_nonce?: string;
  };
  try {
    body = (await c.req.json()) as typeof body;
  } catch {
    return c.json({ error: "invalid_json" }, 400);
  }
  const bad = validateKeyMaterial(body, body.kdf);
  if (bad) return c.json({ error: bad }, 400);

  const store = defaultIdentityKeyStore(c.env);
  const row = await store.rotate({
    user_id: user.user_id,
    wrapped_secret_key: body.wrapped_secret_key!,
    wrap_nonce: body.wrap_nonce!,
    kdf_salt: body.kdf_salt!,
    kdf: body.kdf,
    canary_ciphertext: body.canary_ciphertext!,
    canary_nonce: body.canary_nonce!,
    rotated_at: new Date().toISOString(),
  });
  if (!row) {
    return c.json({ error: "no_identity_key" }, 404);
  }
  await emit(c, {
    action: "identity_key.rotate",
    actor_id: user.user_id,
    org_id: user.org_id,
  });
  return c.json({ generation: row.generation, rotated_at: row.rotated_at });
});

/* ============================================================== */
/* POST /remove  (DISABLED — capstone audit HIGH)                  */
/* ============================================================== */
// Deleting ONLY the identity row orphans every encrypted document:
// the wrapped DEKs + ciphertext stay, `documents.encryption_state`
// stays 'encrypted' so the reaper excludes them forever — the docs
// become permanently undecryptable AND an unreapable storage leak,
// with no user warning (recovery-pillar violation). A correct
// destructive "forget me" must cascade-purge document_deks + the
// .enc ciphertext + flip/clear the documents rows, with a
// doc-count confirm + a distinct audit action. That guarded flow
// is C2.7 scope. Until then this route fails closed rather than
// silently destroying data. It is not surfaced in any UI today.
identityKey.post("/remove", async (c) => {
  const user = await resolveAuthedUser(c);
  if (!user) return c.json({ error: "unauthorized" }, 401);
  await emit(c, {
    action: "identity_key.remove.blocked",
    actor_id: user.user_id,
    org_id: user.org_id,
  });
  return c.json(
    {
      error: "not_available",
      detail:
        "Destructive identity removal is not available yet — it would orphan your encrypted documents. The guarded forget-me flow ships later.",
    },
    409,
  );
});

See also

This is the file as it lives at the moment of this build. The canonical history lives in git. If you want the full history or a specific commit, write to hello@muntin.digital.

identity-key.ts · Verify · Muntin Ledger · Muntin