Add E2EE key pair verification before send

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Jamie Brunton committed 2026-01-02 22:30:51 +00:00
1 parent 69f43ffb7d
commit 0ffa7fa952
6 files changed
+148 -29

No files matched your search

+1 -5
View File
@@ -51,11 +51,7 @@
"expo-build-properties", "expo-build-properties",
{ {
"android": { "android": {
"targetSdkVersion": 35, "targetSdkVersion": 35
"blockedPermissions": [
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO"
]
} }
} }
], ],
+22 -14
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 46; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -19,7 +19,7 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; }; 13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
@@ -390,6 +390,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)", "$(inherited)",
@@ -397,7 +398,10 @@
); );
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -423,9 +427,13 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -490,14 +498,14 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -546,13 +554,13 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
USE_HERMES = true; USE_HERMES = true;
+5 -1
View File
@@ -184,7 +184,11 @@ export const ChatScreen: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error("Error sending message:", err); console.error("Error sending message:", err);
Alert.alert("Error", "Failed to send message"); // Show the actual error message if it's user-friendly (from encryption)
const errorMessage = err instanceof Error && err.message
? err.message
: "Failed to send message";
Alert.alert("Error", errorMessage);
setInputText(content); setInputText(content);
} }
}; };
+60 -7
View File
@@ -8,7 +8,7 @@
* consistency with all other encryption operations. * consistency with all other encryption operations.
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState, useRef } from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { import {
encryptTextMessage, encryptTextMessage,
@@ -16,6 +16,7 @@ import {
getAdminKeyId, getAdminKeyId,
isValidPublicKeyJwk, isValidPublicKeyJwk,
sanitizePublicKeyJwk, sanitizePublicKeyJwk,
verifyKeyPair,
} from '../lib/encryption'; } from '../lib/encryption';
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption'; import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
import { useEncryptionKeys } from './useEncryptionKeys'; import { useEncryptionKeys } from './useEncryptionKeys';
@@ -42,6 +43,9 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
const [isEncrypting, setIsEncrypting] = useState(false); const [isEncrypting, setIsEncrypting] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
// Track whether we've verified the key pair this session
const keyPairVerifiedRef = useRef(false);
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys(); const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
const refreshPartner = useAuthStore((s) => s.refreshPartner); const refreshPartner = useAuthStore((s) => s.refreshPartner);
@@ -63,12 +67,35 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
// This ensures we always use the most current keys, not potentially stale hook state // This ensures we always use the most current keys, not potentially stale hook state
const currentKeys = useAuthStore.getState().encryptionKeys; const currentKeys = useAuthStore.getState().encryptionKeys;
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) { if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
console.warn('[E2EE Send] No keys available in global store at encryption time'); const keysError = new Error('Encryption keys not ready. Please wait a moment and try again.');
return null; console.warn('[E2EE Send] Keys not fully available in global store');
setError(keysError);
throw keysError;
} }
const senderPublicKey = currentKeys.publicKeyJwk; const senderPublicKey = currentKeys.publicKeyJwk;
const senderPrivateKey = currentKeys.privateKeyJwk;
// SAFETY CHECK: Verify the key pair works together before encrypting
// This prevents creating unrecoverable messages if keys are mismatched
if (!keyPairVerifiedRef.current) {
console.log('[E2EE Send] Verifying sender key pair...');
const isValidKeyPair = await verifyKeyPair(
senderPublicKey as RSAPublicKeyJWK,
senderPrivateKey
);
if (!isValidKeyPair) {
const keyError = new Error('Encryption key verification failed. Please restart the app and try again.');
console.error('[E2EE Send] CRITICAL: Sender key pair verification failed!');
setError(keyError);
throw keyError;
}
keyPairVerifiedRef.current = true;
console.log('[E2EE Send] Sender key pair verified successfully');
}
console.log( console.log(
'[E2EE Send] Using sender public key from global store, version:', '[E2EE Send] Using sender public key from global store, version:',
@@ -85,8 +112,33 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
const freshPartner = await refreshPartner(); const freshPartner = await refreshPartner();
// Get admin key for moderation access // Get admin key for moderation access
const adminPublicKey = await getAdminPublicKey(); // SAFETY: Validate the admin key before using it
const adminKeyId = await getAdminKeyId(); let adminPublicKey: RSAPublicKeyJWK;
let adminKeyId: string;
try {
adminPublicKey = await getAdminPublicKey();
adminKeyId = await getAdminKeyId();
// Validate admin key structure
if (!isValidPublicKeyJwk(adminPublicKey)) {
const adminError = new Error('Unable to secure message. Please check your connection and try again.');
console.error('[E2EE Send] Admin public key is invalid');
setIsEncrypting(false);
setError(adminError);
throw adminError;
}
} catch (adminKeyError) {
// Re-throw if already a user-facing error
if (adminKeyError instanceof Error && adminKeyError.message.includes('Unable to secure')) {
throw adminKeyError;
}
const fetchError = new Error('Unable to secure message. Please check your connection and try again.');
console.error('[E2EE Send] Failed to fetch admin key:', adminKeyError);
setIsEncrypting(false);
setError(fetchError);
throw fetchError;
}
// Get partner's public key from the freshly fetched profile // Get partner's public key from the freshly fetched profile
let partnerPublicKey = freshPartner?.public_key_jwk as let partnerPublicKey = freshPartner?.public_key_jwk as
@@ -124,7 +176,8 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
console.error('[E2EE Send] Failed to encrypt message:', err); console.error('[E2EE Send] Failed to encrypt message:', err);
setError(err as Error); setError(err as Error);
setIsEncrypting(false); setIsEncrypting(false);
return null; // Re-throw to block sending - never fall back to plaintext
throw err;
} }
}, },
[isE2EEAvailable, refreshPartner] [isE2EEAvailable, refreshPartner]
+57 -2
View File
@@ -150,7 +150,7 @@ export async function wrapAESKey(
/** /**
* Unwrap (decrypt) an AES key with an RSA private key * Unwrap (decrypt) an AES key with an RSA private key
* *
* @param wrappedKeyBase64 - The wrapped key as base64 string * @param wrappedKeyBase64 - The wrapped key as base64 string
* @param privateKeyJwk - The user's RSA private key * @param privateKeyJwk - The user's RSA private key
* @returns The unwrapped AES key as ArrayBuffer * @returns The unwrapped AES key as ArrayBuffer
@@ -162,10 +162,65 @@ export async function unwrapAESKey(
const subtle = getSubtle(); const subtle = getSubtle();
const rsaKey = await importPrivateKey(privateKeyJwk); const rsaKey = await importPrivateKey(privateKeyJwk);
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64); const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
return await subtle.decrypt( return await subtle.decrypt(
{ name: KEY_WRAP_ALGORITHM }, { name: KEY_WRAP_ALGORITHM },
rsaKey, rsaKey,
wrappedKey wrappedKey
); );
} }
/**
* Verify that a public/private key pair actually work together.
* This catches key mismatches that could cause unrecoverable encrypted messages.
*
* @param publicKeyJwk - The RSA public key
* @param privateKeyJwk - The RSA private key
* @returns true if the keys work together, false otherwise
*/
export async function verifyKeyPair(
publicKeyJwk: RSAPublicKeyJWK,
privateKeyJwk: RSAPrivateKeyJWK
): Promise<boolean> {
try {
const subtle = getSubtle();
// Create test data (32 bytes - same as AES-256 key)
const testData = getRandomValues(new Uint8Array(32));
// Import keys
const publicKey = await importPublicKey(publicKeyJwk);
const privateKey = await importPrivateKey(privateKeyJwk);
// Encrypt with public key
const encrypted = await subtle.encrypt(
{ name: KEY_WRAP_ALGORITHM },
publicKey,
testData as BufferSource
);
// Decrypt with private key
const decrypted = await subtle.decrypt(
{ name: KEY_WRAP_ALGORITHM },
privateKey,
encrypted
);
// Verify the data matches
const decryptedArray = new Uint8Array(decrypted);
if (decryptedArray.length !== testData.length) {
return false;
}
for (let i = 0; i < testData.length; i++) {
if (testData[i] !== decryptedArray[i]) {
return false;
}
}
return true;
} catch (error) {
console.error('[E2EE] Key pair verification failed:', error);
return false;
}
}
+3
View File
@@ -65,3 +65,6 @@ export { triggerKeyRotation } from './triggerKeyRotation';
// Stale Key Repair // Stale Key Repair
export { repairStaleKey } from './repairStaleKey'; export { repairStaleKey } from './repairStaleKey';
// Crypto Operations
export { verifyKeyPair } from './crypto';