@noy-db/on-oidc 0.1.0-pre.3 → 0.1.0-pre.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -240,7 +240,8 @@ async function unlockOidc(enrollment, config, idToken, options = {}) {
240
240
  permissions: parsed.permissions,
241
241
  deks,
242
242
  kek: null,
243
- salt: (0, import_hub.base64ToBuffer)(parsed.salt)
243
+ salt: (0, import_hub.base64ToBuffer)(parsed.salt),
244
+ authenticators: []
244
245
  };
245
246
  }
246
247
  var knownProviders = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-oidc —\n *\n * OAuth/OIDC bridge for noy-db with split-key key connector.\n *\n * This package enables federated login (LINE, Google, Apple, Microsoft,\n * Okta, Auth0, Keycloak, any OIDC-compliant provider) without the server\n * ever seeing plaintext or the unwrapped KEK.\n *\n * Split-key model (Bitwarden-style key connector)\n * ────────────────────────────────────────────────\n * The KEK is XOR-split into two equal-length key halves:\n *\n * serverHalf ⊕ deviceHalf = KEK\n *\n * At enrollment time:\n * 1. The full KEK is available (user is authenticated via passphrase).\n * 2. `deviceHalf` is derived: HKDF(deviceSecret, \"noydb-oidc-device-v1\", sub)\n * where `deviceSecret` is a per-device random value stored in IndexedDB\n * or localStorage (never transmitted).\n * 3. `serverHalf = KEK ⊕ deviceHalf`.\n * 4. `serverHalf` is encrypted with the OIDC access token and sent to the\n * key-connector server endpoint. The server stores it indexed by `sub`\n * (the OIDC subject claim).\n * 5. The server NEVER sees the KEK — only serverHalf, encrypted.\n *\n * At unlock time:\n * 1. User completes OIDC flow → gets an ID token with `sub` claim.\n * 2. Client presents the ID token to the key-connector server.\n * 3. Server verifies the ID token, decrypts and returns `serverHalf`.\n * 4. Client derives `deviceHalf` (same HKDF, same deviceSecret).\n * 5. Client reconstructs `KEK = serverHalf ⊕ deviceHalf`.\n * 6. Client derives DEKs from the keyring file using the reconstructed KEK.\n *\n * Security properties:\n * - Compromise of the OIDC provider (stolen tokens): attacker has the\n * ID token → can get `serverHalf`. But without `deviceHalf` (which is\n * device-local, never transmitted), they cannot reconstruct the KEK.\n * - Compromise of the key-connector server: attacker gets all `serverHalf`\n * values. Still cannot reconstruct KEKs without the per-device secrets.\n * - Phishing / stolen device: attacker has `deviceHalf` (from the device)\n * but needs a valid OIDC token to get `serverHalf`.\n * - Full compromise (OIDC + key-connector + device): KEK is reconstructed.\n * This is the threat model boundary — NOYDB does not defend against an\n * attacker who controls all three.\n *\n * Key-connector server contract\n * ──────────────────────────────\n * This package handles the CLIENT side only. The server must:\n * 1. Expose a `PUT /kek-fragment` endpoint that accepts:\n * `{ idToken, encryptedServerHalf, iv }` and stores the decrypted\n * serverHalf indexed by the `sub` from the verified ID token.\n * 2. Expose a `GET /kek-fragment` endpoint that accepts a Bearer ID token,\n * verifies it, and returns the encrypted `{ serverHalf, iv }` for that `sub`.\n * 3. Rotate the encryption key used for serverHalf periodically, with\n * re-encryption at login time (beyond NOYDB's scope).\n *\n * Provider configuration\n * ──────────────────────\n * The `OidcProviderConfig` type describes the OIDC provider and the\n * key-connector endpoint URL. See `knownProviders` for pre-built configs\n * for common providers (LINE, Google, Apple).\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { UnlockedKeyring, Role } from '@noy-db/hub'\n\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when an OIDC ID token cannot be parsed, is expired, or fails\n * signature verification.\n *\n * This package does NOT perform full server-side verification — that is the\n * responsibility of the key-connector server. `OidcTokenError` is thrown\n * client-side when the token is structurally invalid (missing `sub`, expired\n * `exp`, wrong issuer) before a network call is made.\n */\nexport class OidcTokenError extends Error {\n readonly code = 'OIDC_TOKEN_ERROR'\n constructor(message: string) {\n super(message)\n this.name = 'OidcTokenError'\n }\n}\n\n/**\n * Thrown when the key-connector server returns an error response.\n *\n * The `status` field carries the HTTP status code when available — use it\n * to distinguish 401 Unauthorized (bad token, re-authenticate) from 404\n * (no key fragment stored for this sub, must re-enroll) and 5xx\n * (server-side error, retry).\n */\nexport class KeyConnectorError extends Error {\n readonly code = 'KEY_CONNECTOR_ERROR'\n /** HTTP status code from the key-connector server, if available. */\n readonly status: number | undefined\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'KeyConnectorError'\n this.status = status\n }\n}\n\n/**\n * Thrown by `unlockOidc()` when the per-device secret for the OIDC subject\n * cannot be found in browser storage.\n *\n * The device secret is generated once at `enrollOidc()` time and stored in\n * IndexedDB/localStorage on the enrolling device — it is never transmitted.\n * This error means the device secret was cleared (storage wipe, private\n * browsing session, new device) and the user must re-enroll using their\n * passphrase.\n */\nexport class OidcDeviceSecretNotFoundError extends Error {\n readonly code = 'OIDC_DEVICE_SECRET_NOT_FOUND'\n constructor(sub: string) {\n super(\n `No device secret found for OIDC subject \"${sub}\". ` +\n 'The device secret is generated at enrollment and stored in browser storage. ' +\n 'Re-enroll on this device to restore OIDC unlock.',\n )\n this.name = 'OidcDeviceSecretNotFoundError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/** OIDC provider configuration. */\nexport interface OidcProviderConfig {\n /** Provider name for display and debugging (e.g. 'LINE', 'Google'). */\n readonly name: string\n /** OIDC issuer URL. Used to discover the JWKS endpoint for token verification. */\n readonly issuer: string\n /** OAuth2 client ID for this application. */\n readonly clientId: string\n /** OAuth2 scopes to request. Default: ['openid', 'email']. */\n readonly scopes?: string[]\n /** Authorization endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly authorizationEndpoint?: string\n /** Token endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly tokenEndpoint?: string\n /**\n * Base URL of the noy-db key-connector server.\n * Must expose `PUT /kek-fragment` and `GET /kek-fragment`.\n */\n readonly keyConnectorUrl: string\n}\n\n/** The enrollment record persisted per-device per-OIDC-provider. */\nexport interface OidcEnrollment {\n readonly _noydb_oidc: 1\n /** OIDC provider name from `OidcProviderConfig.name`. */\n readonly providerName: string\n /** OIDC subject claim (`sub`) of the enrolled user. */\n readonly sub: string\n /** The vault this enrollment unlocks. */\n readonly vault: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n /**\n * Device-specific identifier (not the secret itself — the secret lives\n * in IndexedDB/localStorage). Used to look up the device secret at unlock.\n */\n readonly deviceKeyId: string\n /** Number of active enrollments for this sub (for key rotation auditing). */\n readonly enrollmentCount: number\n}\n\n/** Minimal JWT claims we need from an OIDC ID token. */\nexport interface OidcClaims {\n readonly sub: string\n readonly iss: string\n readonly aud: string | string[]\n readonly iat: number\n readonly exp: number\n readonly email?: string\n readonly name?: string\n}\n\n/** Options for `enrollOidc()`. */\nexport interface EnrollOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n/** Options for `unlockOidc()`. */\nexport interface UnlockOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n// ─── Device secret management ─────────────────────────────────────────\n\nconst DEVICE_SECRET_PREFIX = 'noydb:oidc:device-secret:'\n\nfunction getDeviceSecret(sub: string, storage?: Storage): Uint8Array | null {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) return null\n const raw = store.getItem(DEVICE_SECRET_PREFIX + sub)\n if (!raw) return null\n return base64ToBuffer(raw)\n}\n\nfunction saveDeviceSecret(sub: string, secret: Uint8Array, storage?: Storage): void {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) throw new ValidationError('localStorage is not available in this environment')\n store.setItem(DEVICE_SECRET_PREFIX + sub, bufferToBase64(secret))\n}\n\n// ─── HKDF device half derivation ──────────────────────────────────────\n\nasync function deriveDeviceHalf(\n deviceSecret: Uint8Array,\n sub: string,\n vault: string,\n keyLengthBytes: number,\n): Promise<Uint8Array> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey('raw', deviceSecret as Uint8Array<ArrayBuffer>, 'HKDF', false, ['deriveBits'])\n const bits = await subtle.deriveBits(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode(sub),\n info: new TextEncoder().encode(`noydb-oidc-device-v1:${vault}`),\n },\n ikm,\n keyLengthBytes * 8,\n )\n return new Uint8Array(bits)\n}\n\n// ─── XOR key operations ────────────────────────────────────────────────\n\nfunction xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {\n if (a.length !== b.length) {\n throw new Error(`xorBytes: length mismatch (${a.length} vs ${b.length})`)\n }\n const out = new Uint8Array(a.length)\n for (let i = 0; i < a.length; i++) out[i] = a[i]! ^ b[i]!\n return out\n}\n\n// ─── ID token parsing (client-side, no verification) ──────────────────\n\n/**\n * Extract claims from a JWT ID token without cryptographic verification.\n * Verification is the key-connector server's responsibility.\n *\n * We do check the `exp` claim client-side as a fast path to avoid\n * unnecessary network calls with obviously expired tokens.\n */\nexport function parseIdTokenClaims(idToken: string): OidcClaims {\n const parts = idToken.split('.')\n if (parts.length !== 3) {\n throw new OidcTokenError('Invalid ID token format: expected 3 JWT segments')\n }\n const payloadPart = parts[1]!\n // Base64url decode (add padding as needed)\n const padded = payloadPart + '='.repeat((4 - payloadPart.length % 4) % 4)\n const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))\n const claims = JSON.parse(json) as OidcClaims\n if (!claims.sub) throw new OidcTokenError('ID token missing \"sub\" claim')\n if (!claims.exp) throw new OidcTokenError('ID token missing \"exp\" claim')\n return claims\n}\n\n/**\n * Check if an ID token is expired based on its `exp` claim.\n * Does NOT perform cryptographic signature verification.\n */\nexport function isIdTokenExpired(idToken: string): boolean {\n try {\n const claims = parseIdTokenClaims(idToken)\n return Date.now() / 1000 > claims.exp\n } catch {\n return true\n }\n}\n\n// ─── Key-connector HTTP helpers ────────────────────────────────────────\n\nasync function putServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n serverHalfBase64: string,\n ivBase64: string,\n): Promise<void> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n body: JSON.stringify({ encryptedServerHalf: serverHalfBase64, iv: ivBase64 }),\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector PUT failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n}\n\nasync function getServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n): Promise<{ encryptedServerHalf: string; iv: string }> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${idToken}` },\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector GET failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n return resp.json() as Promise<{ encryptedServerHalf: string; iv: string }>\n}\n\n// ─── Enrollment ────────────────────────────────────────────────────────\n\n/**\n * Enroll a device for OIDC unlock (client-side portion).\n *\n * Requires an unlocked keyring (user is already authenticated) and a valid\n * OIDC ID token. Derives the device half, XOR-splits the serialized keyring\n * payload, encrypts the server half with the ID token, and sends it to the\n * key-connector server.\n *\n * Returns an `OidcEnrollment` that should be stored (e.g. in localStorage\n * or a noy-db collection) so `unlockOidc()` can look up the `sub` and\n * `deviceKeyId` later.\n *\n * @param keyring - The currently unlocked keyring.\n * @param vault - The vault to enroll for.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function enrollOidc(\n keyring: UnlockedKeyring,\n vault: string,\n config: OidcProviderConfig,\n idToken: string,\n options: EnrollOidcOptions = {},\n): Promise<OidcEnrollment> {\n const claims = parseIdTokenClaims(idToken)\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before enrolling.')\n }\n\n const subtle = globalThis.crypto.subtle\n\n // Serialize the keyring payload (same as session.ts)\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n const payloadJson = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n const payloadBytes = new TextEncoder().encode(payloadJson)\n\n // Generate a device secret and derive the device half\n const deviceSecret = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const deviceKeyId = bufferToBase64(globalThis.crypto.getRandomValues(new Uint8Array(16)))\n saveDeviceSecret(claims.sub, deviceSecret, options.storage)\n\n // Pad payload to the next 16-byte boundary to avoid leaking length\n const padLen = (16 - (payloadBytes.length % 16)) % 16\n const padded = new Uint8Array(payloadBytes.length + padLen)\n padded.set(payloadBytes)\n\n const deviceHalf = await deriveDeviceHalf(deviceSecret, claims.sub, vault, padded.length)\n const serverHalf = xorBytes(padded, deviceHalf)\n\n // Encrypt serverHalf with the ID token using HKDF(idToken) as the key\n // The server decrypts this with the same derivation, storing only the plaintext serverHalf\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encryptedServerHalf = await subtle.encrypt(\n { name: 'AES-GCM', iv },\n tokenKey,\n serverHalf as Uint8Array<ArrayBuffer>,\n )\n\n await putServerHalf(\n config.keyConnectorUrl,\n idToken,\n bufferToBase64(encryptedServerHalf),\n bufferToBase64(iv),\n )\n\n return {\n _noydb_oidc: 1,\n providerName: config.name,\n sub: claims.sub,\n vault,\n enrolledAt: new Date().toISOString(),\n deviceKeyId,\n enrollmentCount: 1,\n }\n}\n\n/**\n * Unlock a vault using OIDC (client-side portion).\n *\n * Fetches the server half from the key-connector server, derives the device\n * half from the local device secret, XORs them to reconstruct the keyring\n * payload, and returns an UnlockedKeyring.\n *\n * @param enrollment - The enrollment record from `enrollOidc()`.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function unlockOidc(\n enrollment: OidcEnrollment,\n config: OidcProviderConfig,\n idToken: string,\n options: UnlockOidcOptions = {},\n): Promise<UnlockedKeyring> {\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before unlocking.')\n }\n\n const { sub, vault } = enrollment\n\n // Load the device secret\n const deviceSecret = getDeviceSecret(sub, options.storage)\n if (!deviceSecret) {\n throw new OidcDeviceSecretNotFoundError(sub)\n }\n\n // Fetch the server half from the key-connector\n const { encryptedServerHalf, iv: ivBase64 } = await getServerHalf(config.keyConnectorUrl, idToken)\n\n // Decrypt the server half using the ID token\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const serverHalf = new Uint8Array(\n await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBuffer(ivBase64) },\n tokenKey,\n base64ToBuffer(encryptedServerHalf),\n ),\n )\n\n // Reconstruct the padded payload\n const deviceHalf = await deriveDeviceHalf(deviceSecret, sub, vault, serverHalf.length)\n const padded = xorBytes(serverHalf, deviceHalf)\n\n // Parse the payload (strip padding: find the last non-null byte + 1)\n let payloadEnd = padded.length\n while (payloadEnd > 0 && padded[payloadEnd - 1] === 0) payloadEnd--\n const payloadJson = new TextDecoder().decode(padded.subarray(0, payloadEnd))\n\n const parsed = JSON.parse(payloadJson) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const subtle = globalThis.crypto.subtle\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null as unknown as CryptoKey,\n salt: base64ToBuffer(parsed.salt),\n }\n}\n\n// ─── Known provider configs ────────────────────────────────────────────\n\n/**\n * Pre-built provider configs for common OIDC providers.\n * Pass your client ID and key-connector URL to get a complete config.\n */\nexport const knownProviders = {\n line: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'LINE',\n issuer: 'https://access.line.me',\n clientId,\n scopes: ['openid', 'profile', 'email'],\n authorizationEndpoint: 'https://access.line.me/oauth2/v2.1/authorize',\n tokenEndpoint: 'https://api.line.me/oauth2/v2.1/token',\n keyConnectorUrl,\n }),\n google: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Google',\n issuer: 'https://accounts.google.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n apple: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Apple',\n issuer: 'https://appleid.apple.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n}\n\n// ─── Internal ─────────────────────────────────────────────────────────\n\n/**\n * Derive a transient AES-256-GCM key from an OIDC ID token.\n * Used to encrypt the server half before transmitting to the key-connector.\n * The server derives the same key to decrypt and store the plaintext serverHalf.\n */\nasync function deriveKeyFromIdToken(idToken: string): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(idToken),\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-oidc-transport-encrypt-v1'),\n info: new TextEncoder().encode('key-connector-put'),\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n"],"mappings":"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","names":["import_hub"]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-oidc —\n *\n * OAuth/OIDC bridge for noy-db with split-key key connector.\n *\n * This package enables federated login (LINE, Google, Apple, Microsoft,\n * Okta, Auth0, Keycloak, any OIDC-compliant provider) without the server\n * ever seeing plaintext or the unwrapped KEK.\n *\n * Split-key model (Bitwarden-style key connector)\n * ────────────────────────────────────────────────\n * The KEK is XOR-split into two equal-length key halves:\n *\n * serverHalf ⊕ deviceHalf = KEK\n *\n * At enrollment time:\n * 1. The full KEK is available (user is authenticated via passphrase).\n * 2. `deviceHalf` is derived: HKDF(deviceSecret, \"noydb-oidc-device-v1\", sub)\n * where `deviceSecret` is a per-device random value stored in IndexedDB\n * or localStorage (never transmitted).\n * 3. `serverHalf = KEK ⊕ deviceHalf`.\n * 4. `serverHalf` is encrypted with the OIDC access token and sent to the\n * key-connector server endpoint. The server stores it indexed by `sub`\n * (the OIDC subject claim).\n * 5. The server NEVER sees the KEK — only serverHalf, encrypted.\n *\n * At unlock time:\n * 1. User completes OIDC flow → gets an ID token with `sub` claim.\n * 2. Client presents the ID token to the key-connector server.\n * 3. Server verifies the ID token, decrypts and returns `serverHalf`.\n * 4. Client derives `deviceHalf` (same HKDF, same deviceSecret).\n * 5. Client reconstructs `KEK = serverHalf ⊕ deviceHalf`.\n * 6. Client derives DEKs from the keyring file using the reconstructed KEK.\n *\n * Security properties:\n * - Compromise of the OIDC provider (stolen tokens): attacker has the\n * ID token → can get `serverHalf`. But without `deviceHalf` (which is\n * device-local, never transmitted), they cannot reconstruct the KEK.\n * - Compromise of the key-connector server: attacker gets all `serverHalf`\n * values. Still cannot reconstruct KEKs without the per-device secrets.\n * - Phishing / stolen device: attacker has `deviceHalf` (from the device)\n * but needs a valid OIDC token to get `serverHalf`.\n * - Full compromise (OIDC + key-connector + device): KEK is reconstructed.\n * This is the threat model boundary — NOYDB does not defend against an\n * attacker who controls all three.\n *\n * Key-connector server contract\n * ──────────────────────────────\n * This package handles the CLIENT side only. The server must:\n * 1. Expose a `PUT /kek-fragment` endpoint that accepts:\n * `{ idToken, encryptedServerHalf, iv }` and stores the decrypted\n * serverHalf indexed by the `sub` from the verified ID token.\n * 2. Expose a `GET /kek-fragment` endpoint that accepts a Bearer ID token,\n * verifies it, and returns the encrypted `{ serverHalf, iv }` for that `sub`.\n * 3. Rotate the encryption key used for serverHalf periodically, with\n * re-encryption at login time (beyond NOYDB's scope).\n *\n * Provider configuration\n * ──────────────────────\n * The `OidcProviderConfig` type describes the OIDC provider and the\n * key-connector endpoint URL. See `knownProviders` for pre-built configs\n * for common providers (LINE, Google, Apple).\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { UnlockedKeyring, Role } from '@noy-db/hub'\n\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when an OIDC ID token cannot be parsed, is expired, or fails\n * signature verification.\n *\n * This package does NOT perform full server-side verification — that is the\n * responsibility of the key-connector server. `OidcTokenError` is thrown\n * client-side when the token is structurally invalid (missing `sub`, expired\n * `exp`, wrong issuer) before a network call is made.\n */\nexport class OidcTokenError extends Error {\n readonly code = 'OIDC_TOKEN_ERROR'\n constructor(message: string) {\n super(message)\n this.name = 'OidcTokenError'\n }\n}\n\n/**\n * Thrown when the key-connector server returns an error response.\n *\n * The `status` field carries the HTTP status code when available — use it\n * to distinguish 401 Unauthorized (bad token, re-authenticate) from 404\n * (no key fragment stored for this sub, must re-enroll) and 5xx\n * (server-side error, retry).\n */\nexport class KeyConnectorError extends Error {\n readonly code = 'KEY_CONNECTOR_ERROR'\n /** HTTP status code from the key-connector server, if available. */\n readonly status: number | undefined\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'KeyConnectorError'\n this.status = status\n }\n}\n\n/**\n * Thrown by `unlockOidc()` when the per-device secret for the OIDC subject\n * cannot be found in browser storage.\n *\n * The device secret is generated once at `enrollOidc()` time and stored in\n * IndexedDB/localStorage on the enrolling device — it is never transmitted.\n * This error means the device secret was cleared (storage wipe, private\n * browsing session, new device) and the user must re-enroll using their\n * passphrase.\n */\nexport class OidcDeviceSecretNotFoundError extends Error {\n readonly code = 'OIDC_DEVICE_SECRET_NOT_FOUND'\n constructor(sub: string) {\n super(\n `No device secret found for OIDC subject \"${sub}\". ` +\n 'The device secret is generated at enrollment and stored in browser storage. ' +\n 'Re-enroll on this device to restore OIDC unlock.',\n )\n this.name = 'OidcDeviceSecretNotFoundError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/** OIDC provider configuration. */\nexport interface OidcProviderConfig {\n /** Provider name for display and debugging (e.g. 'LINE', 'Google'). */\n readonly name: string\n /** OIDC issuer URL. Used to discover the JWKS endpoint for token verification. */\n readonly issuer: string\n /** OAuth2 client ID for this application. */\n readonly clientId: string\n /** OAuth2 scopes to request. Default: ['openid', 'email']. */\n readonly scopes?: string[]\n /** Authorization endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly authorizationEndpoint?: string\n /** Token endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly tokenEndpoint?: string\n /**\n * Base URL of the noy-db key-connector server.\n * Must expose `PUT /kek-fragment` and `GET /kek-fragment`.\n */\n readonly keyConnectorUrl: string\n}\n\n/** The enrollment record persisted per-device per-OIDC-provider. */\nexport interface OidcEnrollment {\n readonly _noydb_oidc: 1\n /** OIDC provider name from `OidcProviderConfig.name`. */\n readonly providerName: string\n /** OIDC subject claim (`sub`) of the enrolled user. */\n readonly sub: string\n /** The vault this enrollment unlocks. */\n readonly vault: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n /**\n * Device-specific identifier (not the secret itself — the secret lives\n * in IndexedDB/localStorage). Used to look up the device secret at unlock.\n */\n readonly deviceKeyId: string\n /** Number of active enrollments for this sub (for key rotation auditing). */\n readonly enrollmentCount: number\n}\n\n/** Minimal JWT claims we need from an OIDC ID token. */\nexport interface OidcClaims {\n readonly sub: string\n readonly iss: string\n readonly aud: string | string[]\n readonly iat: number\n readonly exp: number\n readonly email?: string\n readonly name?: string\n}\n\n/** Options for `enrollOidc()`. */\nexport interface EnrollOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n/** Options for `unlockOidc()`. */\nexport interface UnlockOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n// ─── Device secret management ─────────────────────────────────────────\n\nconst DEVICE_SECRET_PREFIX = 'noydb:oidc:device-secret:'\n\nfunction getDeviceSecret(sub: string, storage?: Storage): Uint8Array | null {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) return null\n const raw = store.getItem(DEVICE_SECRET_PREFIX + sub)\n if (!raw) return null\n return base64ToBuffer(raw)\n}\n\nfunction saveDeviceSecret(sub: string, secret: Uint8Array, storage?: Storage): void {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) throw new ValidationError('localStorage is not available in this environment')\n store.setItem(DEVICE_SECRET_PREFIX + sub, bufferToBase64(secret))\n}\n\n// ─── HKDF device half derivation ──────────────────────────────────────\n\nasync function deriveDeviceHalf(\n deviceSecret: Uint8Array,\n sub: string,\n vault: string,\n keyLengthBytes: number,\n): Promise<Uint8Array> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey('raw', deviceSecret as Uint8Array<ArrayBuffer>, 'HKDF', false, ['deriveBits'])\n const bits = await subtle.deriveBits(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode(sub),\n info: new TextEncoder().encode(`noydb-oidc-device-v1:${vault}`),\n },\n ikm,\n keyLengthBytes * 8,\n )\n return new Uint8Array(bits)\n}\n\n// ─── XOR key operations ────────────────────────────────────────────────\n\nfunction xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {\n if (a.length !== b.length) {\n throw new Error(`xorBytes: length mismatch (${a.length} vs ${b.length})`)\n }\n const out = new Uint8Array(a.length)\n for (let i = 0; i < a.length; i++) out[i] = a[i]! ^ b[i]!\n return out\n}\n\n// ─── ID token parsing (client-side, no verification) ──────────────────\n\n/**\n * Extract claims from a JWT ID token without cryptographic verification.\n * Verification is the key-connector server's responsibility.\n *\n * We do check the `exp` claim client-side as a fast path to avoid\n * unnecessary network calls with obviously expired tokens.\n */\nexport function parseIdTokenClaims(idToken: string): OidcClaims {\n const parts = idToken.split('.')\n if (parts.length !== 3) {\n throw new OidcTokenError('Invalid ID token format: expected 3 JWT segments')\n }\n const payloadPart = parts[1]!\n // Base64url decode (add padding as needed)\n const padded = payloadPart + '='.repeat((4 - payloadPart.length % 4) % 4)\n const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))\n const claims = JSON.parse(json) as OidcClaims\n if (!claims.sub) throw new OidcTokenError('ID token missing \"sub\" claim')\n if (!claims.exp) throw new OidcTokenError('ID token missing \"exp\" claim')\n return claims\n}\n\n/**\n * Check if an ID token is expired based on its `exp` claim.\n * Does NOT perform cryptographic signature verification.\n */\nexport function isIdTokenExpired(idToken: string): boolean {\n try {\n const claims = parseIdTokenClaims(idToken)\n return Date.now() / 1000 > claims.exp\n } catch {\n return true\n }\n}\n\n// ─── Key-connector HTTP helpers ────────────────────────────────────────\n\nasync function putServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n serverHalfBase64: string,\n ivBase64: string,\n): Promise<void> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n body: JSON.stringify({ encryptedServerHalf: serverHalfBase64, iv: ivBase64 }),\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector PUT failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n}\n\nasync function getServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n): Promise<{ encryptedServerHalf: string; iv: string }> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${idToken}` },\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector GET failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n return resp.json() as Promise<{ encryptedServerHalf: string; iv: string }>\n}\n\n// ─── Enrollment ────────────────────────────────────────────────────────\n\n/**\n * Enroll a device for OIDC unlock (client-side portion).\n *\n * Requires an unlocked keyring (user is already authenticated) and a valid\n * OIDC ID token. Derives the device half, XOR-splits the serialized keyring\n * payload, encrypts the server half with the ID token, and sends it to the\n * key-connector server.\n *\n * Returns an `OidcEnrollment` that should be stored (e.g. in localStorage\n * or a noy-db collection) so `unlockOidc()` can look up the `sub` and\n * `deviceKeyId` later.\n *\n * @param keyring - The currently unlocked keyring.\n * @param vault - The vault to enroll for.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function enrollOidc(\n keyring: UnlockedKeyring,\n vault: string,\n config: OidcProviderConfig,\n idToken: string,\n options: EnrollOidcOptions = {},\n): Promise<OidcEnrollment> {\n const claims = parseIdTokenClaims(idToken)\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before enrolling.')\n }\n\n const subtle = globalThis.crypto.subtle\n\n // Serialize the keyring payload (same as session.ts)\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n const payloadJson = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n const payloadBytes = new TextEncoder().encode(payloadJson)\n\n // Generate a device secret and derive the device half\n const deviceSecret = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const deviceKeyId = bufferToBase64(globalThis.crypto.getRandomValues(new Uint8Array(16)))\n saveDeviceSecret(claims.sub, deviceSecret, options.storage)\n\n // Pad payload to the next 16-byte boundary to avoid leaking length\n const padLen = (16 - (payloadBytes.length % 16)) % 16\n const padded = new Uint8Array(payloadBytes.length + padLen)\n padded.set(payloadBytes)\n\n const deviceHalf = await deriveDeviceHalf(deviceSecret, claims.sub, vault, padded.length)\n const serverHalf = xorBytes(padded, deviceHalf)\n\n // Encrypt serverHalf with the ID token using HKDF(idToken) as the key\n // The server decrypts this with the same derivation, storing only the plaintext serverHalf\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encryptedServerHalf = await subtle.encrypt(\n { name: 'AES-GCM', iv },\n tokenKey,\n serverHalf as Uint8Array<ArrayBuffer>,\n )\n\n await putServerHalf(\n config.keyConnectorUrl,\n idToken,\n bufferToBase64(encryptedServerHalf),\n bufferToBase64(iv),\n )\n\n return {\n _noydb_oidc: 1,\n providerName: config.name,\n sub: claims.sub,\n vault,\n enrolledAt: new Date().toISOString(),\n deviceKeyId,\n enrollmentCount: 1,\n }\n}\n\n/**\n * Unlock a vault using OIDC (client-side portion).\n *\n * Fetches the server half from the key-connector server, derives the device\n * half from the local device secret, XORs them to reconstruct the keyring\n * payload, and returns an UnlockedKeyring.\n *\n * @param enrollment - The enrollment record from `enrollOidc()`.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function unlockOidc(\n enrollment: OidcEnrollment,\n config: OidcProviderConfig,\n idToken: string,\n options: UnlockOidcOptions = {},\n): Promise<UnlockedKeyring> {\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before unlocking.')\n }\n\n const { sub, vault } = enrollment\n\n // Load the device secret\n const deviceSecret = getDeviceSecret(sub, options.storage)\n if (!deviceSecret) {\n throw new OidcDeviceSecretNotFoundError(sub)\n }\n\n // Fetch the server half from the key-connector\n const { encryptedServerHalf, iv: ivBase64 } = await getServerHalf(config.keyConnectorUrl, idToken)\n\n // Decrypt the server half using the ID token\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const serverHalf = new Uint8Array(\n await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBuffer(ivBase64) },\n tokenKey,\n base64ToBuffer(encryptedServerHalf),\n ),\n )\n\n // Reconstruct the padded payload\n const deviceHalf = await deriveDeviceHalf(deviceSecret, sub, vault, serverHalf.length)\n const padded = xorBytes(serverHalf, deviceHalf)\n\n // Parse the payload (strip padding: find the last non-null byte + 1)\n let payloadEnd = padded.length\n while (payloadEnd > 0 && padded[payloadEnd - 1] === 0) payloadEnd--\n const payloadJson = new TextDecoder().decode(padded.subarray(0, payloadEnd))\n\n const parsed = JSON.parse(payloadJson) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const subtle = globalThis.crypto.subtle\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null as unknown as CryptoKey,\n salt: base64ToBuffer(parsed.salt),\n authenticators: [],\n }\n}\n\n// ─── Known provider configs ────────────────────────────────────────────\n\n/**\n * Pre-built provider configs for common OIDC providers.\n * Pass your client ID and key-connector URL to get a complete config.\n */\nexport const knownProviders = {\n line: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'LINE',\n issuer: 'https://access.line.me',\n clientId,\n scopes: ['openid', 'profile', 'email'],\n authorizationEndpoint: 'https://access.line.me/oauth2/v2.1/authorize',\n tokenEndpoint: 'https://api.line.me/oauth2/v2.1/token',\n keyConnectorUrl,\n }),\n google: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Google',\n issuer: 'https://accounts.google.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n apple: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Apple',\n issuer: 'https://appleid.apple.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n}\n\n// ─── Internal ─────────────────────────────────────────────────────────\n\n/**\n * Derive a transient AES-256-GCM key from an OIDC ID token.\n * Used to encrypt the server half before transmitting to the key-connector.\n * The server derives the same key to decrypt and store the plaintext serverHalf.\n */\nasync function deriveKeyFromIdToken(idToken: string): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(idToken),\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-oidc-transport-encrypt-v1'),\n info: new TextEncoder().encode('key-connector-put'),\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n"],"mappings":"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","names":["import_hub"]}
package/dist/index.js CHANGED
@@ -208,7 +208,8 @@ async function unlockOidc(enrollment, config, idToken, options = {}) {
208
208
  permissions: parsed.permissions,
209
209
  deks,
210
210
  kek: null,
211
- salt: base64ToBuffer(parsed.salt)
211
+ salt: base64ToBuffer(parsed.salt),
212
+ authenticators: []
212
213
  };
213
214
  }
214
215
  var knownProviders = {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-oidc —\n *\n * OAuth/OIDC bridge for noy-db with split-key key connector.\n *\n * This package enables federated login (LINE, Google, Apple, Microsoft,\n * Okta, Auth0, Keycloak, any OIDC-compliant provider) without the server\n * ever seeing plaintext or the unwrapped KEK.\n *\n * Split-key model (Bitwarden-style key connector)\n * ────────────────────────────────────────────────\n * The KEK is XOR-split into two equal-length key halves:\n *\n * serverHalf ⊕ deviceHalf = KEK\n *\n * At enrollment time:\n * 1. The full KEK is available (user is authenticated via passphrase).\n * 2. `deviceHalf` is derived: HKDF(deviceSecret, \"noydb-oidc-device-v1\", sub)\n * where `deviceSecret` is a per-device random value stored in IndexedDB\n * or localStorage (never transmitted).\n * 3. `serverHalf = KEK ⊕ deviceHalf`.\n * 4. `serverHalf` is encrypted with the OIDC access token and sent to the\n * key-connector server endpoint. The server stores it indexed by `sub`\n * (the OIDC subject claim).\n * 5. The server NEVER sees the KEK — only serverHalf, encrypted.\n *\n * At unlock time:\n * 1. User completes OIDC flow → gets an ID token with `sub` claim.\n * 2. Client presents the ID token to the key-connector server.\n * 3. Server verifies the ID token, decrypts and returns `serverHalf`.\n * 4. Client derives `deviceHalf` (same HKDF, same deviceSecret).\n * 5. Client reconstructs `KEK = serverHalf ⊕ deviceHalf`.\n * 6. Client derives DEKs from the keyring file using the reconstructed KEK.\n *\n * Security properties:\n * - Compromise of the OIDC provider (stolen tokens): attacker has the\n * ID token → can get `serverHalf`. But without `deviceHalf` (which is\n * device-local, never transmitted), they cannot reconstruct the KEK.\n * - Compromise of the key-connector server: attacker gets all `serverHalf`\n * values. Still cannot reconstruct KEKs without the per-device secrets.\n * - Phishing / stolen device: attacker has `deviceHalf` (from the device)\n * but needs a valid OIDC token to get `serverHalf`.\n * - Full compromise (OIDC + key-connector + device): KEK is reconstructed.\n * This is the threat model boundary — NOYDB does not defend against an\n * attacker who controls all three.\n *\n * Key-connector server contract\n * ──────────────────────────────\n * This package handles the CLIENT side only. The server must:\n * 1. Expose a `PUT /kek-fragment` endpoint that accepts:\n * `{ idToken, encryptedServerHalf, iv }` and stores the decrypted\n * serverHalf indexed by the `sub` from the verified ID token.\n * 2. Expose a `GET /kek-fragment` endpoint that accepts a Bearer ID token,\n * verifies it, and returns the encrypted `{ serverHalf, iv }` for that `sub`.\n * 3. Rotate the encryption key used for serverHalf periodically, with\n * re-encryption at login time (beyond NOYDB's scope).\n *\n * Provider configuration\n * ──────────────────────\n * The `OidcProviderConfig` type describes the OIDC provider and the\n * key-connector endpoint URL. See `knownProviders` for pre-built configs\n * for common providers (LINE, Google, Apple).\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { UnlockedKeyring, Role } from '@noy-db/hub'\n\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when an OIDC ID token cannot be parsed, is expired, or fails\n * signature verification.\n *\n * This package does NOT perform full server-side verification — that is the\n * responsibility of the key-connector server. `OidcTokenError` is thrown\n * client-side when the token is structurally invalid (missing `sub`, expired\n * `exp`, wrong issuer) before a network call is made.\n */\nexport class OidcTokenError extends Error {\n readonly code = 'OIDC_TOKEN_ERROR'\n constructor(message: string) {\n super(message)\n this.name = 'OidcTokenError'\n }\n}\n\n/**\n * Thrown when the key-connector server returns an error response.\n *\n * The `status` field carries the HTTP status code when available — use it\n * to distinguish 401 Unauthorized (bad token, re-authenticate) from 404\n * (no key fragment stored for this sub, must re-enroll) and 5xx\n * (server-side error, retry).\n */\nexport class KeyConnectorError extends Error {\n readonly code = 'KEY_CONNECTOR_ERROR'\n /** HTTP status code from the key-connector server, if available. */\n readonly status: number | undefined\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'KeyConnectorError'\n this.status = status\n }\n}\n\n/**\n * Thrown by `unlockOidc()` when the per-device secret for the OIDC subject\n * cannot be found in browser storage.\n *\n * The device secret is generated once at `enrollOidc()` time and stored in\n * IndexedDB/localStorage on the enrolling device — it is never transmitted.\n * This error means the device secret was cleared (storage wipe, private\n * browsing session, new device) and the user must re-enroll using their\n * passphrase.\n */\nexport class OidcDeviceSecretNotFoundError extends Error {\n readonly code = 'OIDC_DEVICE_SECRET_NOT_FOUND'\n constructor(sub: string) {\n super(\n `No device secret found for OIDC subject \"${sub}\". ` +\n 'The device secret is generated at enrollment and stored in browser storage. ' +\n 'Re-enroll on this device to restore OIDC unlock.',\n )\n this.name = 'OidcDeviceSecretNotFoundError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/** OIDC provider configuration. */\nexport interface OidcProviderConfig {\n /** Provider name for display and debugging (e.g. 'LINE', 'Google'). */\n readonly name: string\n /** OIDC issuer URL. Used to discover the JWKS endpoint for token verification. */\n readonly issuer: string\n /** OAuth2 client ID for this application. */\n readonly clientId: string\n /** OAuth2 scopes to request. Default: ['openid', 'email']. */\n readonly scopes?: string[]\n /** Authorization endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly authorizationEndpoint?: string\n /** Token endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly tokenEndpoint?: string\n /**\n * Base URL of the noy-db key-connector server.\n * Must expose `PUT /kek-fragment` and `GET /kek-fragment`.\n */\n readonly keyConnectorUrl: string\n}\n\n/** The enrollment record persisted per-device per-OIDC-provider. */\nexport interface OidcEnrollment {\n readonly _noydb_oidc: 1\n /** OIDC provider name from `OidcProviderConfig.name`. */\n readonly providerName: string\n /** OIDC subject claim (`sub`) of the enrolled user. */\n readonly sub: string\n /** The vault this enrollment unlocks. */\n readonly vault: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n /**\n * Device-specific identifier (not the secret itself — the secret lives\n * in IndexedDB/localStorage). Used to look up the device secret at unlock.\n */\n readonly deviceKeyId: string\n /** Number of active enrollments for this sub (for key rotation auditing). */\n readonly enrollmentCount: number\n}\n\n/** Minimal JWT claims we need from an OIDC ID token. */\nexport interface OidcClaims {\n readonly sub: string\n readonly iss: string\n readonly aud: string | string[]\n readonly iat: number\n readonly exp: number\n readonly email?: string\n readonly name?: string\n}\n\n/** Options for `enrollOidc()`. */\nexport interface EnrollOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n/** Options for `unlockOidc()`. */\nexport interface UnlockOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n// ─── Device secret management ─────────────────────────────────────────\n\nconst DEVICE_SECRET_PREFIX = 'noydb:oidc:device-secret:'\n\nfunction getDeviceSecret(sub: string, storage?: Storage): Uint8Array | null {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) return null\n const raw = store.getItem(DEVICE_SECRET_PREFIX + sub)\n if (!raw) return null\n return base64ToBuffer(raw)\n}\n\nfunction saveDeviceSecret(sub: string, secret: Uint8Array, storage?: Storage): void {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) throw new ValidationError('localStorage is not available in this environment')\n store.setItem(DEVICE_SECRET_PREFIX + sub, bufferToBase64(secret))\n}\n\n// ─── HKDF device half derivation ──────────────────────────────────────\n\nasync function deriveDeviceHalf(\n deviceSecret: Uint8Array,\n sub: string,\n vault: string,\n keyLengthBytes: number,\n): Promise<Uint8Array> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey('raw', deviceSecret as Uint8Array<ArrayBuffer>, 'HKDF', false, ['deriveBits'])\n const bits = await subtle.deriveBits(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode(sub),\n info: new TextEncoder().encode(`noydb-oidc-device-v1:${vault}`),\n },\n ikm,\n keyLengthBytes * 8,\n )\n return new Uint8Array(bits)\n}\n\n// ─── XOR key operations ────────────────────────────────────────────────\n\nfunction xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {\n if (a.length !== b.length) {\n throw new Error(`xorBytes: length mismatch (${a.length} vs ${b.length})`)\n }\n const out = new Uint8Array(a.length)\n for (let i = 0; i < a.length; i++) out[i] = a[i]! ^ b[i]!\n return out\n}\n\n// ─── ID token parsing (client-side, no verification) ──────────────────\n\n/**\n * Extract claims from a JWT ID token without cryptographic verification.\n * Verification is the key-connector server's responsibility.\n *\n * We do check the `exp` claim client-side as a fast path to avoid\n * unnecessary network calls with obviously expired tokens.\n */\nexport function parseIdTokenClaims(idToken: string): OidcClaims {\n const parts = idToken.split('.')\n if (parts.length !== 3) {\n throw new OidcTokenError('Invalid ID token format: expected 3 JWT segments')\n }\n const payloadPart = parts[1]!\n // Base64url decode (add padding as needed)\n const padded = payloadPart + '='.repeat((4 - payloadPart.length % 4) % 4)\n const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))\n const claims = JSON.parse(json) as OidcClaims\n if (!claims.sub) throw new OidcTokenError('ID token missing \"sub\" claim')\n if (!claims.exp) throw new OidcTokenError('ID token missing \"exp\" claim')\n return claims\n}\n\n/**\n * Check if an ID token is expired based on its `exp` claim.\n * Does NOT perform cryptographic signature verification.\n */\nexport function isIdTokenExpired(idToken: string): boolean {\n try {\n const claims = parseIdTokenClaims(idToken)\n return Date.now() / 1000 > claims.exp\n } catch {\n return true\n }\n}\n\n// ─── Key-connector HTTP helpers ────────────────────────────────────────\n\nasync function putServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n serverHalfBase64: string,\n ivBase64: string,\n): Promise<void> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n body: JSON.stringify({ encryptedServerHalf: serverHalfBase64, iv: ivBase64 }),\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector PUT failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n}\n\nasync function getServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n): Promise<{ encryptedServerHalf: string; iv: string }> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${idToken}` },\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector GET failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n return resp.json() as Promise<{ encryptedServerHalf: string; iv: string }>\n}\n\n// ─── Enrollment ────────────────────────────────────────────────────────\n\n/**\n * Enroll a device for OIDC unlock (client-side portion).\n *\n * Requires an unlocked keyring (user is already authenticated) and a valid\n * OIDC ID token. Derives the device half, XOR-splits the serialized keyring\n * payload, encrypts the server half with the ID token, and sends it to the\n * key-connector server.\n *\n * Returns an `OidcEnrollment` that should be stored (e.g. in localStorage\n * or a noy-db collection) so `unlockOidc()` can look up the `sub` and\n * `deviceKeyId` later.\n *\n * @param keyring - The currently unlocked keyring.\n * @param vault - The vault to enroll for.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function enrollOidc(\n keyring: UnlockedKeyring,\n vault: string,\n config: OidcProviderConfig,\n idToken: string,\n options: EnrollOidcOptions = {},\n): Promise<OidcEnrollment> {\n const claims = parseIdTokenClaims(idToken)\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before enrolling.')\n }\n\n const subtle = globalThis.crypto.subtle\n\n // Serialize the keyring payload (same as session.ts)\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n const payloadJson = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n const payloadBytes = new TextEncoder().encode(payloadJson)\n\n // Generate a device secret and derive the device half\n const deviceSecret = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const deviceKeyId = bufferToBase64(globalThis.crypto.getRandomValues(new Uint8Array(16)))\n saveDeviceSecret(claims.sub, deviceSecret, options.storage)\n\n // Pad payload to the next 16-byte boundary to avoid leaking length\n const padLen = (16 - (payloadBytes.length % 16)) % 16\n const padded = new Uint8Array(payloadBytes.length + padLen)\n padded.set(payloadBytes)\n\n const deviceHalf = await deriveDeviceHalf(deviceSecret, claims.sub, vault, padded.length)\n const serverHalf = xorBytes(padded, deviceHalf)\n\n // Encrypt serverHalf with the ID token using HKDF(idToken) as the key\n // The server decrypts this with the same derivation, storing only the plaintext serverHalf\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encryptedServerHalf = await subtle.encrypt(\n { name: 'AES-GCM', iv },\n tokenKey,\n serverHalf as Uint8Array<ArrayBuffer>,\n )\n\n await putServerHalf(\n config.keyConnectorUrl,\n idToken,\n bufferToBase64(encryptedServerHalf),\n bufferToBase64(iv),\n )\n\n return {\n _noydb_oidc: 1,\n providerName: config.name,\n sub: claims.sub,\n vault,\n enrolledAt: new Date().toISOString(),\n deviceKeyId,\n enrollmentCount: 1,\n }\n}\n\n/**\n * Unlock a vault using OIDC (client-side portion).\n *\n * Fetches the server half from the key-connector server, derives the device\n * half from the local device secret, XORs them to reconstruct the keyring\n * payload, and returns an UnlockedKeyring.\n *\n * @param enrollment - The enrollment record from `enrollOidc()`.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function unlockOidc(\n enrollment: OidcEnrollment,\n config: OidcProviderConfig,\n idToken: string,\n options: UnlockOidcOptions = {},\n): Promise<UnlockedKeyring> {\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before unlocking.')\n }\n\n const { sub, vault } = enrollment\n\n // Load the device secret\n const deviceSecret = getDeviceSecret(sub, options.storage)\n if (!deviceSecret) {\n throw new OidcDeviceSecretNotFoundError(sub)\n }\n\n // Fetch the server half from the key-connector\n const { encryptedServerHalf, iv: ivBase64 } = await getServerHalf(config.keyConnectorUrl, idToken)\n\n // Decrypt the server half using the ID token\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const serverHalf = new Uint8Array(\n await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBuffer(ivBase64) },\n tokenKey,\n base64ToBuffer(encryptedServerHalf),\n ),\n )\n\n // Reconstruct the padded payload\n const deviceHalf = await deriveDeviceHalf(deviceSecret, sub, vault, serverHalf.length)\n const padded = xorBytes(serverHalf, deviceHalf)\n\n // Parse the payload (strip padding: find the last non-null byte + 1)\n let payloadEnd = padded.length\n while (payloadEnd > 0 && padded[payloadEnd - 1] === 0) payloadEnd--\n const payloadJson = new TextDecoder().decode(padded.subarray(0, payloadEnd))\n\n const parsed = JSON.parse(payloadJson) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const subtle = globalThis.crypto.subtle\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null as unknown as CryptoKey,\n salt: base64ToBuffer(parsed.salt),\n }\n}\n\n// ─── Known provider configs ────────────────────────────────────────────\n\n/**\n * Pre-built provider configs for common OIDC providers.\n * Pass your client ID and key-connector URL to get a complete config.\n */\nexport const knownProviders = {\n line: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'LINE',\n issuer: 'https://access.line.me',\n clientId,\n scopes: ['openid', 'profile', 'email'],\n authorizationEndpoint: 'https://access.line.me/oauth2/v2.1/authorize',\n tokenEndpoint: 'https://api.line.me/oauth2/v2.1/token',\n keyConnectorUrl,\n }),\n google: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Google',\n issuer: 'https://accounts.google.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n apple: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Apple',\n issuer: 'https://appleid.apple.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n}\n\n// ─── Internal ─────────────────────────────────────────────────────────\n\n/**\n * Derive a transient AES-256-GCM key from an OIDC ID token.\n * Used to encrypt the server half before transmitting to the key-connector.\n * The server derives the same key to decrypt and store the plaintext serverHalf.\n */\nasync function deriveKeyFromIdToken(idToken: string): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(idToken),\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-oidc-transport-encrypt-v1'),\n info: new TextEncoder().encode('key-connector-put'),\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n"],"mappings":"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","names":["ValidationError"]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @noy-db/on-oidc —\n *\n * OAuth/OIDC bridge for noy-db with split-key key connector.\n *\n * This package enables federated login (LINE, Google, Apple, Microsoft,\n * Okta, Auth0, Keycloak, any OIDC-compliant provider) without the server\n * ever seeing plaintext or the unwrapped KEK.\n *\n * Split-key model (Bitwarden-style key connector)\n * ────────────────────────────────────────────────\n * The KEK is XOR-split into two equal-length key halves:\n *\n * serverHalf ⊕ deviceHalf = KEK\n *\n * At enrollment time:\n * 1. The full KEK is available (user is authenticated via passphrase).\n * 2. `deviceHalf` is derived: HKDF(deviceSecret, \"noydb-oidc-device-v1\", sub)\n * where `deviceSecret` is a per-device random value stored in IndexedDB\n * or localStorage (never transmitted).\n * 3. `serverHalf = KEK ⊕ deviceHalf`.\n * 4. `serverHalf` is encrypted with the OIDC access token and sent to the\n * key-connector server endpoint. The server stores it indexed by `sub`\n * (the OIDC subject claim).\n * 5. The server NEVER sees the KEK — only serverHalf, encrypted.\n *\n * At unlock time:\n * 1. User completes OIDC flow → gets an ID token with `sub` claim.\n * 2. Client presents the ID token to the key-connector server.\n * 3. Server verifies the ID token, decrypts and returns `serverHalf`.\n * 4. Client derives `deviceHalf` (same HKDF, same deviceSecret).\n * 5. Client reconstructs `KEK = serverHalf ⊕ deviceHalf`.\n * 6. Client derives DEKs from the keyring file using the reconstructed KEK.\n *\n * Security properties:\n * - Compromise of the OIDC provider (stolen tokens): attacker has the\n * ID token → can get `serverHalf`. But without `deviceHalf` (which is\n * device-local, never transmitted), they cannot reconstruct the KEK.\n * - Compromise of the key-connector server: attacker gets all `serverHalf`\n * values. Still cannot reconstruct KEKs without the per-device secrets.\n * - Phishing / stolen device: attacker has `deviceHalf` (from the device)\n * but needs a valid OIDC token to get `serverHalf`.\n * - Full compromise (OIDC + key-connector + device): KEK is reconstructed.\n * This is the threat model boundary — NOYDB does not defend against an\n * attacker who controls all three.\n *\n * Key-connector server contract\n * ──────────────────────────────\n * This package handles the CLIENT side only. The server must:\n * 1. Expose a `PUT /kek-fragment` endpoint that accepts:\n * `{ idToken, encryptedServerHalf, iv }` and stores the decrypted\n * serverHalf indexed by the `sub` from the verified ID token.\n * 2. Expose a `GET /kek-fragment` endpoint that accepts a Bearer ID token,\n * verifies it, and returns the encrypted `{ serverHalf, iv }` for that `sub`.\n * 3. Rotate the encryption key used for serverHalf periodically, with\n * re-encryption at login time (beyond NOYDB's scope).\n *\n * Provider configuration\n * ──────────────────────\n * The `OidcProviderConfig` type describes the OIDC provider and the\n * key-connector endpoint URL. See `knownProviders` for pre-built configs\n * for common providers (LINE, Google, Apple).\n */\n\nimport { bufferToBase64, base64ToBuffer } from '@noy-db/hub'\nimport { ValidationError } from '@noy-db/hub'\nimport type { UnlockedKeyring, Role } from '@noy-db/hub'\n\nexport { ValidationError } from '@noy-db/hub'\n\n// ─── Error types ──────────────────────────────────────────────────────\n\n/**\n * Thrown when an OIDC ID token cannot be parsed, is expired, or fails\n * signature verification.\n *\n * This package does NOT perform full server-side verification — that is the\n * responsibility of the key-connector server. `OidcTokenError` is thrown\n * client-side when the token is structurally invalid (missing `sub`, expired\n * `exp`, wrong issuer) before a network call is made.\n */\nexport class OidcTokenError extends Error {\n readonly code = 'OIDC_TOKEN_ERROR'\n constructor(message: string) {\n super(message)\n this.name = 'OidcTokenError'\n }\n}\n\n/**\n * Thrown when the key-connector server returns an error response.\n *\n * The `status` field carries the HTTP status code when available — use it\n * to distinguish 401 Unauthorized (bad token, re-authenticate) from 404\n * (no key fragment stored for this sub, must re-enroll) and 5xx\n * (server-side error, retry).\n */\nexport class KeyConnectorError extends Error {\n readonly code = 'KEY_CONNECTOR_ERROR'\n /** HTTP status code from the key-connector server, if available. */\n readonly status: number | undefined\n constructor(message: string, status?: number) {\n super(message)\n this.name = 'KeyConnectorError'\n this.status = status\n }\n}\n\n/**\n * Thrown by `unlockOidc()` when the per-device secret for the OIDC subject\n * cannot be found in browser storage.\n *\n * The device secret is generated once at `enrollOidc()` time and stored in\n * IndexedDB/localStorage on the enrolling device — it is never transmitted.\n * This error means the device secret was cleared (storage wipe, private\n * browsing session, new device) and the user must re-enroll using their\n * passphrase.\n */\nexport class OidcDeviceSecretNotFoundError extends Error {\n readonly code = 'OIDC_DEVICE_SECRET_NOT_FOUND'\n constructor(sub: string) {\n super(\n `No device secret found for OIDC subject \"${sub}\". ` +\n 'The device secret is generated at enrollment and stored in browser storage. ' +\n 'Re-enroll on this device to restore OIDC unlock.',\n )\n this.name = 'OidcDeviceSecretNotFoundError'\n }\n}\n\n// ─── Types ────────────────────────────────────────────────────────────\n\n/** OIDC provider configuration. */\nexport interface OidcProviderConfig {\n /** Provider name for display and debugging (e.g. 'LINE', 'Google'). */\n readonly name: string\n /** OIDC issuer URL. Used to discover the JWKS endpoint for token verification. */\n readonly issuer: string\n /** OAuth2 client ID for this application. */\n readonly clientId: string\n /** OAuth2 scopes to request. Default: ['openid', 'email']. */\n readonly scopes?: string[]\n /** Authorization endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly authorizationEndpoint?: string\n /** Token endpoint URL. If omitted, discovered from issuer's .well-known. */\n readonly tokenEndpoint?: string\n /**\n * Base URL of the noy-db key-connector server.\n * Must expose `PUT /kek-fragment` and `GET /kek-fragment`.\n */\n readonly keyConnectorUrl: string\n}\n\n/** The enrollment record persisted per-device per-OIDC-provider. */\nexport interface OidcEnrollment {\n readonly _noydb_oidc: 1\n /** OIDC provider name from `OidcProviderConfig.name`. */\n readonly providerName: string\n /** OIDC subject claim (`sub`) of the enrolled user. */\n readonly sub: string\n /** The vault this enrollment unlocks. */\n readonly vault: string\n /** ISO timestamp of enrollment. */\n readonly enrolledAt: string\n /**\n * Device-specific identifier (not the secret itself — the secret lives\n * in IndexedDB/localStorage). Used to look up the device secret at unlock.\n */\n readonly deviceKeyId: string\n /** Number of active enrollments for this sub (for key rotation auditing). */\n readonly enrollmentCount: number\n}\n\n/** Minimal JWT claims we need from an OIDC ID token. */\nexport interface OidcClaims {\n readonly sub: string\n readonly iss: string\n readonly aud: string | string[]\n readonly iat: number\n readonly exp: number\n readonly email?: string\n readonly name?: string\n}\n\n/** Options for `enrollOidc()`. */\nexport interface EnrollOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n/** Options for `unlockOidc()`. */\nexport interface UnlockOidcOptions {\n /** Optional storage override for the device secret. Default: localStorage. */\n storage?: Storage\n}\n\n// ─── Device secret management ─────────────────────────────────────────\n\nconst DEVICE_SECRET_PREFIX = 'noydb:oidc:device-secret:'\n\nfunction getDeviceSecret(sub: string, storage?: Storage): Uint8Array | null {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) return null\n const raw = store.getItem(DEVICE_SECRET_PREFIX + sub)\n if (!raw) return null\n return base64ToBuffer(raw)\n}\n\nfunction saveDeviceSecret(sub: string, secret: Uint8Array, storage?: Storage): void {\n const store = storage ?? (typeof localStorage !== 'undefined' ? localStorage : null)\n if (!store) throw new ValidationError('localStorage is not available in this environment')\n store.setItem(DEVICE_SECRET_PREFIX + sub, bufferToBase64(secret))\n}\n\n// ─── HKDF device half derivation ──────────────────────────────────────\n\nasync function deriveDeviceHalf(\n deviceSecret: Uint8Array,\n sub: string,\n vault: string,\n keyLengthBytes: number,\n): Promise<Uint8Array> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey('raw', deviceSecret as Uint8Array<ArrayBuffer>, 'HKDF', false, ['deriveBits'])\n const bits = await subtle.deriveBits(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode(sub),\n info: new TextEncoder().encode(`noydb-oidc-device-v1:${vault}`),\n },\n ikm,\n keyLengthBytes * 8,\n )\n return new Uint8Array(bits)\n}\n\n// ─── XOR key operations ────────────────────────────────────────────────\n\nfunction xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {\n if (a.length !== b.length) {\n throw new Error(`xorBytes: length mismatch (${a.length} vs ${b.length})`)\n }\n const out = new Uint8Array(a.length)\n for (let i = 0; i < a.length; i++) out[i] = a[i]! ^ b[i]!\n return out\n}\n\n// ─── ID token parsing (client-side, no verification) ──────────────────\n\n/**\n * Extract claims from a JWT ID token without cryptographic verification.\n * Verification is the key-connector server's responsibility.\n *\n * We do check the `exp` claim client-side as a fast path to avoid\n * unnecessary network calls with obviously expired tokens.\n */\nexport function parseIdTokenClaims(idToken: string): OidcClaims {\n const parts = idToken.split('.')\n if (parts.length !== 3) {\n throw new OidcTokenError('Invalid ID token format: expected 3 JWT segments')\n }\n const payloadPart = parts[1]!\n // Base64url decode (add padding as needed)\n const padded = payloadPart + '='.repeat((4 - payloadPart.length % 4) % 4)\n const json = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))\n const claims = JSON.parse(json) as OidcClaims\n if (!claims.sub) throw new OidcTokenError('ID token missing \"sub\" claim')\n if (!claims.exp) throw new OidcTokenError('ID token missing \"exp\" claim')\n return claims\n}\n\n/**\n * Check if an ID token is expired based on its `exp` claim.\n * Does NOT perform cryptographic signature verification.\n */\nexport function isIdTokenExpired(idToken: string): boolean {\n try {\n const claims = parseIdTokenClaims(idToken)\n return Date.now() / 1000 > claims.exp\n } catch {\n return true\n }\n}\n\n// ─── Key-connector HTTP helpers ────────────────────────────────────────\n\nasync function putServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n serverHalfBase64: string,\n ivBase64: string,\n): Promise<void> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'PUT',\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n body: JSON.stringify({ encryptedServerHalf: serverHalfBase64, iv: ivBase64 }),\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector PUT failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n}\n\nasync function getServerHalf(\n keyConnectorUrl: string,\n idToken: string,\n): Promise<{ encryptedServerHalf: string; iv: string }> {\n const resp = await fetch(`${keyConnectorUrl}/kek-fragment`, {\n method: 'GET',\n headers: { Authorization: `Bearer ${idToken}` },\n })\n if (!resp.ok) {\n throw new KeyConnectorError(\n `Key connector GET failed: ${resp.status} ${resp.statusText}`,\n resp.status,\n )\n }\n return resp.json() as Promise<{ encryptedServerHalf: string; iv: string }>\n}\n\n// ─── Enrollment ────────────────────────────────────────────────────────\n\n/**\n * Enroll a device for OIDC unlock (client-side portion).\n *\n * Requires an unlocked keyring (user is already authenticated) and a valid\n * OIDC ID token. Derives the device half, XOR-splits the serialized keyring\n * payload, encrypts the server half with the ID token, and sends it to the\n * key-connector server.\n *\n * Returns an `OidcEnrollment` that should be stored (e.g. in localStorage\n * or a noy-db collection) so `unlockOidc()` can look up the `sub` and\n * `deviceKeyId` later.\n *\n * @param keyring - The currently unlocked keyring.\n * @param vault - The vault to enroll for.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function enrollOidc(\n keyring: UnlockedKeyring,\n vault: string,\n config: OidcProviderConfig,\n idToken: string,\n options: EnrollOidcOptions = {},\n): Promise<OidcEnrollment> {\n const claims = parseIdTokenClaims(idToken)\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before enrolling.')\n }\n\n const subtle = globalThis.crypto.subtle\n\n // Serialize the keyring payload (same as session.ts)\n const dekMap: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n const raw = await subtle.exportKey('raw', dek)\n dekMap[collName] = bufferToBase64(raw)\n }\n const payloadJson = JSON.stringify({\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: dekMap,\n salt: bufferToBase64(keyring.salt),\n })\n const payloadBytes = new TextEncoder().encode(payloadJson)\n\n // Generate a device secret and derive the device half\n const deviceSecret = globalThis.crypto.getRandomValues(new Uint8Array(32))\n const deviceKeyId = bufferToBase64(globalThis.crypto.getRandomValues(new Uint8Array(16)))\n saveDeviceSecret(claims.sub, deviceSecret, options.storage)\n\n // Pad payload to the next 16-byte boundary to avoid leaking length\n const padLen = (16 - (payloadBytes.length % 16)) % 16\n const padded = new Uint8Array(payloadBytes.length + padLen)\n padded.set(payloadBytes)\n\n const deviceHalf = await deriveDeviceHalf(deviceSecret, claims.sub, vault, padded.length)\n const serverHalf = xorBytes(padded, deviceHalf)\n\n // Encrypt serverHalf with the ID token using HKDF(idToken) as the key\n // The server decrypts this with the same derivation, storing only the plaintext serverHalf\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))\n const encryptedServerHalf = await subtle.encrypt(\n { name: 'AES-GCM', iv },\n tokenKey,\n serverHalf as Uint8Array<ArrayBuffer>,\n )\n\n await putServerHalf(\n config.keyConnectorUrl,\n idToken,\n bufferToBase64(encryptedServerHalf),\n bufferToBase64(iv),\n )\n\n return {\n _noydb_oidc: 1,\n providerName: config.name,\n sub: claims.sub,\n vault,\n enrolledAt: new Date().toISOString(),\n deviceKeyId,\n enrollmentCount: 1,\n }\n}\n\n/**\n * Unlock a vault using OIDC (client-side portion).\n *\n * Fetches the server half from the key-connector server, derives the device\n * half from the local device secret, XORs them to reconstruct the keyring\n * payload, and returns an UnlockedKeyring.\n *\n * @param enrollment - The enrollment record from `enrollOidc()`.\n * @param config - OIDC provider + key-connector configuration.\n * @param idToken - A valid OIDC ID token from the completed OIDC flow.\n * @param options - Optional storage override.\n */\nexport async function unlockOidc(\n enrollment: OidcEnrollment,\n config: OidcProviderConfig,\n idToken: string,\n options: UnlockOidcOptions = {},\n): Promise<UnlockedKeyring> {\n if (isIdTokenExpired(idToken)) {\n throw new OidcTokenError('ID token is expired. Complete a fresh OIDC flow before unlocking.')\n }\n\n const { sub, vault } = enrollment\n\n // Load the device secret\n const deviceSecret = getDeviceSecret(sub, options.storage)\n if (!deviceSecret) {\n throw new OidcDeviceSecretNotFoundError(sub)\n }\n\n // Fetch the server half from the key-connector\n const { encryptedServerHalf, iv: ivBase64 } = await getServerHalf(config.keyConnectorUrl, idToken)\n\n // Decrypt the server half using the ID token\n const tokenKey = await deriveKeyFromIdToken(idToken)\n const serverHalf = new Uint8Array(\n await globalThis.crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: base64ToBuffer(ivBase64) },\n tokenKey,\n base64ToBuffer(encryptedServerHalf),\n ),\n )\n\n // Reconstruct the padded payload\n const deviceHalf = await deriveDeviceHalf(deviceSecret, sub, vault, serverHalf.length)\n const padded = xorBytes(serverHalf, deviceHalf)\n\n // Parse the payload (strip padding: find the last non-null byte + 1)\n let payloadEnd = padded.length\n while (payloadEnd > 0 && padded[payloadEnd - 1] === 0) payloadEnd--\n const payloadJson = new TextDecoder().decode(padded.subarray(0, payloadEnd))\n\n const parsed = JSON.parse(payloadJson) as {\n userId: string\n displayName: string\n role: Role\n permissions: Record<string, 'rw' | 'ro'>\n deks: Record<string, string>\n salt: string\n }\n\n const subtle = globalThis.crypto.subtle\n const deks = new Map<string, CryptoKey>()\n for (const [collName, rawBase64] of Object.entries(parsed.deks)) {\n const dek = await subtle.importKey(\n 'raw',\n base64ToBuffer(rawBase64),\n { name: 'AES-GCM', length: 256 },\n true,\n ['encrypt', 'decrypt'],\n )\n deks.set(collName, dek)\n }\n\n return {\n userId: parsed.userId,\n displayName: parsed.displayName,\n role: parsed.role,\n permissions: parsed.permissions,\n deks,\n kek: null as unknown as CryptoKey,\n salt: base64ToBuffer(parsed.salt),\n authenticators: [],\n }\n}\n\n// ─── Known provider configs ────────────────────────────────────────────\n\n/**\n * Pre-built provider configs for common OIDC providers.\n * Pass your client ID and key-connector URL to get a complete config.\n */\nexport const knownProviders = {\n line: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'LINE',\n issuer: 'https://access.line.me',\n clientId,\n scopes: ['openid', 'profile', 'email'],\n authorizationEndpoint: 'https://access.line.me/oauth2/v2.1/authorize',\n tokenEndpoint: 'https://api.line.me/oauth2/v2.1/token',\n keyConnectorUrl,\n }),\n google: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Google',\n issuer: 'https://accounts.google.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n apple: (clientId: string, keyConnectorUrl: string): OidcProviderConfig => ({\n name: 'Apple',\n issuer: 'https://appleid.apple.com',\n clientId,\n scopes: ['openid', 'email'],\n keyConnectorUrl,\n }),\n}\n\n// ─── Internal ─────────────────────────────────────────────────────────\n\n/**\n * Derive a transient AES-256-GCM key from an OIDC ID token.\n * Used to encrypt the server half before transmitting to the key-connector.\n * The server derives the same key to decrypt and store the plaintext serverHalf.\n */\nasync function deriveKeyFromIdToken(idToken: string): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n const ikm = await subtle.importKey(\n 'raw',\n new TextEncoder().encode(idToken),\n 'HKDF',\n false,\n ['deriveKey'],\n )\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: new TextEncoder().encode('noydb-oidc-transport-encrypt-v1'),\n info: new TextEncoder().encode('key-connector-put'),\n },\n ikm,\n { name: 'AES-GCM', length: 256 },\n false,\n ['encrypt', 'decrypt'],\n )\n}\n"],"mappings":"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","names":["ValidationError"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/on-oidc",
3
- "version": "0.1.0-pre.3",
3
+ "version": "0.1.0-pre.5",
4
4
  "description": "OAuth/OIDC bridge for noy-db — federated login (LINE, Google, Apple, Okta) with split-key key connector — server never sees plaintext",
5
5
  "license": "MIT",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",
@@ -39,10 +39,10 @@
39
39
  "node": ">=18.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@noy-db/hub": "0.1.0-pre.3"
42
+ "@noy-db/hub": "0.1.0-pre.5"
43
43
  },
44
44
  "devDependencies": {
45
- "@noy-db/hub": "0.1.0-pre.3"
45
+ "@noy-db/hub": "0.1.0-pre.5"
46
46
  },
47
47
  "keywords": [
48
48
  "noy-db",