feat(functions): wrapReleaseKeyCallable — server-side Tink wrap for iOS→Android release keys (closes keybox Path A interop gap)
This commit is contained in:
parent
3d3209806c
commit
fa8005f25f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
Loading…
Reference in New Issue