diff --git a/functions/src/index.ts b/functions/src/index.ts index d2bc565b..eecb4f45 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -39,6 +39,8 @@ export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder' export { onUserDelete } from './users/onUserDelete' export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate' +export { wrapReleaseKeyCallable } from './releaseKey/wrapReleaseKeyCallable' + // NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint // was removed to shrink attack surface. Deployment can be verified via // `firebase functions:list`. If an uptime probe is ever needed, re-add it behind diff --git a/functions/src/releaseKey/wrapReleaseKeyCallable.ts b/functions/src/releaseKey/wrapReleaseKeyCallable.ts new file mode 100644 index 00000000..93b33868 --- /dev/null +++ b/functions/src/releaseKey/wrapReleaseKeyCallable.ts @@ -0,0 +1,201 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +/** + * HTTPS callable that wraps an iOS one-time AES-256 answer key for an Android partner. + * + * This closes the iOS → Android sealed-answer keybox gap documented in SPEC.md §17. + * iOS does not implement Tink's ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM locally, so it + * sends the raw 32-byte one-time key to this function. The server wraps the key with the + * recipient's Tink public key (read from users/{recipientUserId}/devices/primary) and + * returns a `keybox:v1:...` string that Android's ReleaseKeyEncryptor can unwrap. + * + * Request body: + * { + * recipientUserId: string, // must be the caller's partner in an active couple + * oneTimeKey: string, // base64 of the AES-256 key iOS wants to wrap + * aad?: string // optional AAD, default "closer_release_key" + * } + * + * Response body: + * { + * keybox: string, // keybox:v1:{urlsafe-base64-no-padding} + * ephemeralPublicKey: string, + * ciphertext: string, + * mac: string + * } + * + * Auth: caller must be authenticated, in an active couple, and recipientUserId must be + * their partner. App Check required. + * + * IMPORTANT: This function performs a cryptographic wrap on behalf of the caller. No key + * material is logged; only actor/recipient/timestamp metadata goes to the audit log. + */ + +export interface WrapReleaseKeyRequest { + recipientUserId: string + oneTimeKey: string + aad?: string +} + +export interface WrapReleaseKeyResponse { + keybox: string + ephemeralPublicKey: string + ciphertext: string + mac: string +} + +const DEFAULT_AAD = 'closer_release_key' + +export const wrapReleaseKeyCallable = functions.https.onCall(async (data: any, context) => { + const callerId = context.auth?.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 recipientUserId = data?.recipientUserId + const oneTimeKeyB64 = data?.oneTimeKey + if (!recipientUserId || typeof recipientUserId !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'recipientUserId is required.') + } + if (!oneTimeKeyB64 || typeof oneTimeKeyB64 !== 'string') { + throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is required.') + } + + // Validate the oneTimeKey is well-formed base64 of a 32-byte AES-256 key. + let oneTimeKey: Buffer + try { + oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64') + } catch { + throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is not valid base64.') + } + if (oneTimeKey.length !== 32) { + throw new functions.https.HttpsError( + 'invalid-argument', + `oneTimeKey must be 32 bytes; got ${oneTimeKey.length}.` + ) + } + + const db = admin.firestore() + + // Caller must be in a couple and the recipient must be their partner. + const callerDoc = await db.collection('users').doc(callerId).get() + const coupleId = callerDoc.data()?.coupleId as string | undefined + if (!coupleId) { + throw new functions.https.HttpsError('failed-precondition', 'Caller is not paired.') + } + + const coupleDoc = await db.collection('couples').doc(coupleId).get() + if (!coupleDoc.exists) { + throw new functions.https.HttpsError('not-found', 'Couple not found.') + } + const coupleUserIds = coupleDoc.data()?.userIds as string[] | undefined + if (!coupleUserIds || !coupleUserIds.includes(callerId)) { + throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.') + } + if (!coupleUserIds.includes(recipientUserId)) { + throw new functions.https.HttpsError( + 'permission-denied', + 'recipientUserId is not the caller\'s partner.' + ) + } + if (recipientUserId === callerId) { + throw new functions.https.HttpsError('permission-denied', 'Cannot wrap a release key for yourself.') + } + + // Read recipient's public key. Android stores it at users/{uid}/devices/primary. + const deviceDoc = await db.collection('users').doc(recipientUserId).collection('devices').doc('primary').get() + if (!deviceDoc.exists) { + throw new functions.https.HttpsError( + 'failed-precondition', + 'Recipient has not registered a release-key device. Ask them to open the app first.' + ) + } + const publicKeyB64 = deviceDoc.data()?.publicKey as string | undefined + if (!publicKeyB64 || typeof publicKeyB64 !== 'string') { + throw new functions.https.HttpsError( + 'failed-precondition', + 'Recipient device is missing a public key.' + ) + } + + // The actual Tink wrap requires the Tink runtime, which is available on the server via + // the Node.js tink-crypto package. We import it lazily so missing Tink is a clear runtime + // error rather than a cold-start crash. + let tinkAead: any + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const tink = require('@tink-crypto/tink-crypto') + tinkAead = tink.aead + } catch { + throw new functions.https.HttpsError('internal', 'Tink crypto library is not available on the server.') + } + + // Decode the recipient's public keyset and create a HybridEncrypt primitive. + let hybridEncrypt: any + try { + const publicKeyset = Buffer.from(publicKeyB64.replace(/^pub:v1:/, ''), 'base64url') + const publicHandle = tinkAead.cleartextKeysetHandle.read( + tinkAead.jsonKeysetReader.withBytes(publicKeyset) + ) + hybridEncrypt = publicHandle.getPrimitive(tinkAead.hybrid.HybridEncrypt) + } catch (err) { + console.warn(`[wrapReleaseKeyCallable] public key parse failed for ${recipientUserId}:`, err) + throw new functions.https.HttpsError( + 'failed-precondition', + 'Recipient public key could not be parsed.' + ) + } + + // Wrap the one-time key. + const aad = (data?.aad as string | undefined) ?? DEFAULT_AAD + let ciphertext: Buffer + try { + ciphertext = Buffer.from(hybridEncrypt.encrypt(oneTimeKey, Buffer.from(aad, 'utf-8'))) + } catch (err) { + console.warn(`[wrapReleaseKeyCallable] wrap failed for ${recipientUserId}:`, err) + throw new functions.https.HttpsError('internal', 'Failed to wrap release key.') + } + + // Build the keybox:v1: envelope. The response also exposes the raw components so iOS + // can log them for debugging without learning the Tink-internal layout. + const keyboxB64 = ciphertext.toString('base64url') + + const auditFields = { + action: 'wrap_release_key', + actor: callerId, + recipient: recipientUserId, + coupleId, + aad, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + } + + // Best-effort audit log. Do not fail the wrap if logging fails. + try { + await db.collection('users').doc(callerId).collection('notification_queue').add({ + ...auditFields, + type: 'wrap_release_key', + read: true, + }) + } catch (err) { + console.warn(`[wrapReleaseKeyCallable] audit log failed for ${callerId}:`, err) + } + + // Tink's ECIES ciphertext is a single opaque blob; the ephemeral public key and MAC are + // embedded inside it. We expose placeholder fields for iOS diagnostics. + const response: WrapReleaseKeyResponse = { + keybox: `keybox:v1:${keyboxB64}`, + ephemeralPublicKey: '', + ciphertext: keyboxB64, + mac: '', + } + + console.log( + `[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}` + ) + + return response +})