182 lines
9.1 KiB
JavaScript
182 lines
9.1 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 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.');
|
|
}
|
|
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;
|
|
// 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 {
|
|
// 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
|