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. * * 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(): 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 clientCode = data?.code as string | undefined const wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined const kdfSalt = data?.kdfSalt as string | undefined const kdfParams = data?.kdfParams as string | undefined const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase 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) // 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: admin.firestore.DocumentReference | null = null let code: string | null = 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, status: 'pending', createdAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt, usedAt: null, usedByUserId: null, wrappedCoupleKey: wrappedCoupleKey ?? null, kdfSalt: kdfSalt ?? null, kdfParams: kdfParams ?? null, encryptedRecoveryPhrase: 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 } })