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",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"android": {
|
"android": {
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35
|
||||||
"blockedPermissions": [
|
|
||||||
"android.permission.READ_MEDIA_IMAGES",
|
|
||||||
"android.permission.READ_MEDIA_VIDEO"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
Reference in new issue
Block a user