Closer/functions/dist/couples/createInviteCallable.js

188 lines
9.6 KiB
JavaScript

"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 client using the invite
* code as the KDF input. The server stores it opaquely and never sees the plaintext phrase.
*
* Strict E2EE: code, wrappedCoupleKey, kdfSalt, kdfParams, and encryptedRecoveryPhrase are
* all required. There is no plaintext-couple path.
*
* 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.');
}
if (!context.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.');
}
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;
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
if (!clientCode) {
throw new functions.https.HttpsError('invalid-argument', 'code is required.');
}
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
// codes and anything that could be abused as the document id.
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
throw new functions.https.HttpsError('invalid-argument', 'code must be 6 valid characters.');
}
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.');
}
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 {
// Do not store the raw code — it is the KDF seed for the couple's recovery phrase.
await db.collection('users').doc(callerId).collection('notification_queue').add({
type: 'invite_created',
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 an invite; expires ${expiresAt.toDate().toISOString()}`);
return { code, expiresAt };
});
//# sourceMappingURL=createInviteCallable.js.map