Closer/functions/dist/releaseKey/wrapReleaseKeyCallable.js

160 lines
7.8 KiB
JavaScript
Raw Normal View History

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const DEFAULT_AAD = 'closer_release_key';
exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) => {
var _a, _b, _c, _d, _e;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.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 === null || data === void 0 ? void 0 : data.recipientUserId;
const oneTimeKeyB64 = data === null || data === void 0 ? void 0 : 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;
try {
oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64');
}
catch (_f) {
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 = (_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
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 = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds;
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 = (_d = deviceDoc.data()) === null || _d === void 0 ? void 0 : _d.publicKey;
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;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tink = require('@tink-crypto/tink-crypto');
tinkAead = tink.aead;
}
catch (_g) {
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;
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 = (_e = data === null || data === void 0 ? void 0 : data.aad) !== null && _e !== void 0 ? _e : DEFAULT_AAD;
let ciphertext;
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(Object.assign(Object.assign({}, 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 = {
keybox: `keybox:v1:${keyboxB64}`,
ephemeralPublicKey: '',
ciphertext: keyboxB64,
mac: '',
};
console.log(`[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}`);
return response;
});
//# sourceMappingURL=wrapReleaseKeyCallable.js.map