fix(accept-invite): dynamic encryptionVersion, Firestore TTL on attempt docs, wipe recoveryPhrase on accept
This commit is contained in:
parent
26419ce08d
commit
0a377ecdda
|
|
@ -1,4 +1,11 @@
|
|||
{
|
||||
"indexes": [],
|
||||
"fieldOverrides": []
|
||||
"fieldOverrides": [
|
||||
{
|
||||
"collectionGroup": "invite_attempts",
|
||||
"fieldPath": "expiresAt",
|
||||
"ttl": true,
|
||||
"indexes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -27,6 +27,7 @@ import * as admin from '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
|
||||
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue