159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
|
|
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 }
|
||
|
|
})
|