"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.createInviteCallable = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); /** * HTTPS callable that creates a secure invite code. * * Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create * invites directly. 6-character document IDs are enumerable, so a direct client * write would expose pending invites to scanning. * * Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string } * - code: client-supplied 6-character code (Android). The server validates uniqueness and * returns an error if taken so the client can retry with a new code. If omitted (iOS), * the server generates the code. * - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF * - kdfSalt: base64 KDF salt * - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1) * - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using * the invite code as the KDF input. The server stores it opaquely and never sees the * plaintext phrase. Omitted by iOS until iOS implements E2EE parity. * * When E2EE fields are omitted the function writes nulls; iOS MVP creates * plaintext couples (encryptionVersion=0 on the resulting couple) and does not * supply these fields. Android always supplies them. * * Response: { code: string, expiresAt: Timestamp } * * Operations (all via Admin SDK, so Firestore rules are bypassed): * 1. Verify caller is authenticated and not already paired. * 2. Rate-limit the caller to 5 invite creations per rolling hour. * 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction. * 4. Write the invite document with a 24-hour TTL. */ const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const CODE_LENGTH = 6; const INVITE_TTL_MS = 24 * 60 * 60 * 1000; const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; const RATE_LIMIT_MAX = 5; function generateCode() { let code = ''; const randomValues = Buffer.alloc(CODE_LENGTH); // crypto.randomBytes is synchronous and suitable for Cloud Functions. require('crypto').randomFillSync(randomValues); for (let i = 0; i < CODE_LENGTH; i++) { code += CODE_CHARS[randomValues[i] % CODE_CHARS.length]; } return code; } exports.createInviteCallable = functions.https.onCall(async (data, context) => { var _a, _b, _c; const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; if (!callerId) { throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); } const db = admin.firestore(); // Caller must not already be paired. const callerDoc = await db.collection('users').doc(callerId).get(); if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) { throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.'); } const callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName; // Rate limit: count invites created by this user in the last hour. const now = admin.firestore.Timestamp.now(); const windowStart = admin.firestore.Timestamp.fromMillis(now.toMillis() - RATE_LIMIT_WINDOW_MS); const recentInvitesQuery = db .collection('invites') .where('inviterUserId', '==', callerId) .where('createdAt', '>=', windowStart) .orderBy('createdAt', 'desc') .limit(RATE_LIMIT_MAX + 1); const recentInvites = await recentInvitesQuery.get(); if (recentInvites.size >= RATE_LIMIT_MAX) { throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.'); } const clientCode = data === null || data === void 0 ? void 0 : data.code; const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey; const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt; const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams; const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase; // E2EE fields must be supplied together or omitted together. const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]; const suppliedE2ee = e2eeFields.filter((v) => v != null).length; if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) { throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.'); } const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS); // Android supplies its own code (used as the KDF input for phrase encryption, so the server // must use it as-is). iOS omits the code; the server generates one in that case. // Either way, validate uniqueness via transaction and return an error on collision so the // client can retry with a fresh code. let inviteRef = null; let code = null; const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode); for (const candidate of candidates) { const candidateRef = db.collection('invites').doc(candidate); // eslint-disable-next-line no-await-in-loop const created = await db.runTransaction(async (tx) => { const snap = await tx.get(candidateRef); if (snap.exists) return false; tx.set(candidateRef, { code: candidate, inviterUserId: callerId, inviterDisplayName: callerDisplayName !== null && callerDisplayName !== void 0 ? callerDisplayName : null, status: 'pending', createdAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt, usedAt: null, usedByUserId: null, wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null, }); return true; }); if (created) { code = candidate; inviteRef = candidateRef; break; } } if (!code || !inviteRef) { // Client-supplied code collided; the Android client will retry with a new code. throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.'); } // Write a server-side audit log entry for the inviter. This is not read by // clients and supports the rate-limit count as well as future abuse review. try { await db.collection('users').doc(callerId).collection('notification_queue').add({ type: 'invite_created', inviteCode: code, createdAt: admin.firestore.FieldValue.serverTimestamp(), read: true, }); } catch (err) { // Audit write is best-effort; do not fail the invite if it errors. console.warn(`[createInviteCallable] audit log failed for ${callerId}:`, err); } console.log(`[createInviteCallable] ${callerId} created invite ${code}; expires ${expiresAt.toDate().toISOString()}`); return { code, expiresAt }; }); //# sourceMappingURL=createInviteCallable.js.map