fix(accept-invite): dynamic encryptionVersion, Firestore TTL on attempt docs, wipe recoveryPhrase on accept

This commit is contained in:
null 2026-06-21 09:13:29 -05:00
parent 26419ce08d
commit 0a377ecdda
4 changed files with 50 additions and 14 deletions

View File

@ -1,4 +1,11 @@
{
"indexes": [],
"fieldOverrides": []
"fieldOverrides": [
{
"collectionGroup": "invite_attempts",
"fieldPath": "expiresAt",
"ttl": true,
"indexes": []
}
]
}

View File

@ -62,6 +62,7 @@ const admin = __importStar(require("firebase-admin"));
*/
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000; // 25h: rate window + 1h buffer; Firestore TTL cleans up after
exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
@ -85,8 +86,11 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.');
}
// Record this attempt before doing any work, so failures also count.
// expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides.
const attemptExpiresAt = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() + ACCEPT_ATTEMPT_TTL_MS);
await db.collection('users').doc(callerId).collection('invite_attempts').add({
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt: attemptExpiresAt,
code,
});
// Caller must not already be paired.
@ -122,6 +126,15 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
}
const coupleId = db.collection('couples').doc().id;
const coupleRef = db.collection('couples').doc(coupleId);
// Derive encryption version from E2EE field presence.
// encryptionVersion must stay in sync with EncryptionVersion.kt:
// 0 = plaintext (no couple key; iOS MVP path)
// 1 = legacy migration (mixed)
// 2 = strict E2EE (all new Android couples)
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state
// where the client expects a key that does not exist.
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null;
const encryptionVersion = hasE2EE ? 2 : 0;
const batch = db.batch();
batch.set(coupleRef, {
id: coupleId,
@ -129,22 +142,22 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
inviteCode: code,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
streakCount: 0,
// encryptionVersion must stay in sync with Android's
// app/src/main/java/app/closer/crypto/EncryptionVersion.kt.
// v0 = plaintext (iOS MVP, no E2EE); v1 = legacy migration;
// v2 = strict E2EE — the default for all new Android couples.
encryptionVersion: 2,
encryptionVersion,
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
});
batch.update(db.collection('users').doc(inviterUserId), { coupleId });
batch.update(db.collection('users').doc(callerId), { coupleId });
// Wipe the recovery phrase from the invite doc on acceptance.
// It served its purpose (returned to the acceptor above); leaving key material
// in a document that stays in Firestore for 24h is unnecessary exposure.
batch.update(inviteRef, {
status: 'accepted',
acceptedByUserId: callerId,
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
coupleId,
recoveryPhrase: admin.firestore.FieldValue.delete(),
});
await batch.commit();
console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`);

File diff suppressed because one or more lines are too long

View File

@ -25,8 +25,9 @@ import * as admin from 'firebase-admin'
* 6. Update both user documents with the new coupleId.
* 7. Mark the invite as accepted.
*/
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000 // 25h: rate window + 1h buffer; Firestore TTL cleans up after
export const acceptInviteCallable = functions.https.onCall(async (data: any, context) => {
const callerId = context.auth?.uid
@ -55,8 +56,13 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.')
}
// Record this attempt before doing any work, so failures also count.
// expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides.
const attemptExpiresAt = admin.firestore.Timestamp.fromMillis(
admin.firestore.Timestamp.now().toMillis() + ACCEPT_ATTEMPT_TTL_MS
)
await db.collection('users').doc(callerId).collection('invite_attempts').add({
attemptedAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt: attemptExpiresAt,
code,
})
@ -102,6 +108,16 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
const coupleId = db.collection('couples').doc().id
const coupleRef = db.collection('couples').doc(coupleId)
// Derive encryption version from E2EE field presence.
// encryptionVersion must stay in sync with EncryptionVersion.kt:
// 0 = plaintext (no couple key; iOS MVP path)
// 1 = legacy migration (mixed)
// 2 = strict E2EE (all new Android couples)
// Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state
// where the client expects a key that does not exist.
const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null
const encryptionVersion = hasE2EE ? 2 : 0
const batch = db.batch()
batch.set(coupleRef, {
@ -110,11 +126,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
inviteCode: code,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
streakCount: 0,
// encryptionVersion must stay in sync with Android's
// app/src/main/java/app/closer/crypto/EncryptionVersion.kt.
// v0 = plaintext (iOS MVP, no E2EE); v1 = legacy migration;
// v2 = strict E2EE — the default for all new Android couples.
encryptionVersion: 2,
encryptionVersion,
wrappedCoupleKey: wrappedCoupleKey ?? null,
kdfSalt: kdfSalt ?? null,
kdfParams: kdfParams ?? null,
@ -123,11 +135,15 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
batch.update(db.collection('users').doc(inviterUserId), { coupleId })
batch.update(db.collection('users').doc(callerId), { coupleId })
// Wipe the recovery phrase from the invite doc on acceptance.
// It served its purpose (returned to the acceptor above); leaving key material
// in a document that stays in Firestore for 24h is unnecessary exposure.
batch.update(inviteRef, {
status: 'accepted',
acceptedByUserId: callerId,
acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
coupleId,
recoveryPhrase: admin.firestore.FieldValue.delete(),
})
await batch.commit()