160 lines
7.8 KiB
JavaScript
160 lines
7.8 KiB
JavaScript
"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
|