diff --git a/metro.config.js b/metro.config.js
new file mode 100644
index 0000000..37c7faa
--- /dev/null
+++ b/metro.config.js
@@ -0,0 +1,13 @@
+const { getDefaultConfig } = require('expo/metro-config');
+const path = require('path');
+
+const config = getDefaultConfig(__dirname);
+
+config.resolver.extraNodeModules = {
+ ...config.resolver.extraNodeModules,
+ crypto: path.resolve(__dirname, 'src/utils/crypto_polyfill.ts'),
+ stream: require.resolve('readable-stream'), // Just in case
+ vm: require.resolve('vm-browserify'),
+};
+
+module.exports = config;
diff --git a/package-lock.json b/package-lock.json
index 20b06ee..4e72b9e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,8 @@
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
+ "@noble/ciphers": "^1.3.0",
+ "@noble/hashes": "^1.8.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
@@ -19,6 +21,7 @@
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"expo-constants": "~17.0.8",
+ "expo-crypto": "~14.0.2",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.0",
"expo-image-picker": "^17.0.10",
@@ -32,7 +35,9 @@
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-view-shot": "^3.8.0",
- "react-native-web": "~0.19.13"
+ "react-native-web": "~0.19.13",
+ "readable-stream": "^4.7.0",
+ "vm-browserify": "^1.1.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -3211,9 +3216,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-1.3.0.tgz",
+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@noble/hashes": {
"version": "1.8.0",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
@@ -5579,6 +5596,15 @@
"node": ">=6"
}
},
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
"node_modules/exec-async": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
@@ -5756,6 +5782,18 @@
"react-native": "*"
}
},
+ "node_modules/expo-crypto": {
+ "version": "14.0.2",
+ "resolved": "https://registry.npmmirror.com/expo-crypto/-/expo-crypto-14.0.2.tgz",
+ "integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0"
+ },
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-font": {
"version": "13.0.4",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.0.4.tgz",
@@ -9063,6 +9101,15 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -9534,6 +9581,22 @@
"node": ">=0.10.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
"node_modules/readline": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz",
@@ -10234,6 +10297,15 @@
"node": ">=4"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -11011,6 +11083,12 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT"
},
+ "node_modules/vm-browserify": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz",
+ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
+ "license": "MIT"
+ },
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
diff --git a/package.json b/package.json
index d6cdd73..59217e6 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,8 @@
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
+ "@noble/ciphers": "^1.3.0",
+ "@noble/hashes": "^1.8.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
@@ -20,6 +22,7 @@
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"expo-constants": "~17.0.8",
+ "expo-crypto": "~14.0.2",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.0",
"expo-image-picker": "^17.0.10",
@@ -29,11 +32,13 @@
"react-dom": "18.3.1",
"react-native": "^0.76.9",
"react-native-gesture-handler": "~2.20.2",
- "react-native-view-shot": "^3.8.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
- "react-native-web": "~0.19.13"
+ "react-native-view-shot": "^3.8.0",
+ "react-native-web": "~0.19.13",
+ "readable-stream": "^4.7.0",
+ "vm-browserify": "^1.1.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
diff --git a/src/components/common/BiometricModal.tsx b/src/components/common/BiometricModal.tsx
index 510b50e..a62f30a 100644
--- a/src/components/common/BiometricModal.tsx
+++ b/src/components/common/BiometricModal.tsx
@@ -36,7 +36,7 @@ export default function BiometricModal({
if (visible) {
setIsScanning(false);
scanAnimation.setValue(0);
-
+
// Pulse animation
Animated.loop(
Animated.sequence([
@@ -57,32 +57,30 @@ export default function BiometricModal({
const handleScan = () => {
setIsScanning(true);
-
+
Animated.loop(
Animated.sequence([
Animated.timing(scanAnimation, {
toValue: 1,
- duration: 800,
+ duration: 400,
useNativeDriver: true,
}),
Animated.timing(scanAnimation, {
toValue: 0,
- duration: 800,
+ duration: 400,
useNativeDriver: true,
}),
]),
- { iterations: 2 }
+ { iterations: 1 }
).start(() => {
- setTimeout(() => {
- onSuccess();
- }, 300);
+ onSuccess();
});
};
const backgroundColor = isDark ? colors.vault.cardBackground : colors.white;
const textColor = isDark ? colors.vault.text : colors.nautical.navy;
const accentColor = isDark ? colors.vault.primary : colors.nautical.teal;
- const accentGradient: [string, string] = isDark
+ const accentGradient: [string, string] = isDark
? [colors.vault.primary, colors.vault.secondary]
: [colors.nautical.teal, colors.nautical.seafoam];
@@ -97,10 +95,10 @@ export default function BiometricModal({
{/* Ship wheel watermark */}
-
@@ -109,7 +107,7 @@ export default function BiometricModal({
{message}
-
+
-
diff --git a/src/config/index.ts b/src/config/index.ts
index cd88aff..9bec176 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -91,12 +91,18 @@ export function getVaultStorageKeys(userId: number | string | null): {
INITIALIZED: string;
SHARE_DEVICE: string;
MNEMONIC_PART_LOCAL: string;
+ AES_KEY: string;
+ SHARE_SERVER: string;
+ SHARE_HEIR: string;
} {
const suffix = userId != null ? `_u${userId}` : '_guest';
return {
INITIALIZED: `${VAULT_KEY_PREFIX}_initialized${suffix}`,
SHARE_DEVICE: `${VAULT_KEY_PREFIX}_s0${suffix}`,
MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local${suffix}`,
+ AES_KEY: `sentinel_aes_key${suffix}`,
+ SHARE_SERVER: `sentinel_share_server${suffix}`,
+ SHARE_HEIR: `sentinel_share_heir${suffix}`,
};
}
diff --git a/src/hooks/useVaultAssets.ts b/src/hooks/useVaultAssets.ts
index ea51fca..ffaffeb 100644
--- a/src/hooks/useVaultAssets.ts
+++ b/src/hooks/useVaultAssets.ts
@@ -6,9 +6,11 @@
import { useState, useEffect, useCallback } from 'react';
import * as bip39 from 'bip39';
+import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '../context/AuthContext';
import { assetsService } from '../services/assets.service';
-import { createAssetPayload } from '../services/vault.service';
+import { getVaultStorageKeys, DEBUG_MODE } from '../config';
+import { SentinelVault } from '../utils/crypto_core';
import {
initialVaultAssets,
mapApiAssetsToVaultAssets,
@@ -51,7 +53,7 @@ export interface UseVaultAssetsReturn {
* Vault assets list + create. Fetches on unlock when token exists; keeps mock on error.
*/
export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
- const { token, signOut } = useAuth();
+ const { user, token, signOut } = useAuth();
const [assets, setAssets] = useState(initialVaultAssets);
const [isSealing, setIsSealing] = useState(false);
const [createError, setCreateError] = useState(null);
@@ -101,19 +103,37 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setIsSealing(true);
setCreateError(null);
try {
- const wordList = bip39.wordlists.english;
- const payload = await createAssetPayload(
- title.trim(),
- content.trim(),
- wordList,
- 'note',
- 0
- );
+ const vaultKeys = getVaultStorageKeys(user?.id ?? null);
+ const [s1Str, aesKeyHex, s0Str, s2Str] = await Promise.all([
+ AsyncStorage.getItem(vaultKeys.SHARE_SERVER),
+ AsyncStorage.getItem(vaultKeys.AES_KEY),
+ AsyncStorage.getItem(vaultKeys.SHARE_DEVICE),
+ AsyncStorage.getItem(vaultKeys.SHARE_HEIR),
+ ]);
+
+ if (!s1Str || !aesKeyHex) {
+ throw new Error('Vault keys missing. Please re-unlock your vault.');
+ }
+
+ const vault = new SentinelVault();
+ const aesKey = Buffer.from(aesKeyHex, 'hex');
+ const encryptedBuffer = vault.encryptData(aesKey, content.trim());
+ const content_inner_encrypted = encryptedBuffer.toString('hex');
+
+ if (DEBUG_MODE) {
+ console.log('[DEBUG] Crypto Data during Asset Creation:');
+ console.log(' s0 (Device):', s0Str);
+ console.log(' s1 (Server):', s1Str);
+ console.log(' s2 (Heir): ', s2Str);
+ console.log(' AES Key: ', aesKeyHex);
+ console.log(' Encrypted: ', content_inner_encrypted);
+ }
+
await assetsService.createAsset(
{
- title: payload.title,
- private_key_shard: payload.private_key_shard,
- content_inner_encrypted: payload.content_inner_encrypted,
+ title: title.trim(),
+ private_key_shard: s1Str,
+ content_inner_encrypted,
},
token
);
@@ -143,7 +163,7 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setIsSealing(false);
}
},
- [token, refreshAssets, signOut]
+ [token, user, refreshAssets, signOut]
);
const clearCreateError = useCallback(() => setCreateError(null), []);
diff --git a/src/screens/FlowScreen.tsx b/src/screens/FlowScreen.tsx
index 2dddebf..a45214f 100644
--- a/src/screens/FlowScreen.tsx
+++ b/src/screens/FlowScreen.tsx
@@ -29,10 +29,14 @@ import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
-import { aiService } from '../services/ai.service';
+import { aiService, AIMessage } from '../services/ai.service';
+import { assetsService } from '../services/assets.service';
import { useAuth } from '../context/AuthContext';
-import { AI_CONFIG } from '../config';
+import { AI_CONFIG, getVaultStorageKeys } from '../config';
import { storageService } from '../services/storage.service';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { SentinelVault } from '../utils/crypto_core';
+import { Buffer } from 'buffer';
// =============================================================================
// Type Definitions
@@ -78,6 +82,18 @@ export default function FlowScreen() {
const [showHistoryModal, setShowHistoryModal] = useState(false);
const modalSlideAnim = useRef(new Animated.Value(0)).current;
+ // Summary state
+ const [showSummaryConfirmModal, setShowSummaryConfirmModal] = useState(false);
+ const [showSummaryResultModal, setShowSummaryResultModal] = useState(false);
+ const [isSummarizing, setIsSummarizing] = useState(false);
+ const [generatedSummary, setGeneratedSummary] = useState('');
+
+ // Save to Vault state
+ const [showVaultConfirmModal, setShowVaultConfirmModal] = useState(false);
+ const [showSaveResultModal, setShowSaveResultModal] = useState(false);
+ const [saveResult, setSaveResult] = useState<{ success: boolean; message: string }>({ success: true, message: '' });
+ const [isSavingToVault, setIsSavingToVault] = useState(false);
+
const [chatHistory, setChatHistory] = useState([
// Sample history data
{
@@ -453,6 +469,107 @@ export default function FlowScreen() {
);
};
+ /**
+ * Handle generating summary for current conversation
+ */
+ const handleGenerateSummary = async () => {
+ if (messages.length === 0) {
+ Alert.alert('No Messages', 'There are no messages to summarize.');
+ return;
+ }
+
+ if (!token) {
+ Alert.alert('Login Required', 'Please login to generate a summary.');
+ return;
+ }
+
+ setShowSummaryConfirmModal(false);
+ setIsSummarizing(true);
+
+ try {
+ // Convert messages to AIMessage format
+ const aiMessages: AIMessage[] = messages.map(msg => ({
+ role: msg.role,
+ content: msg.content,
+ }));
+
+ const summary = await aiService.summarizeChat(aiMessages, token);
+ setGeneratedSummary(summary);
+ setShowSummaryResultModal(true);
+ } catch (error) {
+ console.error('Failed to generate summary:', error);
+ Alert.alert('Error', 'Failed to generate summary. Please try again later.');
+ } finally {
+ setIsSummarizing(false);
+ }
+ };
+
+ /**
+ * Handle saving the generated summary to the vault
+ */
+ const handleSaveToVault = async () => {
+ if (!generatedSummary || isSavingToVault) return;
+
+ if (!token) {
+ Alert.alert('Login Required', 'Please login to save to vault.');
+ return;
+ }
+
+ setShowVaultConfirmModal(false);
+ setIsSavingToVault(true);
+
+ try {
+ // Retrieve vault keys
+ if (!user) {
+ Alert.alert('Error', 'User information not found. Please login again.');
+ return;
+ }
+ const vaultKeys = getVaultStorageKeys(user.id);
+ const shareServer = await AsyncStorage.getItem(vaultKeys.SHARE_SERVER);
+ const aesKeyHex = await AsyncStorage.getItem(vaultKeys.AES_KEY);
+
+ if (!shareServer || !aesKeyHex) {
+ Alert.alert(
+ 'Vault Not Initialized',
+ 'Your vault is not fully initialized. Please set it up in the Vault tab first.'
+ );
+ return;
+ }
+
+ // Encrypt summary with AES key
+ const vault = new SentinelVault();
+ const aesKey = Buffer.from(aesKeyHex, 'hex');
+ const encryptedSummary = vault.encryptData(aesKey, generatedSummary).toString('hex');
+
+ // Create asset in backend
+ await assetsService.createAsset({
+ title: `Chat Summary - ${new Date().toLocaleDateString()}`,
+ private_key_shard: shareServer,
+ content_inner_encrypted: encryptedSummary,
+ }, token);
+
+ setSaveResult({ success: true, message: 'Summary encrypted and saved to your vault successfully.' });
+ setShowSaveResultModal(true);
+ } catch (error) {
+ console.error('Failed to save to vault:', error);
+ setSaveResult({ success: false, message: 'Failed to save summary to vault. Please try again.' });
+ setShowSaveResultModal(true);
+ } finally {
+ setIsSavingToVault(false);
+ }
+ };
+
+ /**
+ * Handle closing all summary related modals after successful save or manual close of result
+ */
+ const handleFinishSaveFlow = () => {
+ setShowSaveResultModal(false);
+ if (saveResult.success) {
+ setShowSummaryResultModal(false);
+ setShowVaultConfirmModal(false);
+ }
+ };
+
// =============================================================================
// Helper Functions
// =============================================================================
@@ -596,6 +713,19 @@ export default function FlowScreen() {
+ {/* Summary Button */}
+ setShowSummaryConfirmModal(true)}
+ disabled={messages.length === 0 || isSummarizing}
+ >
+
+
+
{/* History Button */}
+
+ {/* Summary Confirmation Modal */}
+ setShowSummaryConfirmModal(false)}
+ >
+ setShowSummaryConfirmModal(false)}>
+
+ e.stopPropagation()}>
+
+
+ Generate Summary
+
+ Would you like to generate a summary for the current conversation?
+
+
+
+ setShowSummaryConfirmModal(false)}
+ >
+ No
+
+
+
+ Yes, Generate
+
+
+
+
+
+
+
+
+
+ {/* Summary Result Modal */}
+ setShowSummaryResultModal(false)}
+ >
+ setShowSummaryResultModal(false)}>
+
+ e.stopPropagation()}>
+
+
+
+ Conversation Summary
+ setShowSummaryResultModal(false)}>
+
+
+
+
+
+
+ {generatedSummary}
+
+
+
+
+ setShowVaultConfirmModal(true)}
+ disabled={isSavingToVault}
+ >
+
+ {isSavingToVault ? (
+
+ ) : (
+ <>
+
+ Save to Vault
+ >
+ )}
+
+
+
+ setShowSummaryResultModal(false)}
+ >
+ Done
+
+
+
+
+
+
+
+
+ {/* Save to Vault Confirmation Modal */}
+ setShowVaultConfirmModal(false)}
+ >
+ setShowVaultConfirmModal(false)}>
+
+ e.stopPropagation()}>
+
+
+ Save to Vault
+
+ Would you like to securely save this summary to your digital vault?
+
+
+
+ setShowVaultConfirmModal(false)}
+ >
+ Cancel
+
+
+
+ Yes, Save
+
+
+
+
+
+
+
+
+
+ {/* Save Result Modal */}
+
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+ {saveResult.success ? 'Success!' : 'Oops!'}
+
+
+
+ {saveResult.message}
+
+
+
+
+ Confirm
+
+
+
+
+
+
+
+
+ {/* Summary Loading Modal */}
+
+
+
+
+ Generating Summary...
+
+
+
);
}
@@ -1281,4 +1617,101 @@ const styles = StyleSheet.create({
color: colors.flow.textSecondary,
fontWeight: '600',
},
+
+ // Summary Modal styles
+ modalSubtitle: {
+ fontSize: typography.fontSize.base,
+ color: colors.flow.textSecondary,
+ lineHeight: 22,
+ },
+ modalActions: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ marginTop: spacing.base,
+ },
+ actionButton: {
+ flex: 1,
+ height: 50,
+ borderRadius: borderRadius.lg,
+ justifyContent: 'center',
+ alignItems: 'center',
+ overflow: 'hidden',
+ },
+ actionButtonGradient: {
+ width: '100%',
+ height: '100%',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ cancelButton: {
+ backgroundColor: colors.nautical.paleAqua,
+ },
+ confirmButton: {
+ // Gradient handled in child
+ },
+ cancelButtonText: {
+ fontSize: typography.fontSize.base,
+ fontWeight: '600',
+ color: colors.flow.textSecondary,
+ },
+ confirmButtonText: {
+ fontSize: typography.fontSize.base,
+ fontWeight: '600',
+ color: '#fff',
+ },
+ summaryContainer: {
+ marginVertical: spacing.md,
+ },
+ summaryCard: {
+ backgroundColor: colors.nautical.paleAqua + '40', // 25% opacity
+ padding: spacing.md,
+ borderRadius: borderRadius.lg,
+ borderWidth: 1,
+ borderColor: colors.nautical.lightMint,
+ },
+ summaryText: {
+ fontSize: typography.fontSize.base,
+ color: colors.flow.text,
+ lineHeight: 24,
+ },
+ summaryActions: {
+ marginTop: spacing.md,
+ gap: spacing.sm,
+ },
+ saveToVaultButton: {
+ height: 54,
+ },
+ resultIconContainer: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: spacing.md,
+ },
+ successIconBg: {
+ backgroundColor: colors.nautical.paleAqua,
+ },
+ errorIconBg: {
+ backgroundColor: 'rgba(231, 76, 60, 0.1)', // coral at 10%
+ },
+ loadingOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(26, 58, 74, 0.6)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ loadingContainer: {
+ backgroundColor: colors.flow.cardBackground,
+ padding: spacing.xl,
+ borderRadius: borderRadius.xl,
+ alignItems: 'center',
+ ...shadows.soft,
+ gap: spacing.md,
+ },
+ loadingText: {
+ fontSize: typography.fontSize.base,
+ color: colors.flow.text,
+ fontWeight: '600',
+ },
});
diff --git a/src/screens/VaultScreen.tsx b/src/screens/VaultScreen.tsx
index 56d59c4..339619a 100644
--- a/src/screens/VaultScreen.tsx
+++ b/src/screens/VaultScreen.tsx
@@ -27,6 +27,7 @@ import { useAuth } from '../context/AuthContext';
import { useVaultAssets } from '../hooks/useVaultAssets';
import { getVaultStorageKeys } from '../config';
import { mnemonicToEntropy, splitSecret, serializeShare } from '../utils/sss';
+import { SentinelVault } from '@/utils/crypto_core';
// Asset type configuration with nautical theme
const assetTypeConfig: Record = {
@@ -210,7 +211,7 @@ export default function VaultScreen() {
return;
}
if (isUnlocked) return;
- const timer = setTimeout(() => setShowBiometric(true), 500);
+ const timer = setTimeout(() => setShowBiometric(true), 100);
return () => clearTimeout(timer);
}, [isUnlocked, hasS0]);
@@ -218,7 +219,7 @@ export default function VaultScreen() {
if (isUnlocked) {
Animated.timing(fadeAnim, {
toValue: 1,
- duration: 600,
+ duration: 200,
useNativeDriver: true,
}).start();
}
@@ -230,12 +231,12 @@ export default function VaultScreen() {
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
- duration: 1500,
+ duration: 500,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
- duration: 1500,
+ duration: 500,
useNativeDriver: true,
}),
])
@@ -380,9 +381,19 @@ export default function VaultScreen() {
const entropy = mnemonicToEntropy(mnemonicWords, wordList);
const shares = splitSecret(entropy);
const s0 = shares[0]; // device share (S0)
+ const s1 = shares[1]; // server share (S1)
+ const s2 = shares[2]; // heir share (S2)
// S0 is stored in AsyncStorage under user-scoped key — app-level storage, not hardware TEE/SE
+ const vault = new SentinelVault()
+
+ const aes_key = await vault.deriveKey(mnemonicWords.join(' '))
+
await AsyncStorage.setItem(vaultKeys.SHARE_DEVICE, serializeShare(s0));
+ await AsyncStorage.setItem(vaultKeys.SHARE_SERVER, serializeShare(s1));
await AsyncStorage.setItem(vaultKeys.INITIALIZED, '1');
+ await AsyncStorage.setItem(vaultKeys.AES_KEY, aes_key.toString('hex'));
+ await AsyncStorage.setItem(vaultKeys.SHARE_HEIR, serializeShare(s2));
+
setHasS0(true);
setShowMnemonic(false);
setShowBiometric(true);
@@ -576,327 +587,327 @@ export default function VaultScreen() {
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
-
- setShowMnemonic(false)}
- activeOpacity={0.85}
+
-
-
-
-
- Mnemonic Setup
-
- {mnemonicStep === 1 ? (
- <>
-
- Review your 12-word mnemonic. Tap any word to replace it.
-
-
- {mnemonicWords.map((word, index) => (
- setReplaceIndex(index)}
- activeOpacity={0.8}
- >
- setShowMnemonic(false)}
+ activeOpacity={0.85}
+ >
+
+
+
+
+ Mnemonic Setup
+
+ {mnemonicStep === 1 ? (
+ <>
+
+ Review your 12-word mnemonic. Tap any word to replace it.
+
+
+ {mnemonicWords.map((word, index) => (
+ setReplaceIndex(index)}
+ activeOpacity={0.8}
>
- {index + 1}
+
+ {index + 1}
+
+
+ {word}
+
+
+ ))}
+
+ {replaceIndex !== null ? (
+
+
+ Replace word {replaceIndex + 1}
-
- {word}
-
-
- ))}
-
- {replaceIndex !== null ? (
-
-
- Replace word {replaceIndex + 1}
-
-
-
- {(replaceQuery
- ? bip39.wordlists.english.filter((word) =>
+
+
+ {(replaceQuery
+ ? bip39.wordlists.english.filter((word) =>
word.startsWith(replaceQuery.toLowerCase())
)
- : bip39.wordlists.english
- )
- .slice(0, 24)
- .map((word) => (
- handleReplaceWord(word)}
- activeOpacity={0.8}
- >
- {word}
-
- ))}
-
- setReplaceIndex(null)}
- activeOpacity={0.85}
- >
- CANCEL
-
-
- ) : null}
- setMnemonicStep(2)}
- activeOpacity={0.85}
- >
- NEXT
-
- >
- ) : null}
+ : bip39.wordlists.english
+ )
+ .slice(0, 24)
+ .map((word) => (
+ handleReplaceWord(word)}
+ activeOpacity={0.8}
+ >
+ {word}
+
+ ))}
+
+ setReplaceIndex(null)}
+ activeOpacity={0.85}
+ >
+ CANCEL
+
+
+ ) : null}
+ setMnemonicStep(2)}
+ activeOpacity={0.85}
+ >
+ NEXT
+
+ >
+ ) : null}
- {mnemonicStep === 2 ? (
- <>
-
- Confirm your 12-word mnemonic.
-
-
-
- {mnemonicWords.join(' ')}
+ {mnemonicStep === 2 ? (
+ <>
+
+ Confirm your 12-word mnemonic.
-
- setMnemonicStep(3)}
- activeOpacity={0.85}
- >
- CONFIRM
-
- setMnemonicStep(1)}
- activeOpacity={0.85}
- >
- EDIT SELECTION
-
- >
- ) : null}
-
- {mnemonicStep === 3 ? (
- <>
-
- Back up your mnemonic before entering the Vault.
-
-
-
- {mnemonicWords.join(' ')}
-
-
-
-
- {isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'}
-
-
-
- EMAIL BACKUP
-
- >
- ) : null}
-
- {mnemonicStep === 4 ? (
- <>
-
- Finalizing your vault protection.
-
-
-
-
-
-
-
-
- {progressIndex === 0 && '1. Your key is being processed'}
- {progressIndex === 1 && '2. Your key has been split'}
- {progressIndex === 2 && '3. Part one stored on this device'}
- {progressIndex === 3 && '4. Part two uploaded to the cloud'}
- {progressIndex >= 4 && '5. Part three inquiry initiated'}
-
-
- >
- ) : null}
-
- {mnemonicStep === 5 ? (
- <>
- {heirStep === 'decision' ? (
- <>
-
- Share Part Three with your legacy handler?
+
+
+ {mnemonicWords.join(' ')}
-
- YES, SEND
-
-
- NOT NOW
-
- >
- ) : null}
+
+ setMnemonicStep(3)}
+ activeOpacity={0.85}
+ >
+ CONFIRM
+
+ setMnemonicStep(1)}
+ activeOpacity={0.85}
+ >
+ EDIT SELECTION
+
+ >
+ ) : null}
- {heirStep === 'asset' ? (
- <>
-
- Select the vault item to assign.
+ {mnemonicStep === 3 ? (
+ <>
+
+ Back up your mnemonic before entering the Vault.
+
+
+
+ {mnemonicWords.join(' ')}
-
- {assets.map((asset) => {
- const config = assetTypeConfig[asset.type];
- return (
+
+
+
+ {isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'}
+
+
+
+ EMAIL BACKUP
+
+ >
+ ) : null}
+
+ {mnemonicStep === 4 ? (
+ <>
+
+ Finalizing your vault protection.
+
+
+
+
+
+
+
+
+ {progressIndex === 0 && '1. Your key is being processed'}
+ {progressIndex === 1 && '2. Your key has been split'}
+ {progressIndex === 2 && '3. Part one stored on this device'}
+ {progressIndex === 3 && '4. Part two uploaded to the cloud'}
+ {progressIndex >= 4 && '5. Part three inquiry initiated'}
+
+
+ >
+ ) : null}
+
+ {mnemonicStep === 5 ? (
+ <>
+ {heirStep === 'decision' ? (
+ <>
+
+ Share Part Three with your legacy handler?
+
+
+ YES, SEND
+
+
+ NOT NOW
+
+ >
+ ) : null}
+
+ {heirStep === 'asset' ? (
+ <>
+
+ Select the vault item to assign.
+
+
+ {assets.map((asset) => {
+ const config = assetTypeConfig[asset.type];
+ return (
+ handleSelectHeirAsset(asset)}
+ activeOpacity={0.8}
+ >
+
+ {renderAssetTypeIcon(config, 18, colors.vault.primary)}
+
+
+ {asset.label}
+ {config.label}
+
+
+ );
+ })}
+
+ setHeirStep('decision')}
+ activeOpacity={0.85}
+ >
+ BACK
+
+ >
+ ) : null}
+
+ {heirStep === 'heir' ? (
+ <>
+
+ Choose a legacy handler.
+
+
+ {initialHeirs.map((heir) => (
handleSelectHeirAsset(asset)}
+ onPress={() => handleSelectHeir(heir)}
activeOpacity={0.8}
>
- {renderAssetTypeIcon(config, 18, colors.vault.primary)}
+
- {asset.label}
- {config.label}
+ {heir.name}
+ {heir.email}
- );
- })}
-
- setHeirStep('decision')}
- activeOpacity={0.85}
- >
- BACK
-
- >
- ) : null}
+ ))}
+
+ setHeirStep('asset')}
+ activeOpacity={0.85}
+ >
+ BACK
+
+ >
+ ) : null}
- {heirStep === 'heir' ? (
- <>
-
- Choose a legacy handler.
-
-
- {initialHeirs.map((heir) => (
- handleSelectHeir(heir)}
- activeOpacity={0.8}
- >
-
-
-
-
- {heir.name}
- {heir.email}
-
-
- ))}
-
- setHeirStep('asset')}
- activeOpacity={0.85}
- >
- BACK
-
- >
- ) : null}
-
- {heirStep === 'summary' ? (
- <>
-
- Confirm assignment details.
-
-
- Vault Item
- {selectedHeirAsset?.label}
- Legacy Handler
- {selectedHeir?.name}
- {selectedHeir?.email}
- Release Tier
- Tier {selectedHeir?.releaseLevel}
-
-
- SUBMIT
-
- setHeirStep('heir')}
- activeOpacity={0.85}
- >
- EDIT
-
- >
- ) : null}
- >
- ) : null}
-
-
-
-
-
+ {heirStep === 'summary' ? (
+ <>
+
+ Confirm assignment details.
+
+
+ Vault Item
+ {selectedHeirAsset?.label}
+ Legacy Handler
+ {selectedHeir?.name}
+ {selectedHeir?.email}
+ Release Tier
+ Tier {selectedHeir?.releaseLevel}
+
+
+ SUBMIT
+
+ setHeirStep('heir')}
+ activeOpacity={0.85}
+ >
+ EDIT
+
+ >
+ ) : null}
+ >
+ ) : null}
+
+
+
+
+
@@ -904,60 +915,60 @@ export default function VaultScreen() {
// Lock screen
const lockScreen = (
-
-
-
-
-
-
-
-
-
-
- THE DEEP VAULT
- Where treasures rest in silence
-
-
-
-
-
- setShowBiometric(true) : handleUnlock}
- activeOpacity={0.8}
+
+
+
+
+
+
-
-
-
- {hasS0 === true ? "Captain's Verification" : hasS0 === false ? 'Enter Vault' : 'Loading…'}
-
-
-
-
-
-
+
+
+
- setShowBiometric(false)}
- title="Enter the Vault"
- message="Verify your identity to access your treasures"
- isDark
- />
-
- );
+ THE DEEP VAULT
+ Where treasures rest in silence
+
+
+
+
+
+ setShowBiometric(true) : handleUnlock}
+ activeOpacity={0.8}
+ >
+
+
+
+ {hasS0 === true ? "Captain's Verification" : hasS0 === false ? 'Enter Vault' : 'Loading…'}
+
+
+
+
+
+
+
+ setShowBiometric(false)}
+ title="Enter the Vault"
+ message="Verify your identity to access your treasures"
+ isDark
+ />
+
+ );
const vaultScreen = (
@@ -1003,7 +1014,7 @@ export default function VaultScreen() {
)}
{/* Asset List */}
-
TREASURE TYPE
- {
+ if (NO_BACKEND_MODE) {
+ logApiDebug('AI Summary', 'Using mock mode');
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve('This is a mock summary of your conversation. You discussed various topics including AI integration and UI design. The main conclusion was to proceed with the proposed implementation plan.');
+ }, AI_CONFIG.MOCK_RESPONSE_DELAY);
+ });
+ }
+
+ const historicalMessages = messages.map(msg => ({
+ role: msg.role,
+ content: msg.content,
+ }));
+
+ const summaryPrompt: AIMessage = {
+ role: 'user',
+ content: 'Please provide a concise summary of the conversation above in Chinese (since the user request was in Chinese). Focus on the main topics discussed and any key conclusions or actions mentioned.',
+ };
+
+ const response = await this.chat([...historicalMessages, summaryPrompt], token);
+ return response.choices[0]?.message?.content || 'No summary generated';
+ },
};
diff --git a/src/utils/crypto_core.ts b/src/utils/crypto_core.ts
new file mode 100644
index 0000000..9a47b9c
--- /dev/null
+++ b/src/utils/crypto_core.ts
@@ -0,0 +1,202 @@
+import * as bip39 from 'bip39';
+import * as crypto from 'crypto';
+
+// 定义分片类型:[x坐标, y坐标]
+export type Share = [bigint, bigint];
+
+// 定义生成密钥的返回接口
+export interface VaultKeys {
+ mnemonic: string;
+ entropyHex: string;
+}
+
+export class SentinelKeyEngine {
+ // 使用第 13 个梅森素数 (2^521 - 1)
+ // readonly 确保不会被修改
+ private readonly PRIME: bigint = 2n ** 521n - 1n;
+
+ /**
+ * 1. 生成原始 12 助记词 (Master Key)
+ */
+ public generateVaultKeys(): VaultKeys {
+ // 生成 128 位强度的助记词 (12 个单词)
+ const mnemonic = bip39.generateMnemonic(128);
+
+ // 将助记词转为 16 进制熵 (Hex String)
+ const entropyHex = bip39.mnemonicToEntropy(mnemonic);
+
+ return { mnemonic, entropyHex };
+ }
+
+ public mnemonicToEntropy(mnemonic: string): string {
+ return bip39.mnemonicToEntropy(mnemonic);
+ }
+
+ /**
+ * 2. SSS (3,2) 门限分片逻辑
+ * @param entropyHex - 16进制字符串 (32字符)
+ */
+ public splitToShares(entropyHex: string): Share[] {
+ // 将 Hex 熵转换为 BigInt
+ const secretInt = BigInt('0x' + entropyHex);
+
+ // 生成随机系数 a,范围 [0, PRIME-1]
+ const a = this.secureRandomBigInt(this.PRIME);
+
+ // 定义函数 f(x) = (S + a * x) % PRIME
+ const f = (x: number): bigint => {
+ const xBi = BigInt(x);
+ return (secretInt + a * xBi) % this.PRIME;
+ };
+
+ // 生成 3 个分片: x=1, x=2, x=3
+ const share1: Share = [1n, f(1)]; // 手机分片
+ const share2: Share = [2n, f(2)]; // 云端分片
+ const share3: Share = [3n, f(3)]; // 传承卡分片
+
+ return [share1, share2, share3];
+ }
+
+ /**
+ * 3. 恢复逻辑:拉格朗日插值还原
+ * @param shareA - 第一个分片
+ * @param shareB - 第二个分片
+ */
+ public recoverFromShares(shareA: Share, shareB: Share): string {
+ const [x1, y1] = shareA;
+ const [x2, y2] = shareB;
+
+ // 计算分子: (x2 * y1 - x1 * y2) % PRIME
+ // TS/JS 的 % 运算符对负数返回负数,需修正为正余数
+ let numerator = (x2 * y1 - x1 * y2) % this.PRIME;
+ if (numerator < 0n) numerator += this.PRIME;
+
+ // 计算分母: (x2 - x1)
+ let denominator = (x2 - x1) % this.PRIME;
+ if (denominator < 0n) denominator += this.PRIME;
+
+ // 计算分母的模逆: denominator^-1 mod PRIME
+ // 费马小定理: a^(p-2) = a^-1 (mod p)
+ const invDenominator = this.modPow(denominator, this.PRIME - 2n, this.PRIME);
+
+ // 还原常数项 S
+ const secretInt = (numerator * invDenominator) % this.PRIME;
+
+ // 转回 Hex 字符串
+ let recoveredEntropyHex = secretInt.toString(16);
+
+ // 补齐前导零 (Pad Start)
+ // 128 bit 熵 = 16 字节 = 32 个 Hex 字符
+ // 如果你的熵是 256 bit,这里需要改为 64
+ recoveredEntropyHex = recoveredEntropyHex.padStart(32, '0');
+
+ return bip39.entropyToMnemonic(recoveredEntropyHex);
+ }
+
+ // --- Private Helper Methods ---
+
+ /**
+ * 生成小于 limit 的安全随机 BigInt
+ */
+ private secureRandomBigInt(limit: bigint): bigint {
+ // 计算需要的字节数
+ const bitLength = limit.toString(2).length;
+ const byteLength = Math.ceil(bitLength / 8);
+
+ let randomBi: bigint;
+ do {
+ const buf = crypto.randomBytes(byteLength);
+ randomBi = BigInt('0x' + buf.toString('hex'));
+ // 拒绝采样:确保结果小于 limit
+ } while (randomBi >= limit);
+
+ return randomBi;
+ }
+
+ /**
+ * 模幂运算: (base^exp) % modulus
+ * 用于计算模逆
+ */
+ private modPow(base: bigint, exp: bigint, modulus: bigint): bigint {
+ let result = 1n;
+ base = base % modulus;
+ while (exp > 0n) {
+ if (exp % 2n === 1n) result = (result * base) % modulus;
+ exp = exp >> 1n; // 相当于除以 2
+ base = (base * base) % modulus;
+ }
+ return result;
+ }
+}
+
+export class SentinelVault {
+ private salt: Buffer;
+
+ constructor(salt?: string | Buffer) {
+ // 默认盐值与 Python 版本保持一致
+ this.salt = salt ? Buffer.from(salt) : Buffer.from('Sentinel_Salt_2026');
+ }
+
+ /**
+ * 使用 PBKDF2 将助记词转换为 AES-256 密钥 (32 bytes)
+ */
+ public async deriveKey(mnemonicPhrase: string): Promise {
+ // 1. BIP-39 助记词转种子 (遵循 BIP-39 标准)
+ // Python 的 to_seed 默认返回 64 字节种子
+ const seed = await bip39.mnemonicToSeed(mnemonicPhrase);
+
+ // 2. PBKDF2 派生密钥
+ // 注意:PyCryptodome 的 PBKDF2 默认使用 HMAC-SHA1 (如未指定)
+ // 为了确保与 Python 逻辑严格一致,这里使用 'sha1'
+ return new Promise((resolve, reject) => {
+ crypto.pbkdf2(seed, this.salt, 100000, 32, 'sha1', (err, derivedKey) => {
+ if (err) reject(err);
+ resolve(derivedKey);
+ });
+ });
+ }
+
+ /**
+ * 使用 AES-256-GCM 模式进行加密
+ */
+ public encryptData(key: Buffer, plaintext: string): Buffer {
+ // GCM 模式推荐 nonce 长度,Python 默认通常为 16 字节
+ const iv = crypto.randomBytes(16);
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+
+ const ciphertext = Buffer.concat([
+ cipher.update(plaintext, 'utf8'),
+ cipher.final()
+ ]);
+
+ // 获取 GCM 认证标签 (16 bytes)
+ const tag = cipher.getAuthTag();
+
+ // 拼接结果:Nonce + Tag + Ciphertext
+ return Buffer.concat([iv, tag, ciphertext]);
+ }
+
+ /**
+ * AES-256-GCM 解密
+ */
+ public decryptData(key: Buffer, encryptedBlob: Buffer): string {
+ try {
+ // 切片提取组件
+ const iv = encryptedBlob.subarray(0, 16);
+ const tag = encryptedBlob.subarray(16, 32);
+ const ciphertext = encryptedBlob.subarray(32);
+
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
+ decipher.setAuthTag(tag);
+
+ const decrypted = Buffer.concat([
+ decipher.update(ciphertext),
+ decipher.final()
+ ]);
+
+ return decrypted.toString('utf8');
+ } catch (error) {
+ return "【解密失败】:密钥错误或数据被篡改";
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/utils/crypto_polyfill.ts b/src/utils/crypto_polyfill.ts
new file mode 100644
index 0000000..816b749
--- /dev/null
+++ b/src/utils/crypto_polyfill.ts
@@ -0,0 +1,135 @@
+import * as ExpoCrypto from 'expo-crypto';
+import { Buffer } from 'buffer';
+import { pbkdf2 as noblePbkdf2 } from '@noble/hashes/pbkdf2';
+import { sha1 } from '@noble/hashes/sha1';
+import { sha256 } from '@noble/hashes/sha256';
+import { sha512 } from '@noble/hashes/sha512';
+import { gcm } from '@noble/ciphers/aes';
+
+/**
+ * Node.js Crypto Polyfill for React Native
+ */
+
+export function randomBytes(size: number): Buffer {
+ const bytes = new Uint8Array(size);
+ ExpoCrypto.getRandomValues(bytes);
+ return Buffer.from(bytes);
+}
+
+const hashMap: Record = {
+ sha1,
+ sha256,
+ sha512,
+};
+
+export function pbkdf2(
+ password: string | Buffer,
+ salt: string | Buffer,
+ iterations: number,
+ keylen: number,
+ digest: string,
+ callback: (err: Error | null, derivedKey: Buffer) => void
+): void {
+ try {
+ const passwordBytes = typeof password === 'string' ? Buffer.from(password) : password;
+ const saltBytes = typeof salt === 'string' ? Buffer.from(salt) : salt;
+ const hasher = hashMap[digest.toLowerCase()];
+
+ if (!hasher) {
+ throw new Error(`Unsupported digest: ${digest}`);
+ }
+
+ const result = noblePbkdf2(hasher, passwordBytes, saltBytes, {
+ c: iterations,
+ dkLen: keylen,
+ });
+
+ callback(null, Buffer.from(result));
+ } catch (err) {
+ callback(err as Error, Buffer.alloc(0));
+ }
+}
+
+// AES-GCM Implementation
+class Cipher {
+ private key: Uint8Array;
+ private iv: Uint8Array;
+ private authTag: Buffer | null = null;
+ private aesGcm: any;
+ private buffer: Buffer = Buffer.alloc(0);
+
+ constructor(key: Buffer, iv: Buffer) {
+ this.key = new Uint8Array(key);
+ this.iv = new Uint8Array(iv);
+ // @noble/ciphers/aes gcm takes (key, nonce)
+ this.aesGcm = gcm(this.key, this.iv);
+ }
+
+ update(data: string | Buffer, inputEncoding?: string): Buffer {
+ const input = typeof data === 'string' ? Buffer.from(data, inputEncoding as any) : data;
+ this.buffer = Buffer.concat([this.buffer, input]);
+ return Buffer.alloc(0);
+ }
+
+ final(): Buffer {
+ const result = this.aesGcm.encrypt(this.buffer);
+ // @noble/ciphers returns ciphertext + tag (16 bytes)
+ const tag = result.slice(-16);
+ const ciphertext = result.slice(0, -16);
+ this.authTag = Buffer.from(tag);
+ return Buffer.from(ciphertext);
+ }
+
+ getAuthTag(): Buffer {
+ if (!this.authTag) throw new Error('Ciphers: TAG not available before final()');
+ return this.authTag;
+ }
+}
+
+class Decipher {
+ private key: Uint8Array;
+ private iv: Uint8Array;
+ private tag: Uint8Array | null = null;
+ private aesGcm: any;
+ private buffer: Buffer = Buffer.alloc(0);
+
+ constructor(key: Buffer, iv: Buffer) {
+ this.key = new Uint8Array(key);
+ this.iv = new Uint8Array(iv);
+ this.aesGcm = gcm(this.key, this.iv);
+ }
+
+ setAuthTag(tag: Buffer): void {
+ this.tag = new Uint8Array(tag);
+ }
+
+ update(data: Buffer): Buffer {
+ this.buffer = Buffer.concat([this.buffer, data]);
+ return Buffer.alloc(0);
+ }
+
+ final(): Buffer {
+ if (!this.tag) throw new Error('Decipher: Auth tag not set');
+ // @noble/ciphers expects ciphertext then tag
+ const full = new Uint8Array(this.buffer.length + this.tag.length);
+ full.set(this.buffer);
+ full.set(this.tag, this.buffer.length);
+
+ const decrypted = this.aesGcm.decrypt(full);
+ return Buffer.from(decrypted);
+ }
+}
+
+export function createCipheriv(algorithm: string, key: Buffer, iv: Buffer): Cipher {
+ if (algorithm !== 'aes-256-gcm') {
+ throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
+ }
+ return new Cipher(key, iv);
+}
+
+export function createDecipheriv(algorithm: string, key: Buffer, iv: Buffer): Decipher {
+ if (algorithm !== 'aes-256-gcm') {
+ throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
+ }
+ return new Decipher(key, iv);
+}