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 })