mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Add E2EE key pair verification before send
🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
69f43ffb7d
commit
0ffa7fa952
6 files changed
+148
-29
No files matched your search
@@ -51,11 +51,7 @@
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"targetSdkVersion": 35,
|
||||
"blockedPermissions": [
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO"
|
||||
]
|
||||
"targetSdkVersion": 35
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 46;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -19,7 +19,7 @@
|
||||
/* End PBXBuildFile 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; };
|
||||
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>"; };
|
||||
@@ -390,6 +390,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
@@ -397,7 +398,10 @@
|
||||
);
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
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;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -423,9 +427,13 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
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;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -490,14 +498,14 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
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)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
@@ -546,13 +554,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
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)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
USE_HERMES = true;
|
||||
|
||||
@@ -184,7 +184,11 @@ export const ChatScreen: React.FC = () => {
|
||||
}
|
||||
} catch (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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* consistency with all other encryption operations.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
encryptTextMessage,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getAdminKeyId,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
verifyKeyPair,
|
||||
} from '../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
@@ -42,6 +43,9 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||
const [isEncrypting, setIsEncrypting] = useState(false);
|
||||
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 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
|
||||
const currentKeys = useAuthStore.getState().encryptionKeys;
|
||||
|
||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
|
||||
console.warn('[E2EE Send] No keys available in global store at encryption time');
|
||||
return null;
|
||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk || !currentKeys.privateKeyJwk) {
|
||||
const keysError = new Error('Encryption keys not ready. Please wait a moment and try again.');
|
||||
console.warn('[E2EE Send] Keys not fully available in global store');
|
||||
setError(keysError);
|
||||
throw keysError;
|
||||
}
|
||||
|
||||
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(
|
||||
'[E2EE Send] Using sender public key from global store, version:',
|
||||
@@ -85,8 +112,33 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||
const freshPartner = await refreshPartner();
|
||||
|
||||
// Get admin key for moderation access
|
||||
const adminPublicKey = await getAdminPublicKey();
|
||||
const adminKeyId = await getAdminKeyId();
|
||||
// SAFETY: Validate the admin key before using it
|
||||
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
|
||||
let partnerPublicKey = freshPartner?.public_key_jwk as
|
||||
@@ -124,7 +176,8 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||
console.error('[E2EE Send] Failed to encrypt message:', err);
|
||||
setError(err as Error);
|
||||
setIsEncrypting(false);
|
||||
return null;
|
||||
// Re-throw to block sending - never fall back to plaintext
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[isE2EEAvailable, refreshPartner]
|
||||
|
||||
@@ -150,7 +150,7 @@ export async function wrapAESKey(
|
||||
|
||||
/**
|
||||
* Unwrap (decrypt) an AES key with an RSA private key
|
||||
*
|
||||
*
|
||||
* @param wrappedKeyBase64 - The wrapped key as base64 string
|
||||
* @param privateKeyJwk - The user's RSA private key
|
||||
* @returns The unwrapped AES key as ArrayBuffer
|
||||
@@ -162,10 +162,65 @@ export async function unwrapAESKey(
|
||||
const subtle = getSubtle();
|
||||
const rsaKey = await importPrivateKey(privateKeyJwk);
|
||||
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
|
||||
|
||||
|
||||
return await subtle.decrypt(
|
||||
{ name: KEY_WRAP_ALGORITHM },
|
||||
rsaKey,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -65,3 +65,6 @@ export { triggerKeyRotation } from './triggerKeyRotation';
|
||||
|
||||
// Stale Key Repair
|
||||
export { repairStaleKey } from './repairStaleKey';
|
||||
|
||||
// Crypto Operations
|
||||
export { verifyKeyPair } from './crypto';
|
||||
Reference in new issue
Block a user