import * as functions from 'firebase-functions' import * as admin from '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. This function generates a * unique 6-character code server-side, stores the invite document, and returns * only the code and expiry to the inviter. * * Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string } * - 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) * - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor) * * 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. Generate a unique 6-character alphanumeric code 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(): string { 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 } export const createInviteCallable = functions.https.onCall(async (data: any, context) => { const callerId = context.auth?.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 && callerDoc.data()?.coupleId != null) { throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.') } const callerDisplayName = callerDoc.data()?.displayName as string | undefined // 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 wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined const kdfSalt = data?.kdfSalt as string | undefined const kdfParams = data?.kdfParams as string | undefined const recoveryPhrase = data?.recoveryPhrase as string | undefined // 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) // Race-safe unique code creation via transaction. We attempt a bounded number // of times; each attempt verifies the candidate code is free before creating. const maxAttempts = 10 let inviteRef: admin.firestore.DocumentReference | null = null let code: string | null = null for (let attempt = 0; attempt < maxAttempts; attempt++) { const candidate = generateCode() 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, status: 'pending', createdAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt, usedAt: null, usedByUserId: null, wrappedCoupleKey: wrappedCoupleKey ?? null, kdfSalt: kdfSalt ?? null, kdfParams: kdfParams ?? null, recoveryPhrase: recoveryPhrase ?? null, }) return true }) if (created) { code = candidate inviteRef = candidateRef break } } if (!code || !inviteRef) { throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. 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 } })