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 { 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
|
||||||
|
|
|
||||||
|
|
@ -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