feat(functions): wrapReleaseKeyCallable — server-side Tink wrap for iOS→Android release keys (closes keybox Path A interop gap)

This commit is contained in:
null 2026-06-28 17:19:06 -05:00
parent 3d3209806c
commit fa8005f25f
2 changed files with 203 additions and 0 deletions

View File

@ -39,6 +39,8 @@ export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
export { onUserDelete } from './users/onUserDelete' export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate' export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate'
export { wrapReleaseKeyCallable } from './releaseKey/wrapReleaseKeyCallable'
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint // NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
// was removed to shrink attack surface. Deployment can be verified via // 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 // `firebase functions:list`. If an uptime probe is ever needed, re-add it behind

View File

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