10 Commits

Author SHA1 Message Date
Ada
465881c0e4 Update README.md 2026-02-09 13:06:50 -08:00
Ada
8994a3e045 feat(flow): image attachment and 422 error message display
- Attach image then send with optional text or image-only (default prompt)
- Attached image preview above input with remove
- AI image API error detail no longer shows [object Object]
2026-02-04 17:19:51 -08:00
Ada
e33ea62e35 fix(mobile): polyfill
- polyfill crypto.getRandomValues (uuid/LangChain  not supported))
- polyfill AbortSignal.prototype.throwIfAborted (throwIfAborted is not a function)")
2026-02-04 16:48:00 -08:00
Ada
96d95a50fc fix: resolve ReadableStream in simulator so LangGraph runs on RN
- Add src/polyfills.ts as the first executed module; inject ReadableStream/WritableStream/TransformStream via web-streams-polyfill and ponyfill fallback
- Import polyfills at the top of App.tsx so globals are set before any LangChain/LangGraph code loads
- Add assets/images/icon.png to fix Metro “Asset not found” for icon
2026-02-04 15:24:14 -08:00
lusixing
c1ce804d14 langgraph_used 2026-02-03 21:37:41 -08:00
lusixing
0aab9a838b ai_role_update 2026-02-02 22:20:24 -08:00
lusixing
6822638d47 complete_heir_functions 2026-02-02 19:40:49 -08:00
lusixing
5c1172a912 added_reveal_secret_and_delete_treasure 2026-02-02 17:34:03 -08:00
lusixing
b5373c2d9a update_260201-3 2026-02-01 21:13:15 -08:00
Ada
3ffcc60ee8 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 15:22:32 -08:00
25 changed files with 3553 additions and 861 deletions

View File

@@ -4,6 +4,7 @@
* Main application component with authentication routing.
* Shows loading screen while restoring auth state.
*/
import './src/polyfills';
import React from 'react';
import { Buffer } from 'buffer';

798
README.md
View File

@@ -1,287 +1,595 @@
# Sentinel App
# Sentinel Frontend
[中文版](#中文版)
A React Native mobile application for secure digital legacy management, built with Expo and TypeScript. Sentinel enables users to preserve, encrypt, and conditionally transfer their digital assets to designated heirs through a sophisticated cryptographic architecture.
## Digital Legacy Management
## Table of Contents
Sentinel is a mobile application that helps users securely manage their digital legacy. Built with React Native (Expo) and TypeScript.
- [Overview](#overview)
- [Business Features](#business-features)
- [Technical Architecture](#technical-architecture)
- [Security & Cryptography](#security--cryptography)
- [Getting Started](#getting-started)
- [Project Structure](#project-structure)
- [Configuration](#configuration)
- [Development](#development)
## Features
## Overview
Sentinel is a comprehensive digital inheritance platform that addresses the critical challenge of preserving and transferring digital assets securely. The application combines advanced cryptographic techniques with an intuitive user interface, ensuring that sensitive information remains protected while enabling controlled access for designated beneficiaries.
### Core Value Proposition
- **Zero-Knowledge Architecture**: The platform cannot access user data without cryptographic keys
- **Conditional Release**: Assets are released only when specific conditions are met (e.g., subscription expiration, inactivity)
- **Multi-Layer Encryption**: Combines symmetric and asymmetric encryption for maximum security
- **Distributed Trust**: Uses secret sharing to prevent single points of failure
## Business Features
### 🗞️ Flow - Captain's Journal
- Record daily thoughts, emotions, and reflections
An AI-powered journaling interface for daily thoughts, emotions, and reflections.
**Capabilities:**
- Multi-modal entry support: text, voice, and image inputs
- AI-inferred emotional state tracking
- Archive entries to the encrypted Vault
- Support for text, voice, and image entries
- Conversational AI with multiple role configurations:
- Reflective Assistant: Deep introspection and emotional exploration
- Creative Spark: Brainstorming and creative writing
- Action Planner: Goal setting and productivity coaching
- Empathetic Guide: Emotional support and non-judgmental listening
- Archive entries to encrypted Vault for long-term preservation
- Conversation summarization for efficient review
**Use Cases:**
- Daily emotional tracking and mental health monitoring
- Creative writing and idea generation
- Goal planning and accountability
- Therapeutic journaling with AI support
### 📦 Vault - The Deep Vault
- End-to-end encrypted asset storage
- Support for game accounts, private keys, documents, photos, wills
- Biometric authentication required for access
- Zero-knowledge architecture
End-to-end encrypted storage for sensitive digital assets.
**Supported Asset Types:**
- **Game Accounts**: Credentials for gaming platforms (Steam, etc.)
- **Private Keys**: Cryptographic keys and wallet seeds
- **Documents**: Legal documents, contracts, and important files
- **Photos**: Personal memories and sensitive images
- **Wills**: Testamentary documents and final wishes
- **Custom**: User-defined asset categories
**Security Features:**
- Biometric authentication required for access (Face ID / Touch ID / Fingerprint)
- Shamir's Secret Sharing (3-of-2 threshold) for key management
- AES-256-GCM encryption for content protection
- User-isolated storage (multi-account support)
- Zero-knowledge architecture (server cannot decrypt without user shares)
**Workflow:**
1. User creates asset with plaintext content
2. System generates mnemonic phrase and splits into 3 shares
3. Content encrypted with derived AES key
4. One share stored on device, one on server, one for heir
5. Any 2 shares required to recover decryption key
### ⚓ Sentinel - Lighthouse Watch
- Dead Man's Switch monitoring system
- Heartbeat confirmation mechanism
- Subscription and activity tracking
- Configurable grace periods
Dead Man's Switch monitoring system for conditional asset release.
**Mechanism:**
- Continuous heartbeat monitoring of user activity
- Subscription status tracking
- Configurable grace periods before triggering release
- Activity logging and audit trail
- Status indicators: Normal, Warning, Releasing
**Trigger Conditions:**
- Subscription expiration without renewal
- Extended inactivity period
- Manual activation by user
- Administrative declaration (for testing/emergencies)
**Release Process:**
1. System detects trigger condition
2. Outer encryption layer removed (RSA gateway unlocked)
3. Heirs notified of available assets
4. Heirs can claim assets with their share + server share
### 🧭 Heritage - Fleet Legacy
- Heir management with release levels
- Customizable release order and timing
- Payment strategy configuration
- Legal document-style interface
Heir management and asset distribution system.
**Features:**
- **Heir Management**: Add, edit, and remove designated beneficiaries
- **Release Levels**: Configure priority tiers (1-3) for asset access
- **Release Order**: Define sequence for multi-heir scenarios
- **Payment Strategies**:
- Prepaid: Heir pays upfront for access
- Pay on Access: Payment required when claiming assets
- **Legal Document Interface**: Formal, testamentary-style presentation
- **Assignment Workflow**: Assign specific assets to specific heirs
**Heir Status:**
- **Invited**: Heir has been designated but not yet confirmed
- **Confirmed**: Heir has accepted invitation and verified identity
### ⛵ Me - Captain's Quarters
- Subscription and protocol status
- Sentinel configuration
- Security center
- Data export and backup
- Social responsibility program
## Tech Stack
User account management and system configuration.
- **Framework**: React Native (Expo SDK 52)
- **Language**: TypeScript
- **Navigation**: React Navigation (Bottom Tabs)
- **Icons**: @expo/vector-icons (Feather, Ionicons, FontAwesome5)
- **Styling**: Custom nautical theme with gradients
- **State Management**: React Context (AuthContext)
- **Storage**: AsyncStorage for auth persistence
**Sections:**
- **Subscription Status**: Current tier, expiration date, features enabled
- **Protocol Information**: Version tracking and update status
- **Sentinel Configuration**: Heartbeat intervals, grace periods, monitoring settings
- **Security Center**:
- Biometric settings
- Vault state management
- Key recovery options
- **Data Export**: Backup encrypted vault data
- **Social Responsibility**: Program information and participation
## Configuration
## Technical Architecture
The application uses a centralized configuration file located at `src/config/index.ts`.
### Technology Stack
### Key Configuration Options
| Category | Technology | Purpose |
|----------|-----------|---------|
| **Framework** | React Native (Expo SDK 52) | Cross-platform mobile development |
| **Language** | TypeScript 5.3+ | Type-safe development |
| **Navigation** | React Navigation 6 | Bottom tabs + stack navigation |
| **State Management** | React Context API | Authentication and app state |
| **Storage** | AsyncStorage | Local persistence (auth tokens, vault state) |
| **Icons** | @expo/vector-icons | Feather, Ionicons, FontAwesome5, Material |
| **Styling** | StyleSheet + LinearGradient | Custom nautical-themed UI |
| **Crypto** | Web Crypto API + Polyfills | Cryptographic operations |
| **AI Integration** | LangChain + LangGraph | AI conversation management |
| **Build System** | Metro Bundler | JavaScript bundling |
| Option | Description | Default |
|--------|-------------|---------|
| `NO_BACKEND_MODE` | Use mock data instead of real backend | `false` |
| `DEBUG_MODE` | Enable API debug logging | `true` |
| `API_BASE_URL` | Backend API server URL | `http://localhost:8000` |
| `API_TIMEOUT` | Request timeout (ms) | `30000` |
### Architecture Patterns
### API Endpoints
All backend API routes are defined in `API_ENDPOINTS`:
- **AUTH**: `/login`, `/register`
- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign`
- **AI**: `/ai/proxy`
- **ADMIN**: `/admin/declare-guale`
### Environment Setup
For development, you may need to modify `API_BASE_URL` in the config file to match your backend server address.
## Project Structure
```
src/
├── components/
│ └── common/
│ ├── BiometricModal.tsx
│ ├── Icons.tsx
│ └── VaultDoorAnimation.tsx
├── config/
│ └── index.ts # Centralized configuration
├── context/
│ └── AuthContext.tsx # Authentication state management
├── navigation/
│ ├── AuthNavigator.tsx # Login/Register navigation
│ └── TabNavigator.tsx # Main app navigation
├── screens/
│ ├── FlowScreen.tsx # AI chat interface
│ ├── VaultScreen.tsx
│ ├── SentinelScreen.tsx
│ ├── HeritageScreen.tsx
│ ├── MeScreen.tsx
│ ├── LoginScreen.tsx
│ └── RegisterScreen.tsx
├── services/
│ ├── index.ts # Service exports
│ ├── ai.service.ts # AI API integration
│ ├── auth.service.ts # Authentication API
│ ├── assets.service.ts # Asset management API
│ └── admin.service.ts # Admin operations API
├── theme/
│ └── colors.ts
└── types/
└── index.ts
assets/
├── icon.png # App icon (1024x1024)
├── adaptive-icon.png # Android adaptive icon
├── splash.png # Splash screen
├── favicon.png # Web favicon (32x32)
├── favicon.svg # SVG favicon for web
├── logo.svg # Vector logo (512x512)
└── images/
└── captain-avatar.svg # Avatar placeholder
```
## Services
#### Service Layer Architecture
The application uses a modular service architecture for API communication:
- **AuthService**: User authentication (login, register)
- **AIService**: AI conversation proxy with support for text and image input
- **AssetsService**: Digital asset management
- **AdminService**: Administrative operations
## Icons & Branding
The Sentinel brand uses a nautical anchor-and-star logo on a teal (#459E9E) background.
### Logo Elements
- **Anchor**: Symbolizes stability and anchoring your digital legacy
- **Star/Compass**: Represents guidance and direction for heirs
- **Teal Color**: Evokes ocean depth and calm security
### Generating Icons
```bash
# View icon specifications
node scripts/generate-icons.js
```
src/services/
├── auth.service.ts # Authentication (login, register)
├── assets.service.ts # Asset CRUD and inheritance
├── vault.service.ts # Vault encryption/decryption
├── ai.service.ts # AI conversation proxy
├── admin.service.ts # Administrative operations
├── langgraph.service.ts # LangGraph workflow integration
└── storage.service.ts # AsyncStorage abstraction
```
Use the `assets/logo.svg` as the source and export to required sizes.
**Service Pattern:**
- Centralized API configuration
- Mock mode support for development
- Consistent error handling
- Debug logging integration
- Type-safe request/response interfaces
#### Context-Based State Management
**AuthContext** (`src/context/AuthContext.tsx`):
- Manages authentication state
- Handles token persistence
- Provides user information throughout app
- Handles initialization and loading states
**Usage Pattern:**
```typescript
const { user, token, login, logout, isInitializing } = useAuth();
```
#### Navigation Structure
```
App (Root)
├── AuthNavigator (if not authenticated)
│ ├── LoginScreen
│ └── RegisterScreen
└── TabNavigator (if authenticated)
├── FlowScreen (🗞️)
├── VaultScreen (📦)
├── SentinelScreen (⚓)
├── HeritageScreen (🧭)
└── MeScreen (⛵)
```
### Key Dependencies
**Core:**
- `react`: 18.3.1
- `react-native`: 0.76.9
- `expo`: ~52.0.0
**Navigation:**
- `@react-navigation/native`: ^6.1.18
- `@react-navigation/bottom-tabs`: ^6.6.1
- `@react-navigation/native-stack`: ^6.11.0
**Cryptography:**
- `@noble/ciphers`: ^1.3.0 (AES encryption)
- `@noble/hashes`: ^1.8.0 (Hash functions)
- `bip39`: ^3.1.0 (Mnemonic generation)
- `expo-crypto`: ~14.0.2 (Crypto polyfills)
**AI & Language Models:**
- `@langchain/core`: ^1.1.18
- `@langchain/langgraph`: ^1.1.3
**Storage & Utilities:**
- `@react-native-async-storage/async-storage`: ^2.2.0
- `buffer`: ^6.0.3 (Node.js Buffer polyfill)
- `readable-stream`: ^4.7.0
## Security & Cryptography
### Encryption Architecture
Sentinel implements a multi-layer encryption system:
#### Layer 1: Vault Encryption (User-Controlled)
**Process:**
1. Generate 12-word BIP-39 mnemonic phrase
2. Derive AES-256 key using PBKDF2:
- Input: Mnemonic phrase
- Salt: `Sentinel_Salt_2026`
- Iterations: 100,000
- Hash: SHA-256
- Output: 32-byte AES key
3. Encrypt plaintext with AES-256-GCM:
- Mode: Galois/Counter Mode (authenticated encryption)
- IV: 16-byte random nonce
- Tag: 16-byte authentication tag
- Output: `IV + Ciphertext + Tag` (hex encoded)
**Implementation:** `src/utils/vaultCrypto.ts`
#### Layer 2: Secret Sharing (Distributed Trust)
**Shamir's Secret Sharing (3-of-2 Threshold):**
1. Convert mnemonic to entropy (BigInt representation)
2. Split entropy into 3 shares using linear polynomial:
```
f(x) = secret + a*x (mod P)
```
Where:
- `secret`: Mnemonic entropy
- `a`: Random coefficient
- `P`: Prime modulus (2^127 - 1)
- Shares at x = 1, 2, 3
3. Share Distribution:
- **Device Share (S0)**: Stored locally on user's device
- **Cloud Share (S1)**: Stored on Sentinel server
- **Heir Share (S2)**: Provided to designated heir
4. Recovery: Any 2 shares can recover original entropy via Lagrange interpolation
**Implementation:** `src/utils/sss.ts`
#### Layer 3: Gateway Encryption (Server-Controlled)
**RSA Outer Encryption:**
- Server generates RSA-4096 key pair per asset
- Inner encrypted content encrypted again with RSA public key
- Private key held by server, released only on trigger conditions
- Prevents unauthorized access even if inner encryption is compromised
**Note:** Gateway encryption is handled by backend; frontend sends `content_inner_encrypted` which backend wraps with RSA.
### Key Management
**Storage Strategy:**
- User-isolated keys: `getVaultStorageKeys(userId)` generates per-user storage keys
- Device share (S0): Stored in AsyncStorage with user-scoped key
- Mnemonic backup: Optional local backup of mnemonic portion (encrypted)
- Multi-account support: Each user has independent vault state
**Storage Keys:**
```typescript
{
INITIALIZED: `sentinel_vault_initialized_u{userId}`,
SHARE_DEVICE: `sentinel_vault_s0_u{userId}`,
MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local_u{userId}`,
AES_KEY: `sentinel_aes_key_u{userId}`,
SHARE_SERVER: `sentinel_share_server_u{userId}`,
SHARE_HEIR: `sentinel_share_heir_u{userId}`
}
```
### Security Properties
1. **Zero-Knowledge**: Server cannot decrypt user data without user's share
2. **Forward Secrecy**: Compromising one share reveals nothing about the secret
3. **Authenticated Encryption**: GCM mode ensures data integrity
4. **Key Derivation**: PBKDF2 with high iteration count prevents brute force
5. **Distributed Trust**: No single point of failure for key recovery
## Getting Started
### Prerequisites
- Node.js 18+ and npm
- Expo CLI (installed globally or via npx)
- iOS Simulator (macOS) or Android Emulator / physical device
- Backend API server running (or use `NO_BACKEND_MODE`)
### Installation
```bash
# Install dependencies
npm install
# Start the development server
# Start Expo development server
npx expo start
```
### Platform-Specific Commands
```bash
# iOS Simulator (macOS only)
npm run ios
# Android Emulator / Device
npm run android
# Web Browser
npm run web
```
### Development Modes
**With Backend:**
1. Ensure backend server is running (default: `http://localhost:8000`)
2. Update `API_BASE_URL` in `src/config/index.ts` if needed
3. Start Expo: `npx expo start`
**Without Backend (Mock Mode):**
1. Set `NO_BACKEND_MODE = true` in `src/config/index.ts`
2. All API calls return mock data
3. Useful for UI development and testing
## Project Structure
```
frontend/
├── App.tsx # Root component with auth routing
├── app.json # Expo configuration
├── package.json # Dependencies and scripts
├── tsconfig.json # TypeScript configuration
├── assets/ # Static assets
│ ├── icon.png # App icon (1024x1024)
│ ├── adaptive-icon.png # Android adaptive icon
│ ├── splash.png # Splash screen
│ ├── favicon.png # Web favicon
│ ├── logo.svg # Vector logo
│ └── images/ # Additional images
├── scripts/ # Build scripts
│ └── generate-icons.js # Icon generation utility
└── src/
├── components/ # Reusable UI components
│ └── common/
│ ├── BiometricModal.tsx # Biometric auth modal
│ ├── Icons.tsx # Icon helper component
│ └── VaultDoorAnimation.tsx # Vault unlock animation
├── config/ # Configuration
│ └── index.ts # Centralized config (API, endpoints, etc.)
├── context/ # React Context providers
│ └── AuthContext.tsx # Authentication state management
├── hooks/ # Custom React hooks
│ ├── index.ts
│ └── useVaultAssets.ts # Vault asset management hook
├── navigation/ # Navigation configuration
│ ├── AuthNavigator.tsx # Login/Register navigation
│ └── TabNavigator.tsx # Main app tab navigation
├── screens/ # Screen components
│ ├── FlowScreen.tsx # AI journaling interface
│ ├── VaultScreen.tsx # Encrypted asset management
│ ├── SentinelScreen.tsx # Dead Man's Switch monitoring
│ ├── HeritageScreen.tsx # Heir management
│ ├── MeScreen.tsx # User settings and account
│ ├── LoginScreen.tsx # Authentication
│ └── RegisterScreen.tsx
├── services/ # API service layer
│ ├── index.ts # Service exports
│ ├── auth.service.ts # Authentication API
│ ├── assets.service.ts # Asset CRUD API
│ ├── vault.service.ts # Vault encryption utilities
│ ├── ai.service.ts # AI conversation API
│ ├── admin.service.ts # Admin operations
│ ├── langgraph.service.ts # LangGraph integration
│ └── storage.service.ts # AsyncStorage abstraction
├── theme/ # Design system
│ └── colors.ts # Color palette and typography
├── types/ # TypeScript type definitions
│ └── index.ts # Shared types and interfaces
├── utils/ # Utility functions
│ ├── index.ts
│ ├── crypto_core.ts # Crypto utilities (if needed)
│ ├── crypto_polyfill.ts # Crypto API polyfills
│ ├── sss.ts # Shamir's Secret Sharing
│ ├── vaultCrypto.ts # AES encryption/decryption
│ ├── vaultAssets.ts # Asset management utilities
│ ├── token_utils.ts # Token management
│ └── async_hooks_mock.ts # Async hooks polyfill
└── polyfills.ts # Global polyfills (Buffer, etc.)
```
## Configuration
### Environment Configuration
All configuration is centralized in `src/config/index.ts`:
```typescript
// Development mode
export const NO_BACKEND_MODE = false; // Use mock data
export const DEBUG_MODE = true; // API debug logging
// API Configuration
export const API_BASE_URL = 'http://localhost:8000';
export const API_TIMEOUT = 30000; // 30 seconds
```
### API Endpoints
Defined in `API_ENDPOINTS` constant:
| Category | Endpoint | Method | Purpose |
|----------|----------|--------|---------|
| **Auth** | `/login` | POST | User authentication |
| | `/register` | POST | User registration |
| **Assets** | `/assets/get` | GET | Fetch user's assets |
| | `/assets/create` | POST | Create new asset |
| | `/assets/claim` | POST | Claim inherited asset |
| | `/assets/assign` | POST | Assign asset to heir |
| | `/assets/delete` | POST | Delete asset |
| **AI** | `/ai/proxy` | POST | AI conversation proxy |
| | `/get_ai_roles` | GET | Fetch available AI roles |
| **Admin** | `/admin/declare-guale` | POST | Admin: Declare user deceased |
### Vault Storage Configuration
Vault storage uses user-scoped keys for multi-account support:
```typescript
// Get storage keys for a user
const keys = getVaultStorageKeys(userId);
// Keys are prefixed with user ID to prevent cross-user access
// Format: sentinel_vault_{key}_{suffix}
// Suffix: _u{userId} for authenticated users, _guest for guests
```
### AI Configuration
AI roles and system prompts are configurable:
```typescript
export const AI_CONFIG = {
DEFAULT_SYSTEM_PROMPT: '...',
MOCK_RESPONSE_DELAY: 500,
ROLES: [
{ id: 'reflective', name: 'Reflective Assistant', ... },
{ id: 'creative', name: 'Creative Spark', ... },
{ id: 'planner', name: 'Action Planner', ... },
{ id: 'empathetic', name: 'Empathetic Guide', ... },
]
};
```
## Development
### Code Style
- **TypeScript**: Strict mode enabled, all files typed
- **Components**: Functional components with hooks
- **Naming**: PascalCase for components, camelCase for functions/variables
- **Imports**: Absolute imports preferred (configured in tsconfig.json)
### Testing
**Manual Testing:**
- Use Expo Go app on physical device for real-world testing
- Test biometric authentication on actual devices
- Verify encryption/decryption flows with real backend
**Mock Mode Testing:**
- Enable `NO_BACKEND_MODE` for UI/UX testing
- Mock responses simulate real API behavior
- Useful for rapid iteration without backend dependency
### Debugging
**API Debug Logging:**
- Enabled when `DEBUG_MODE = true`
- Logs all API requests/responses to console
- Includes request URLs, headers, and response data
**React Native Debugger:**
- Use React Native Debugger or Chrome DevTools
- Inspect component state and props
- Monitor network requests
### Building for Production
```bash
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Build for Web
npx expo export:web
```
**Note:** Requires Expo Application Services (EAS) account for native builds.
### Common Issues
**Crypto API Not Available:**
- Ensure polyfills are loaded (`src/polyfills.ts`)
- Check that `crypto.subtle` is available in environment
- React Native requires polyfills for Web Crypto API
**AsyncStorage Errors:**
- Ensure `@react-native-async-storage/async-storage` is properly linked
- Check storage permissions on device
- Clear storage if corrupted: `AsyncStorage.clear()`
**Navigation Issues:**
- Ensure `NavigationContainer` wraps navigators
- Check that screens are properly registered
- Verify tab bar configuration matches screen names
## Design Philosophy
- **Nautical Theme**: Captain's sanctum aesthetic with anchors, ship wheels, and ocean colors
- **Emotional Balance**: Warm and secure feeling across different tabs
- **Privacy First**: Zero-knowledge architecture, local encryption
- **Elegant UI**: Mint gradients, serif typography, subtle shadows
### Nautical Theme
The application uses a consistent nautical/maritime aesthetic:
- **Color Palette**: Teal (#459E9E) primary, ocean blues, mint gradients
- **Iconography**: Anchors, ship wheels, compasses, lighthouses
- **Terminology**: Captain, Fleet, Vault, Sentinel, Heritage
- **Typography**: Serif fonts for formal sections, sans-serif for UI
### User Experience Principles
1. **Privacy First**: Encryption happens locally, user controls keys
2. **Transparency**: Clear explanation of security mechanisms
3. **Accessibility**: Biometric auth for convenience, fallback options available
4. **Elegance**: Clean, modern UI with subtle animations
5. **Trust**: Visual indicators for security status and system health
## License
Private - All rights reserved.
## Support
For issues, questions, or contributions, please contact the development team.
---
# 中文版
[English Version](#sentinel-app)
## 数字遗产管理
Sentinel 是一款帮助用户安全管理数字遗产的移动应用程序。使用 React Native (Expo) 和 TypeScript 构建。
## 功能特性
### 🗞️ Flow - 船长日志
- 记录日常想法、情感和反思
- AI 推断情感状态追踪
- 将条目归档到加密保险库
- 支持文本、语音和图像条目
### 📦 Vault - 深海宝库
- 端到端加密资产存储
- 支持游戏账号、私钥、文档、照片、遗嘱
- 需要生物识别认证才能访问
- 零知识架构
### ⚓ Sentinel - 灯塔守望
- 死人开关监控系统
- 心跳确认机制
- 订阅和活动追踪
- 可配置的冷静期
### 🧭 Heritage - 舰队遗产
- 继承人管理与释放等级
- 可自定义释放顺序和时间
- 付款策略配置
- 法律文书风格界面
### ⛵ Me - 船长室
- 订阅和协议状态
- 哨兵配置
- 安全中心
- 数据导出和备份
- 社会责任计划
## 技术栈
- **框架**: React Native (Expo SDK 52)
- **语言**: TypeScript
- **导航**: React Navigation (底部标签)
- **图标**: @expo/vector-icons (Feather, Ionicons, FontAwesome5)
- **样式**: 自定义航海主题配渐变
- **状态管理**: React Context (AuthContext)
- **存储**: AsyncStorage 用于认证持久化
## 配置说明
应用使用位于 `src/config/index.ts` 的集中配置文件。
### 主要配置项
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `NO_BACKEND_MODE` | 使用模拟数据而非真实后端 | `false` |
| `DEBUG_MODE` | 启用 API 调试日志 | `true` |
| `API_BASE_URL` | 后端 API 服务器地址 | `http://localhost:8000` |
| `API_TIMEOUT` | 请求超时时间(毫秒) | `30000` |
### API 端点
所有后端 API 路由定义在 `API_ENDPOINTS` 中:
- **AUTH**: `/login`, `/register`
- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign`
- **AI**: `/ai/proxy`
- **ADMIN**: `/admin/declare-guale`
### 环境配置
开发时,您可能需要修改配置文件中的 `API_BASE_URL` 以匹配您的后端服务器地址。
## 服务层
应用使用模块化的服务架构进行 API 通信:
- **AuthService**: 用户认证(登录、注册)
- **AIService**: AI 对话代理,支持文本和图片输入
- **AssetsService**: 数字资产管理
- **AdminService**: 管理员操作
## 运行项目
```bash
# 安装依赖
npm install
# 启动开发服务器
npx expo start
```
## 图标与品牌
Sentinel 品牌使用青色(#459E9E)背景上的航海锚与星星标志。
### 标志元素
- **锚**: 象征稳定性和锚定你的数字遗产
- **星星/指南针**: 代表对继承人的指引和方向
- **青色**: 唤起海洋深度和平静的安全感
### 生成图标
```bash
# 查看图标规格
node scripts/generate-icons.js
```
使用 `assets/logo.svg` 作为源文件并导出所需尺寸。
## 设计理念
- **航海主题**: 船长圣殿美学,配以锚、船舵和海洋色彩
- **情感平衡**: 不同标签页带来温暖而安全的感觉
- **隐私优先**: 零知识架构,本地加密
- **优雅界面**: 薄荷渐变、衬线字体、柔和阴影
**Version**: 2.0.0
**Last Updated**: February 2026

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

15
metro.config.js Normal file
View File

@@ -0,0 +1,15 @@
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'),
vm: require.resolve('vm-browserify'),
async_hooks: path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
'node:async_hooks': path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
};
module.exports = config;

471
package-lock.json generated
View File

@@ -10,6 +10,10 @@
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
"@langchain/core": "^1.1.18",
"@langchain/langgraph": "^1.1.3",
"@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 +23,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 +37,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",
@@ -2198,6 +2205,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@egjs/hammerjs": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
@@ -3211,9 +3224,219 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@langchain/core": {
"version": "1.1.18",
"resolved": "https://registry.npmmirror.com/@langchain/core/-/core-1.1.18.tgz",
"integrity": "sha512-vwzbtHUSZaJONBA1n9uQedZPfyFFZ6XzTggTpR28n8tiIg7e1NC/5dvGW/lGtR1Du1VwV9DvDHA5/bOrLe6cVg==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
"js-tiktoken": "^1.0.12",
"langsmith": ">=0.4.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^10.0.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/core/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@langchain/core/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/core/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@langchain/langgraph/-/langgraph-1.1.3.tgz",
"integrity": "sha512-o/cEWeocDDSpyBI2MfX07LkNG4LzdRKxwcgUcbR4PyRzhxxCkeIZRCCYkXVQoDbdKqAczJa0D7+yjU9rmA5iHQ==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.0.0",
"@langchain/langgraph-sdk": "~1.5.5",
"@standard-schema/spec": "1.1.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.0.1",
"zod": "^3.25.32 || ^4.2.0",
"zod-to-json-schema": "^3.x"
},
"peerDependenciesMeta": {
"zod-to-json-schema": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-checkpoint": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz",
"integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==",
"license": "MIT",
"dependencies": {
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.0.1"
}
},
"node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.5.tgz",
"integrity": "sha512-SyiAs6TVXPWlt/8cI9pj/43nbIvclY3ytKqUFbL5MplCUnItetEyqvH87EncxyVF5D7iJKRZRfSVYBMmOZbjbQ==",
"license": "MIT",
"dependencies": {
"p-queue": "^9.0.1",
"p-retry": "^7.1.1",
"uuid": "^13.0.0"
},
"peerDependencies": {
"@langchain/core": "^1.1.15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
},
"peerDependenciesMeta": {
"@langchain/core": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.1.0",
"resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"p-timeout": "^7.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-7.0.1.tgz",
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/@langchain/langgraph/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"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": {
@@ -3801,6 +4024,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3923,6 +4152,12 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -5082,6 +5317,15 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/console-table-printer": {
"version": "2.15.0",
"resolved": "https://registry.npmmirror.com/console-table-printer/-/console-table-printer-2.15.0.tgz",
"integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==",
"license": "MIT",
"dependencies": {
"simple-wcswidth": "^1.1.2"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5221,6 +5465,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -5579,6 +5832,21 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"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 +6024,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",
@@ -6775,6 +7055,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-network-error": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/is-network-error/-/is-network-error-1.3.0.tgz",
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -7093,6 +7385,15 @@
"integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==",
"license": "MIT"
},
"node_modules/js-tiktoken": {
"version": "1.0.21",
"resolved": "https://registry.npmmirror.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
"integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.5.1"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7213,6 +7514,65 @@
"node": ">=6"
}
},
"node_modules/langsmith": {
"version": "0.4.12",
"resolved": "https://registry.npmmirror.com/langsmith/-/langsmith-0.4.12.tgz",
"integrity": "sha512-YWt0jcGvKqjUgIvd78rd4QcdMss0lUkeUaqp0UpVRq7H2yNDx8H5jOUO/laWUmaPtWGgcip0qturykXe1g9Gqw==",
"license": "MIT",
"dependencies": {
"@types/uuid": "^10.0.0",
"chalk": "^4.1.2",
"console-table-printer": "^2.12.1",
"p-queue": "^6.6.2",
"semver": "^7.6.3",
"uuid": "^10.0.0"
},
"peerDependencies": {
"@opentelemetry/api": "*",
"@opentelemetry/exporter-trace-otlp-proto": "*",
"@opentelemetry/sdk-trace-base": "*",
"openai": "*"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-proto": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
},
"openai": {
"optional": true
}
}
},
"node_modules/langsmith/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/langsmith/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -8299,6 +8659,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -8713,6 +9082,49 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -9063,6 +9475,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 +9955,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",
@@ -10080,6 +10517,12 @@
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/simple-wcswidth": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz",
"integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==",
"license": "MIT"
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -10234,6 +10677,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 +11463,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",
@@ -11415,6 +11873,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -11,6 +11,10 @@
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
"@langchain/core": "^1.1.18",
"@langchain/langgraph": "^1.1.3",
"@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 +24,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 +34,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",

View File

@@ -62,20 +62,18 @@ export default function BiometricModal({
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);
});
};

View File

@@ -51,11 +51,13 @@ export const API_ENDPOINTS = {
CREATE: '/assets/create',
CLAIM: '/assets/claim',
ASSIGN: '/assets/assign',
DELETE: '/assets/delete',
},
// AI Services
AI: {
PROXY: '/ai/proxy',
GET_ROLES: '/get_ai_roles',
},
// Admin Operations
@@ -67,9 +69,9 @@ export const API_ENDPOINTS = {
// =============================================================================
// Vault storage (user-isolated, multi-account)
// =============================================================================
// - AsyncStorage keys for vault state (S0 share, initialized flag).
// - User-scoped: each account has its own keys so vault state is isolated.
// - Store: use getVaultStorageKeys(userId) and write to INITIALIZED / SHARE_DEVICE.
// - AsyncStorage keys for vault state (S0 share, initialized flag, mnemonic part backup).
// - User-scoped: each account has its own keys so vault/mnemonic state is isolated.
// - Store: use getVaultStorageKeys(userId) and write to INITIALIZED / SHARE_DEVICE / MNEMONIC_PART_LOCAL.
// - Clear: use same keys in multiRemove (e.g. MeScreen Reset Vault State).
// - Multi-account: same device, multiple users → each has independent vault (no cross-user leakage).
@@ -79,21 +81,30 @@ const VAULT_KEY_PREFIX = 'sentinel_vault';
export const VAULT_STORAGE_KEYS = {
INITIALIZED: 'sentinel_vault_initialized',
SHARE_DEVICE: 'sentinel_vault_s0',
MNEMONIC_PART_LOCAL: 'sentinel_mnemonic_part_local',
} as const;
/**
* Returns vault storage keys for the given user (user isolation).
* - Use for: reading S0, writing S0 after mnemonic, clearing on Reset Vault State.
* - Use for: reading/writing S0, mnemonic part backup, clearing on Reset Vault State.
* - userId null → guest namespace (_guest). userId set → per-user namespace (_u{userId}).
*/
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}`,
};
}

View File

@@ -7,8 +7,9 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginRequest, RegisterRequest } from '../types';
import { User, LoginRequest, RegisterRequest, AIRole } from '../types';
import { authService } from '../services/auth.service';
import { aiService } from '../services/ai.service';
import { storageService } from '../services/storage.service';
// =============================================================================
@@ -18,11 +19,13 @@ import { storageService } from '../services/storage.service';
interface AuthContextType {
user: User | null;
token: string | null;
aiRoles: AIRole[];
isLoading: boolean;
isInitializing: boolean;
signIn: (credentials: LoginRequest) => Promise<void>;
signUp: (data: RegisterRequest) => Promise<void>;
signOut: () => void;
refreshAIRoles: () => Promise<void>;
}
// Storage keys
@@ -44,6 +47,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [aiRoles, setAIRoles] = useState<AIRole[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
@@ -66,6 +70,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
// Fetch AI roles after restoring session
fetchAIRoles(storedToken);
}
} catch (error) {
console.error('[Auth] Failed to load stored auth:', error);
@@ -74,6 +80,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
/**
* Fetch AI roles from API
*/
const fetchAIRoles = async (authToken: string) => {
console.log('[Auth] Fetching AI roles with token:', authToken ? `${authToken.substring(0, 10)}...` : 'MISSING');
try {
const roles = await aiService.getAIRoles(authToken);
setAIRoles(roles);
console.log('[Auth] AI roles fetched successfully:', roles.length);
} catch (error) {
console.error('[Auth] Failed to fetch AI roles:', error);
}
};
/**
* Manual refresh of AI roles
*/
const refreshAIRoles = async () => {
if (token) {
await fetchAIRoles(token);
}
};
/**
* Save authentication to AsyncStorage
*/
@@ -114,6 +143,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(response.access_token);
setUser(response.user);
await saveAuth(response.access_token, response.user);
// Fetch AI roles immediately after login
await fetchAIRoles(response.access_token);
} catch (error) {
throw error;
} finally {
@@ -143,7 +174,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signOut = () => {
setUser(null);
setToken(null);
setAIRoles([]);
clearAuth();
//storageService.clearAllData();
};
@@ -152,11 +186,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
value={{
user,
token,
aiRoles,
isLoading,
isInitializing,
signIn,
signUp,
signOut
signOut,
refreshAIRoles
}}
>
{children}

View File

@@ -6,9 +6,12 @@
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 { storageService } from '../services/storage.service';
import {
initialVaultAssets,
mapApiAssetsToVaultAssets,
@@ -35,6 +38,10 @@ export interface UseVaultAssetsReturn {
refreshAssets: () => Promise<void>;
/** Create asset via POST /assets/create; on success refreshes list */
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
/** Delete asset via POST /assets/delete; on success refreshes list */
deleteAsset: (assetId: number) => Promise<CreateAssetResult>;
/** Assign asset to heir via POST /assets/assign */
assignAsset: (assetId: number, heirEmail: string) => Promise<CreateAssetResult>;
/** True while create request is in flight */
isSealing: boolean;
/** Error message from last create failure (non-401) */
@@ -51,7 +58,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<VaultAsset[]>(initialVaultAssets);
const [isSealing, setIsSealing] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
@@ -63,10 +70,14 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
if (Array.isArray(list)) {
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
}
} catch {
} catch (err: unknown) {
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
if (/Could not validate credentials/i.test(rawMessage)) {
signOut();
}
// Keep current assets (mock or previous fetch)
}
}, [token]);
}, [token, signOut]);
// Fetch list when unlocked and token exists
useEffect(() => {
@@ -79,7 +90,13 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
}
})
.catch(() => {
.catch((err) => {
if (!cancelled) {
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
if (/Could not validate credentials/i.test(rawMessage)) {
signOut();
}
}
// Keep initial (mock) assets
});
return () => {
@@ -101,22 +118,45 @@ 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
);
await assetsService.createAsset(
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);
}
const createdAsset = 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
);
// Backup plaintext content locally
if (createdAsset && createdAsset.id && user?.id) {
await storageService.saveAssetBackup(createdAsset.id, content, user.id);
}
await refreshAssets();
return { success: true };
} catch (err: unknown) {
@@ -143,9 +183,85 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setIsSealing(false);
}
},
[token, user, refreshAssets, signOut]
);
const deleteAsset = useCallback(
async (assetId: number): Promise<CreateAssetResult> => {
if (!token) {
return { success: false, error: 'Not logged in.' };
}
setIsSealing(true);
setCreateError(null);
try {
await assetsService.deleteAsset(assetId, token);
await refreshAssets();
return { success: true };
} catch (err: unknown) {
const status =
err && typeof err === 'object' && 'status' in err
? (err as { status?: number }).status
: undefined;
const rawMessage =
err instanceof Error ? err.message : String(err ?? 'Failed to delete.');
const isUnauthorized =
status === 401 || /401|Unauthorized/i.test(rawMessage);
if (isUnauthorized) {
signOut();
return { success: false, isUnauthorized: true };
}
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
? 'Network error. Please check that the backend is running and reachable.'
: rawMessage;
setCreateError(friendlyMessage);
return { success: false, error: friendlyMessage };
} finally {
setIsSealing(false);
}
},
[token, refreshAssets, signOut]
);
const assignAsset = useCallback(
async (assetId: number, heirEmail: string): Promise<CreateAssetResult> => {
if (!token) {
return { success: false, error: 'Not logged in.' };
}
setIsSealing(true);
setCreateError(null);
try {
await assetsService.assignAsset({ asset_id: assetId, heir_email: heirEmail }, token);
await refreshAssets();
return { success: true };
} catch (err: unknown) {
const status =
err && typeof err === 'object' && 'status' in err
? (err as { status?: number }).status
: undefined;
const rawMessage =
err instanceof Error ? err.message : String(err ?? 'Failed to assign.');
const isUnauthorized =
status === 401 || /401|Unauthorized/i.test(rawMessage);
if (isUnauthorized) {
signOut();
return { success: false, isUnauthorized: true };
}
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
? 'Network error. Please check that the backend is running and reachable.'
: rawMessage;
setCreateError(friendlyMessage);
return { success: false, error: friendlyMessage };
} finally {
setIsSealing(false);
}
},
[token, signOut]
);
const clearCreateError = useCallback(() => setCreateError(null), []);
return {
@@ -153,6 +269,8 @@ export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
setAssets,
refreshAssets,
createAsset,
deleteAsset,
assignAsset,
isSealing,
createError,
clearCreateError,

45
src/polyfills.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Polyfills that must run before any other app code (including LangChain/LangGraph).
* This file is imported as the very first line in App.tsx so that ReadableStream
* and crypto.getRandomValues exist before @langchain/core / uuid are loaded.
*/
import 'web-streams-polyfill';
// Ensure globalThis has ReadableStream (main polyfill may not patch in RN/Metro)
const g = typeof globalThis !== 'undefined' ? globalThis : (typeof global !== 'undefined' ? global : (typeof self !== 'undefined' ? self : {}));
if (typeof (g as any).ReadableStream === 'undefined') {
const ponyfill = require('web-streams-polyfill/dist/ponyfill.js');
(g as any).ReadableStream = ponyfill.ReadableStream;
(g as any).WritableStream = ponyfill.WritableStream;
(g as any).TransformStream = ponyfill.TransformStream;
}
// Polyfill crypto.getRandomValues for React Native/Expo (required by uuid, LangChain, etc.)
if (typeof g !== 'undefined') {
const cryptoObj = (g as any).crypto;
if (!cryptoObj || typeof (cryptoObj.getRandomValues) !== 'function') {
try {
const ExpoCrypto = require('expo-crypto');
const getRandomValues = (array: ArrayBufferView): ArrayBufferView => {
ExpoCrypto.getRandomValues(array);
return array;
};
if (!(g as any).crypto) (g as any).crypto = {};
(g as any).crypto.getRandomValues = getRandomValues;
} catch (e) {
console.warn('[polyfills] crypto.getRandomValues polyfill failed:', e);
}
}
}
// Polyfill AbortSignal.prototype.throwIfAborted (required by fetch/LangChain in RN; not present in older runtimes)
const AbortSignalGlobal = (g as any).AbortSignal;
if (typeof AbortSignalGlobal === 'function' && AbortSignalGlobal.prototype && typeof AbortSignalGlobal.prototype.throwIfAborted !== 'function') {
AbortSignalGlobal.prototype.throwIfAborted = function (this: AbortSignal) {
if (this.aborted) {
const e = new Error('Aborted');
e.name = 'AbortError';
throw e;
}
};
}

View File

@@ -28,11 +28,18 @@ import {
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { AIRole } from '../types';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { aiService } from '../services/ai.service';
import { aiService, AIMessage } from '../services/ai.service';
import { langGraphService } from '../services/langgraph.service';
import { HumanMessage, AIMessage as LangChainAIMessage, SystemMessage } from "@langchain/core/messages";
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
@@ -59,7 +66,7 @@ interface ChatSession {
// =============================================================================
export default function FlowScreen() {
const { token, user, signOut } = useAuth();
const { token, user, signOut, aiRoles, refreshAIRoles } = useAuth();
const scrollViewRef = useRef<ScrollView>(null);
// Current conversation state
@@ -67,10 +74,11 @@ export default function FlowScreen() {
const [newContent, setNewContent] = useState('');
const [isSending, setIsSending] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
/** Attached image for next send (uri + base64); user can add optional text then send together */
const [attachedImage, setAttachedImage] = useState<{ uri: string; base64: string } | null>(null);
// AI Role state
const [selectedRole, setSelectedRole] = useState(AI_CONFIG.ROLES[0]);
// AI Role state - start with null to detect first load
const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
const [showRoleModal, setShowRoleModal] = useState(false);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
@@ -78,6 +86,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<ChatSession[]>([
// Sample history data
{
@@ -143,9 +163,9 @@ export default function FlowScreen() {
// Load messages whenever role changes
useEffect(() => {
const loadRoleMessages = async () => {
if (!user) return;
if (!user || !selectedRole) return;
try {
const savedMessages = await storageService.getCurrentChat(selectedRole.id, user.id);
const savedMessages = await storageService.getCurrentChat(selectedRole?.id || '', user.id);
if (savedMessages) {
const formattedMessages = savedMessages.map((msg: any) => ({
...msg,
@@ -156,18 +176,42 @@ export default function FlowScreen() {
setMessages([]);
}
} catch (error) {
console.error(`Failed to load messages for role ${selectedRole.id}:`, error);
if (selectedRole) {
console.error(`Failed to load messages for role ${selectedRole?.id}:`, error);
}
setMessages([]);
}
};
loadRoleMessages();
}, [selectedRole.id, user]);
}, [selectedRole?.id, user]);
// Ensure we have a valid selected role from the dynamic list
useEffect(() => {
if (aiRoles.length > 0) {
if (!selectedRole) {
// Initial load or first time roles become available
setSelectedRole(aiRoles[0]);
} else {
// If roles refreshed, make sure current selectedRole is still valid or updated
const updatedRole = aiRoles.find(r => r.id === selectedRole?.id);
if (updatedRole) {
setSelectedRole(updatedRole);
} else {
// Current role no longer exists in dynamic list, fallback to first
setSelectedRole(aiRoles[0]);
}
}
} else if (!selectedRole) {
// Fallback if no dynamic roles yet
setSelectedRole(AI_CONFIG.ROLES[0]);
}
}, [aiRoles]);
// Save current messages for the active role when they change
useEffect(() => {
if (user && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole.id, messages, user.id);
if (user && selectedRole && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole?.id || '', messages, user.id);
}
if (messages.length > 0) {
@@ -175,7 +219,7 @@ export default function FlowScreen() {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages, selectedRole.id, user]);
}, [messages, selectedRole?.id, user]);
// Save history when it changes
useEffect(() => {
@@ -210,10 +254,12 @@ export default function FlowScreen() {
// =============================================================================
/**
* Handle sending a message to AI
* Handle sending a message to AI (text-only via LangGraph, or image + optional text via vision API)
*/
const handleSendMessage = async () => {
if (!newContent.trim() || isSending) return;
const hasText = !!newContent.trim();
const hasImage = !!attachedImage;
if ((!hasText && !hasImage) || isSending || !selectedRole) return;
// Check authentication
if (!token) {
@@ -225,11 +271,64 @@ export default function FlowScreen() {
return;
}
const userMessage = newContent.trim();
setIsSending(true);
// --- Path: send with image (optional text) ---
if (hasImage && attachedImage) {
const imageUri = attachedImage.uri;
const imageBase64 = attachedImage.base64;
const userText = newContent.trim() || '请描述或分析这张图片';
setAttachedImage(null);
setNewContent('');
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: userText,
imageUri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
const aiResponse = await aiService.sendMessageWithImage(userText, imageBase64, token);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert('Session Expired', 'Your login session has expired. Please login again.', [{ text: 'OK' }]);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
}
return;
}
// --- Path: text-only via LangGraph (unchanged) ---
const userMessage = newContent.trim();
setNewContent('');
// Add user message immediately
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
@@ -239,10 +338,15 @@ export default function FlowScreen() {
setMessages(prev => [...prev, userMsg]);
try {
// Call AI proxy with selected role's system prompt
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole.systemPrompt);
const history: (HumanMessage | LangChainAIMessage | SystemMessage)[] = messages.map(msg => {
if (msg.role === 'user') return new HumanMessage(msg.content);
return new LangChainAIMessage(msg.content);
});
const systemPrompt = new SystemMessage(selectedRole?.systemPrompt || '');
const currentMsg = new HumanMessage(userMessage);
const fullMessages = [systemPrompt, ...history, currentMsg];
const aiResponse = await langGraphService.execute(fullMessages, token);
// Add AI response
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
@@ -250,20 +354,15 @@ export default function FlowScreen() {
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Handle authentication errors (401, credentials, unauthorized)
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('credentials') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Not authenticated') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
@@ -273,8 +372,6 @@ export default function FlowScreen() {
);
return;
}
// Show error as AI message
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
@@ -296,17 +393,15 @@ export default function FlowScreen() {
};
/**
* Handle image attachment - pick image and analyze with AI
* Handle image attachment - pick image and attach to next message (user can add text then send)
*/
const handleAddImage = async () => {
// Request permission
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Required', 'Please grant permission to access photos');
return;
}
// Pick image
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
@@ -315,78 +410,11 @@ export default function FlowScreen() {
});
if (!result.canceled && result.assets[0]) {
const imageAsset = result.assets[0];
setSelectedImage(imageAsset.uri);
// Check authentication
if (!token) {
Alert.alert(
'Login Required',
'Please login to analyze images',
[{ text: 'OK', onPress: () => signOut() }]
);
return;
}
setIsSending(true);
// Add user message with image
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: 'Analyze this image',
imageUri: imageAsset.uri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Call AI with image (using base64)
const aiResponse = await aiService.sendMessageWithImage(
'Please describe and analyze this image in detail.',
imageAsset.base64 || '',
token
);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image analysis failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Handle authentication errors
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
'Session Expired',
'Your login session has expired. Please login again.',
[{ text: 'OK' }]
);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error analyzing image: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
setSelectedImage(null);
}
const asset = result.assets[0];
setAttachedImage({
uri: asset.uri,
base64: asset.base64 || '',
});
}
};
@@ -408,8 +436,8 @@ export default function FlowScreen() {
// Clear current messages and storage for this role
setMessages([]);
if (user) {
storageService.saveCurrentChat(selectedRole.id, [], user.id);
if (user && selectedRole) {
storageService.saveCurrentChat(selectedRole?.id || '', [], user.id);
}
closeHistoryModal();
};
@@ -453,6 +481,112 @@ 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
const createdAsset = await assetsService.createAsset({
title: `Chat Summary - ${new Date().toLocaleDateString()}`,
private_key_shard: shareServer,
content_inner_encrypted: encryptedSummary,
}, token);
// Backup plaintext content locally
if (createdAsset && createdAsset.id && user?.id) {
await storageService.saveAssetBackup(createdAsset.id, generatedSummary, user.id);
}
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
// =============================================================================
@@ -525,9 +659,9 @@ export default function FlowScreen() {
<View style={styles.emptyIcon}>
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
</View>
<Text style={styles.emptyTitle}>Chatting with {selectedRole.name}</Text>
<Text style={styles.emptyTitle}>Chatting with {selectedRole?.name || 'AI'}</Text>
<Text style={styles.emptySubtitle}>
{selectedRole.description}
{selectedRole?.description || 'Loading AI Assistant...'}
</Text>
</View>
);
@@ -585,17 +719,32 @@ export default function FlowScreen() {
onPress={() => setShowRoleModal(true)}
activeOpacity={0.7}
>
{selectedRole && (
<Ionicons
name={selectedRole.icon as any}
name={(selectedRole?.icon || 'help-outline') as any}
size={16}
color={colors.nautical.teal}
/>
)}
<Text style={styles.headerRoleText} numberOfLines={1}>
{selectedRole.name}
{selectedRole?.name || 'Loading...'}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
</TouchableOpacity>
{/* Summary Button */}
<TouchableOpacity
style={[styles.historyButton, { marginRight: spacing.sm }]}
onPress={() => setShowSummaryConfirmModal(true)}
disabled={messages.length === 0 || isSummarizing}
>
<Ionicons
name="document-text-outline"
size={20}
color={messages.length === 0 || isSummarizing ? colors.flow.textSecondary : colors.flow.primary}
/>
</TouchableOpacity>
{/* History Button */}
<TouchableOpacity
style={styles.historyButton}
@@ -638,21 +787,35 @@ export default function FlowScreen() {
{/* Bottom Input Bar */}
<View style={styles.inputBarContainer}>
{/* Attached image preview (optional text then send) */}
{attachedImage && (
<View style={styles.attachedImageRow}>
<Image source={{ uri: attachedImage.uri }} style={styles.attachedImageThumb} resizeMode="cover" />
<Text style={styles.attachedImageHint} numberOfLines={1}></Text>
<TouchableOpacity
style={styles.attachedImageRemove}
onPress={() => setAttachedImage(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons name="close-circle" size={24} color={colors.flow.textSecondary} />
</TouchableOpacity>
</View>
)}
<View style={styles.inputBar}>
{/* Image attachment button */}
<TouchableOpacity
style={styles.inputBarButton}
style={[styles.inputBarButton, attachedImage && styles.inputBarButtonActive]}
onPress={handleAddImage}
activeOpacity={0.7}
>
<Feather name="image" size={22} color={colors.flow.textSecondary} />
<Feather name="image" size={22} color={attachedImage ? colors.nautical.teal : colors.flow.textSecondary} />
</TouchableOpacity>
{/* Text Input */}
<View style={styles.inputWrapper}>
<TextInput
style={styles.inputBarText}
placeholder="Message..."
placeholder={attachedImage ? '输入对图片的说明(可选)...' : 'Message...'}
placeholderTextColor={colors.flow.textSecondary}
value={newContent}
onChangeText={setNewContent}
@@ -661,8 +824,8 @@ export default function FlowScreen() {
/>
</View>
{/* Send or Voice button */}
{newContent.trim() || isSending ? (
{/* Send or Voice button: show send when has text or attached image */}
{newContent.trim() || attachedImage || isSending ? (
<TouchableOpacity
style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
onPress={handleSendMessage}
@@ -776,34 +939,34 @@ export default function FlowScreen() {
<Text style={styles.modalTitle}>Choose AI Assistant</Text>
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
{AI_CONFIG.ROLES.map((role) => (
{aiRoles.map((role) => (
<View key={role.id} style={styles.roleItemContainer}>
<View
style={[
styles.roleItem,
selectedRole.id === role.id && styles.roleItemActive
selectedRole?.id === role.id && styles.roleItemActive
]}
>
<TouchableOpacity
style={styles.roleSelectionArea}
onPress={() => {
setSelectedRole(role as any);
setSelectedRole(role);
setShowRoleModal(false);
}}
>
<View style={[
styles.roleItemIcon,
selectedRole.id === role.id && styles.roleItemIconActive
selectedRole?.id === role.id && styles.roleItemIconActive
]}>
<Ionicons
name={role.icon as any}
size={20}
color={selectedRole.id === role.id ? '#fff' : colors.nautical.teal}
color={selectedRole?.id === role.id ? '#fff' : colors.nautical.teal}
/>
</View>
<Text style={[
styles.roleItemName,
selectedRole.id === role.id && styles.roleItemNameActive
selectedRole?.id === role.id && styles.roleItemNameActive
]}>
{role.name}
</Text>
@@ -843,6 +1006,212 @@ export default function FlowScreen() {
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Confirmation Modal */}
<Modal
visible={showSummaryConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowSummaryConfirmModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowSummaryConfirmModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Generate Summary</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
Would you like to generate a summary for the current conversation?
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={() => setShowSummaryConfirmModal(false)}
>
<Text style={styles.cancelButtonText}>No</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={handleGenerateSummary}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Yes, Generate</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Result Modal */}
<Modal
visible={showSummaryResultModal}
transparent
animationType="slide"
onRequestClose={() => setShowSummaryResultModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowSummaryResultModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { maxHeight: '70%' }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Conversation Summary</Text>
<TouchableOpacity onPress={() => setShowSummaryResultModal(false)}>
<Ionicons name="close" size={24} color={colors.flow.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.summaryContainer} showsVerticalScrollIndicator={false}>
<View style={styles.summaryCard}>
<Text style={styles.summaryText}>{generatedSummary}</Text>
</View>
</ScrollView>
<View style={styles.summaryActions}>
<TouchableOpacity
style={[styles.actionButton, styles.saveToVaultButton]}
onPress={() => setShowVaultConfirmModal(true)}
disabled={isSavingToVault}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
{isSavingToVault ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<>
<Ionicons name="shield-checkmark-outline" size={20} color="#fff" />
<Text style={styles.confirmButtonText}>Save to Vault</Text>
</>
)}
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowSummaryResultModal(false)}
>
<Text style={styles.closeButtonText}>Done</Text>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Save to Vault Confirmation Modal */}
<Modal
visible={showVaultConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowVaultConfirmModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowVaultConfirmModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Save to Vault</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
Would you like to securely save this summary to your digital vault?
</Text>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.actionButton, styles.cancelButton]}
onPress={() => setShowVaultConfirmModal(false)}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton]}
onPress={handleSaveToVault}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Yes, Save</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Save Result Modal */}
<Modal
visible={showSaveResultModal}
transparent
animationType="fade"
onRequestClose={handleFinishSaveFlow}
>
<TouchableWithoutFeedback onPress={handleFinishSaveFlow}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[styles.modalContent, { paddingBottom: spacing.xl, alignItems: 'center' }]}>
<View style={styles.modalHandle} />
<View style={[
styles.resultIconContainer,
saveResult.success ? styles.successIconBg : styles.errorIconBg
]}>
<Ionicons
name={saveResult.success ? "checkmark-circle" : "alert-circle"}
size={64}
color={saveResult.success ? colors.nautical.teal : colors.nautical.coral}
/>
</View>
<Text style={styles.modalTitle}>
{saveResult.success ? 'Success!' : 'Oops!'}
</Text>
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base, textAlign: 'center' }]}>
{saveResult.message}
</Text>
<TouchableOpacity
style={[styles.actionButton, styles.confirmButton, { width: '100%' }]}
onPress={handleFinishSaveFlow}
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
style={styles.actionButtonGradient}
>
<Text style={styles.confirmButtonText}>Confirm</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Summary Loading Modal */}
<Modal
visible={isSummarizing}
transparent
animationType="fade"
>
<View style={styles.loadingOverlay}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.nautical.teal} />
<Text style={styles.loadingText}>Generating Summary...</Text>
</View>
</View>
</Modal>
</View>
);
}
@@ -1129,6 +1498,33 @@ const styles = StyleSheet.create({
paddingTop: spacing.sm,
backgroundColor: 'transparent',
},
attachedImageRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
borderRadius: borderRadius.lg,
padding: spacing.sm,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
gap: spacing.sm,
},
attachedImageThumb: {
width: 48,
height: 48,
borderRadius: borderRadius.md,
},
attachedImageHint: {
flex: 1,
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
},
attachedImageRemove: {
padding: spacing.xs,
},
inputBarButtonActive: {
backgroundColor: colors.nautical.paleAqua,
},
inputBar: {
flexDirection: 'row',
alignItems: 'flex-end',
@@ -1281,4 +1677,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',
},
});

View File

@@ -315,6 +315,7 @@ export default function MeScreen() {
await AsyncStorage.multiRemove([
vaultKeys.INITIALIZED,
vaultKeys.SHARE_DEVICE,
vaultKeys.MNEMONIC_PART_LOCAL,
]);
setResetVaultFeedback({
status: 'success',

View File

@@ -25,8 +25,10 @@ import { VaultAsset, VaultAssetType, Heir } from '../types';
import BiometricModal from '../components/common/BiometricModal';
import { useAuth } from '../context/AuthContext';
import { useVaultAssets } from '../hooks/useVaultAssets';
import { getVaultStorageKeys } from '../config';
import { getVaultStorageKeys, DEBUG_MODE } from '../config';
import { mnemonicToEntropy, splitSecret, serializeShare } from '../utils/sss';
import { storageService } from '../services/storage.service';
import { SentinelVault } from '@/utils/crypto_core';
// Asset type configuration with nautical theme
const assetTypeConfig: Record<VaultAssetType, { icon: string; iconType: 'ionicons' | 'feather' | 'material' | 'fontawesome5'; label: string }> = {
@@ -91,41 +93,6 @@ type HeirAssignment = {
heir: Heir;
};
// Mock data
// const initialAssets: VaultAsset[] = [
// {
// id: '1',
// type: 'private_key',
// label: 'ETH Main Wallet Key',
// createdAt: new Date('2024-01-10'),
// updatedAt: new Date('2024-01-10'),
// isEncrypted: true,
// },
// {
// id: '2',
// type: 'game_account',
// label: 'Steam Account Credentials',
// createdAt: new Date('2024-01-08'),
// updatedAt: new Date('2024-01-08'),
// isEncrypted: true,
// },
// {
// id: '3',
// type: 'document',
// label: 'Insurance Policy Scan',
// createdAt: new Date('2024-01-05'),
// updatedAt: new Date('2024-01-05'),
// isEncrypted: true,
// },
// {
// id: '4',
// type: 'will',
// label: 'Testament Draft v2',
// createdAt: new Date('2024-01-02'),
// updatedAt: new Date('2024-01-15'),
// isEncrypted: true,
// },
// ];
const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], size: number, color: string) => {
switch (config.iconType) {
@@ -148,11 +115,15 @@ export default function VaultScreen() {
setAssets,
refreshAssets,
createAsset: createVaultAsset,
deleteAsset: deleteVaultAsset,
assignAsset: assignVaultAsset,
isSealing,
createError: addError,
clearCreateError: clearAddError,
} = useVaultAssets(isUnlocked);
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedType, setSelectedType] = useState<VaultAssetType>('custom');
const [newLabel, setNewLabel] = useState('');
const [showUploadSuccess, setShowUploadSuccess] = useState(false);
@@ -169,8 +140,15 @@ export default function VaultScreen() {
const [showAddBiometric, setShowAddBiometric] = useState(false);
const [accountProvider, setAccountProvider] = useState<'bank' | 'steam' | 'facebook' | 'custom'>('bank');
const [showMnemonic, setShowMnemonic] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [showAssignErrorModal, setShowAssignErrorModal] = useState(false);
const [assignErrorMessage, setAssignErrorMessage] = useState('');
const [isAssigning, setIsAssigning] = useState(false);
const [heirEmail, setHeirEmail] = useState('');
const [showLegacyAssignCta, setShowLegacyAssignCta] = useState(false);
const [hasS0, setHasS0] = useState<boolean | null>(null);
const [backupContent, setBackupContent] = useState<string | null>(null);
const [isFetchingBackup, setIsFetchingBackup] = useState(false);
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
const [mnemonicParts, setMnemonicParts] = useState<string[][]>([]);
const [mnemonicStep, setMnemonicStep] = useState<1 | 2 | 3 | 4 | 5>(1);
@@ -210,7 +188,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 +196,7 @@ export default function VaultScreen() {
if (isUnlocked) {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
duration: 200,
useNativeDriver: true,
}).start();
}
@@ -230,12 +208,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,
}),
])
@@ -258,7 +236,7 @@ export default function VaultScreen() {
setProgressIndex(0);
progressAnim.setValue(0);
setTimeout(() => setShowMnemonic(true), 200);
AsyncStorage.setItem('sentinel_mnemonic_part_local', parts[0].join(' ')).catch(() => {
AsyncStorage.setItem(vaultKeys.MNEMONIC_PART_LOCAL, parts[0].join(' ')).catch(() => {
// Best-effort local store; UI remains available
});
};
@@ -380,9 +358,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);
@@ -486,6 +474,10 @@ export default function VaultScreen() {
setSelectedAsset(asset);
setShowDetail(true);
setShowKeyPreview(false);
if (DEBUG_MODE) {
console.log('[DEBUG] Vault Asset Details:', JSON.stringify(asset.rawData, null, 2));
}
};
const handleCloseDetail = () => {
@@ -493,6 +485,93 @@ export default function VaultScreen() {
setSelectedAsset(null);
setShowKeyPreview(false);
setShowGuardedBiometric(false);
setBackupContent(null);
};
const handleFetchBackup = async () => {
if (!selectedAsset || !user?.id) return;
setIsFetchingBackup(true);
try {
const content = await storageService.getAssetBackup(Number(selectedAsset.id), user.id);
if (content) {
setBackupContent(content);
} else {
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('No Backup Found', 'No local plaintext backup found for this treasure.');
}
}
} catch (error) {
console.error('Fetch backup error:', error);
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Error', 'Failed to retrieve local backup.');
}
} finally {
setIsFetchingBackup(false);
}
};
const handleDeleteAsset = async () => {
if (!selectedAsset || isDeleting) return;
setIsDeleting(true);
try {
const result = await deleteVaultAsset(Number(selectedAsset.id));
if (result.success) {
setShowDeleteConfirm(false);
handleCloseDetail();
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Success', 'Treasure removed from the vault.');
}
} else if (result.isUnauthorized) {
setShowDeleteConfirm(false);
handleCloseDetail();
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Unauthorized', 'Your session has expired. Please sign in again.');
}
} else if (result.error && typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Failed', result.error);
}
} catch (error) {
console.error('Delete error:', error);
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Error', 'An unexpected error occurred during deletion.');
}
} finally {
setIsDeleting(false);
}
};
const handleAssignHeir = async () => {
if (!selectedAsset || isAssigning) return;
if (!heirEmail.trim() || !heirEmail.includes('@')) {
Alert.alert('Invalid Email', 'Please enter a valid email address.');
return;
}
setIsAssigning(true);
try {
const result = await assignVaultAsset(Number(selectedAsset.id), heirEmail.trim());
if (result.success) {
setShowAssignModal(false);
setHeirEmail('');
Alert.alert('Success', `Asset assigned to ${heirEmail.trim()}`);
} else if (result.isUnauthorized) {
setShowAssignModal(false);
if (typeof Alert !== 'undefined' && Alert.alert) {
Alert.alert('Unauthorized', 'Your session has expired. Please sign in again.');
}
} else if (result.error) {
setAssignErrorMessage(result.error);
setShowAssignErrorModal(true);
}
} catch (error) {
console.error('Assign error:', error);
setAssignErrorMessage('An unexpected error occurred during assignment.');
setShowAssignErrorModal(true);
} finally {
setIsAssigning(false);
}
};
const handleGuardedAccess = () => {
@@ -502,6 +581,7 @@ export default function VaultScreen() {
const handleGuardedSuccess = () => {
setShowGuardedBiometric(false);
setShowKeyPreview(true);
handleFetchBackup();
};
const handleAddVerification = () => {
@@ -1437,6 +1517,12 @@ export default function VaultScreen() {
<Text style={styles.metaLabel}>{detailMetaLabel}</Text>
<Text style={styles.metaValue}>{detailMetaValue}</Text>
</View>
{selectedAsset?.heirEmail ? (
<View style={[styles.metaCard, { width: '100%' }]}>
<Text style={styles.metaLabel}>ASSIGNED HEIR</Text>
<Text style={styles.metaValue}>{selectedAsset.heirEmail}</Text>
</View>
) : null}
</View>
<View style={styles.actionGroup}>
@@ -1449,10 +1535,30 @@ export default function VaultScreen() {
<MaterialCommunityIcons name="file-lock" size={18} color={colors.vault.primary} />
<Text style={styles.actionText}>Export Cipher Pack</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionRow} activeOpacity={0.8}>
<MaterialCommunityIcons name="refresh" size={18} color={colors.vault.primary} />
<TouchableOpacity style={styles.actionRow} activeOpacity={0.7}>
<Ionicons name="notifications-outline" size={18} color={colors.vault.text} />
<Text style={styles.actionText}>Reset Sentinel Timer</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionRow, styles.assignActionRow]}
onPress={() => setShowAssignModal(true)}
activeOpacity={0.7}
>
<Ionicons name="person-add-outline" size={18} color={colors.vault.text} />
<Text style={styles.actionText}>
{selectedAsset?.heirEmail ? 'Change Heir' : 'Assign Heir'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionRow, styles.deleteActionRow]}
onPress={() => setShowDeleteConfirm(true)}
activeOpacity={0.7}
>
<Feather name="trash-2" size={18} color={colors.vault.warning} />
<Text style={[styles.actionText, styles.deleteActionText]}>Delete Treasure</Text>
</TouchableOpacity>
</View>
<View style={styles.guardCard}>
@@ -1463,6 +1569,7 @@ export default function VaultScreen() {
<Text style={styles.guardText}>
Plaintext access requires biometric verification and a memory rehearsal step.
</Text>
<TouchableOpacity
style={styles.guardButton}
onPress={handleGuardedAccess}
@@ -1470,10 +1577,13 @@ export default function VaultScreen() {
>
<Text style={styles.guardButtonText}>Begin Verification</Text>
</TouchableOpacity>
{showKeyPreview && (
<View style={styles.previewCard}>
<Text style={styles.previewLabel}>MNEMONIC SHARD (MASKED)</Text>
<Text style={styles.previewValue}>ocean-anchored-ember-veil</Text>
<Text style={styles.previewLabel}>LOCAL PLAINTEXT BACKUP</Text>
<Text style={styles.previewValue}>
{isFetchingBackup ? 'Fetching content...' : (backupContent || 'No local backup found for this treasure')}
</Text>
</View>
)}
</View>
@@ -1498,6 +1608,155 @@ export default function VaultScreen() {
isDark
/>
</Modal>
{/* Delete Confirmation Modal */}
<Modal
visible={showDeleteConfirm}
animationType="fade"
transparent
onRequestClose={() => setShowDeleteConfirm(false)}
>
<View style={styles.modalOverlay}>
<TouchableOpacity
style={styles.modalOverlayDismiss}
activeOpacity={1}
onPress={() => setShowDeleteConfirm(false)}
/>
<View style={styles.deleteConfirmContent}>
<View style={styles.deleteIconContainer}>
<Feather name="alert-triangle" size={32} color={colors.vault.warning} />
</View>
<Text style={styles.deleteTitle}>Remove Treasure?</Text>
<Text style={styles.deleteMessage}>
This action cannot be undone. The treasure will be permanently shredded from the deep vault.
</Text>
<View style={styles.deleteButtons}>
<TouchableOpacity
style={styles.deleteCancelButton}
onPress={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
>
<Text style={styles.deleteCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteConfirmButton}
onPress={handleDeleteAsset}
disabled={isDeleting}
>
{isDeleting ? (
<Text style={styles.deleteConfirmText}>Shredding...</Text>
) : (
<Text style={styles.deleteConfirmText}>Confirm Delete</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Assign Heir Modal */}
<Modal
visible={showAssignModal}
animationType="slide"
transparent
onRequestClose={() => setShowAssignModal(false)}
>
<View style={styles.modalOverlay}>
<TouchableOpacity
style={styles.modalOverlayDismiss}
activeOpacity={1}
onPress={() => setShowAssignModal(false)}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.assignModalContainer}
>
<View style={styles.assignModalContent}>
<View style={styles.assignModalHeader}>
<View style={styles.assignIconGlow}>
<Ionicons name="person-add" size={32} color={colors.vault.primary} />
</View>
<Text style={styles.assignTitle}>Designate Heir</Text>
<Text style={styles.assignSubtitle}>
Enter the email address of the person who should inherit this treasure.
</Text>
</View>
<View style={styles.assignInputContainer}>
<Text style={styles.inputLabel}>HEIR EMAIL ADDRESS</Text>
<TextInput
style={styles.assignInput}
value={heirEmail}
onChangeText={setHeirEmail}
placeholder="name@example.com"
placeholderTextColor={colors.vault.textSecondary}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.assignButtons}>
<TouchableOpacity
style={styles.assignCancelButton}
onPress={() => {
setShowAssignModal(false);
setHeirEmail('');
}}
disabled={isAssigning}
>
<Text style={styles.assignCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.assignConfirmButton}
onPress={handleAssignHeir}
disabled={isAssigning || !heirEmail.trim()}
>
<LinearGradient
colors={[colors.vault.primary, colors.vault.secondary]}
style={styles.assignConfirmGradient}
>
{isAssigning ? (
<Text style={styles.assignConfirmText}>Assigning...</Text>
) : (
<Text style={styles.assignConfirmText}>Confirm Heir</Text>
)}
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
{/* Assign Heir Error Modal */}
<Modal
visible={showAssignErrorModal}
animationType="fade"
transparent
onRequestClose={() => setShowAssignErrorModal(false)}
>
<View style={styles.modalOverlay}>
<TouchableOpacity
style={styles.modalOverlayDismiss}
activeOpacity={1}
onPress={() => setShowAssignErrorModal(false)}
/>
<View style={styles.errorModalContent}>
<View style={styles.errorIconContainer}>
<MaterialCommunityIcons name="alert-circle-outline" size={32} color={colors.vault.warning} />
</View>
<Text style={styles.errorTitle}>Assignment Failed</Text>
<Text style={styles.errorMessage}>{assignErrorMessage}</Text>
<TouchableOpacity
style={styles.errorCloseButton}
onPress={() => setShowAssignErrorModal(false)}
>
<Text style={styles.errorCloseButtonText}>Dismiss</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
@@ -1766,6 +2025,73 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(26, 58, 74, 0.8)',
justifyContent: 'flex-end',
},
modalOverlayDismiss: {
...StyleSheet.absoluteFillObject,
},
deleteConfirmContent: {
backgroundColor: colors.vault.cardBackground,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.xl,
padding: spacing.xl,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
alignItems: 'center',
marginBottom: spacing.xxl + spacing.lg,
},
deleteIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: `${colors.vault.warning}20`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
},
deleteTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
marginBottom: spacing.sm,
fontFamily: typography.fontFamily.serif,
},
deleteMessage: {
fontSize: typography.fontSize.sm,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.sm * 1.5,
marginBottom: spacing.xl,
},
deleteButtons: {
flexDirection: 'row',
gap: spacing.md,
width: '100%',
},
deleteCancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
deleteCancelText: {
color: colors.vault.textSecondary,
fontSize: typography.fontSize.base,
fontWeight: '600',
},
deleteConfirmButton: {
flex: 2,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: colors.vault.warning,
alignItems: 'center',
},
deleteConfirmText: {
color: colors.vault.text,
fontSize: typography.fontSize.base,
fontWeight: '700',
},
modalContent: {
backgroundColor: colors.nautical.cream,
borderTopLeftRadius: borderRadius.xxl,
@@ -2570,8 +2896,204 @@ const styles = StyleSheet.create({
borderColor: colors.sentinel.cardBorder,
},
mnemonicSecondaryText: {
color: colors.sentinel.text,
fontWeight: '700',
letterSpacing: typography.letterSpacing.wide,
},
deleteActionRow: {
backgroundColor: 'rgba(229, 115, 115, 0.08)',
borderColor: 'rgba(229, 115, 115, 0.2)',
marginTop: spacing.sm,
},
deleteActionText: {
color: colors.vault.warning,
},
confirmModalOverlay: {
flex: 1,
backgroundColor: 'rgba(11, 20, 24, 0.85)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
confirmModalContent: {
width: '100%',
backgroundColor: colors.vault.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
gap: spacing.md,
},
confirmModalTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
textAlign: 'center',
},
confirmModalText: {
fontSize: typography.fontSize.base,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: 22,
},
confirmModalButtons: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.sm,
},
confirmModalButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
},
confirmCancelButton: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
confirmDeleteButton: {
backgroundColor: colors.vault.warning,
},
confirmCancelText: {
color: colors.vault.textSecondary,
},
assignActionRow: {
// Optional specific styling
},
assignModalContainer: {
backgroundColor: colors.vault.cardBackground,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.xl,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
overflow: 'hidden',
marginBottom: spacing.xxl,
},
assignModalContent: {
padding: spacing.xl,
},
assignModalHeader: {
alignItems: 'center',
marginBottom: spacing.xl,
},
assignIconGlow: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: `${colors.vault.primary}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.md,
},
assignTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
marginBottom: spacing.xs,
fontFamily: typography.fontFamily.serif,
},
assignSubtitle: {
fontSize: typography.fontSize.sm,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.sm * 1.5,
},
assignInputContainer: {
marginBottom: spacing.xl,
},
inputLabel: {
fontSize: typography.fontSize.xs,
color: colors.vault.textSecondary,
fontWeight: '700',
letterSpacing: 1,
marginBottom: spacing.sm,
},
assignInput: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: borderRadius.lg,
padding: spacing.md,
fontSize: typography.fontSize.base,
color: colors.vault.text,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
assignButtons: {
flexDirection: 'row',
gap: spacing.md,
},
assignCancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
assignCancelText: {
color: colors.vault.textSecondary,
fontSize: typography.fontSize.base,
fontWeight: '600',
},
assignConfirmButton: {
flex: 2,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
assignConfirmGradient: {
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
assignConfirmText: {
color: colors.vault.text,
fontSize: typography.fontSize.base,
fontWeight: '700',
},
errorModalContent: {
backgroundColor: colors.vault.cardBackground,
marginHorizontal: spacing.lg,
borderRadius: borderRadius.xl,
padding: spacing.xl,
borderWidth: 1,
borderColor: colors.vault.cardBorder,
alignItems: 'center',
marginBottom: spacing.xxl,
},
errorIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: `${colors.vault.warning}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
},
errorTitle: {
fontSize: typography.fontSize.lg,
color: colors.vault.text,
fontWeight: '700',
marginBottom: spacing.sm,
fontFamily: typography.fontFamily.serif,
},
errorMessage: {
fontSize: typography.fontSize.sm,
color: colors.vault.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.sm * 1.5,
marginBottom: spacing.xl,
},
errorCloseButton: {
width: '100%',
paddingVertical: spacing.md,
borderRadius: borderRadius.lg,
backgroundColor: colors.vault.warning,
alignItems: 'center',
},
errorCloseButtonText: {
color: colors.vault.text,
fontSize: typography.fontSize.base,
fontWeight: '700',
},
});

View File

@@ -12,6 +12,8 @@ import {
getApiHeaders,
logApiDebug,
} from '../config';
import { AIRole } from '../types';
import { trimInternalMessages } from '../utils/token_utils';
// =============================================================================
// Type Definitions
@@ -219,10 +221,13 @@ export const aiService = {
const errorText = await response.text();
logApiDebug('AI Image Error Response', errorText);
let errorDetail = 'AI image request failed';
let errorDetail: string = 'AI image request failed';
try {
const errorData = JSON.parse(errorText);
errorDetail = errorData.detail || errorDetail;
const d = errorData.detail;
if (typeof d === 'string') errorDetail = d;
else if (Array.isArray(d) && d[0]?.msg) errorDetail = d.map((e: { msg?: string }) => e.msg).join('; ');
else if (d && typeof d === 'object') errorDetail = JSON.stringify(d);
} catch {
errorDetail = errorText || errorDetail;
}
@@ -241,4 +246,86 @@ export const aiService = {
throw error;
}
},
/**
* Summarize a chat conversation
* @param messages - Array of chat messages
* @param token - JWT token for authentication
* @returns AI summary text
*/
async summarizeChat(messages: AIMessage[], token?: string): Promise<string> {
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);
});
}
// Enforce token limit (10,000 tokens)
const trimmedMessages = trimInternalMessages(messages);
const historicalMessages = trimmedMessages.map(msg => ({
role: msg.role,
content: msg.content,
}));
const summaryPrompt: AIMessage = {
role: 'user',
content: 'Please provide a concise summary of the conversation above in English. 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';
},
/**
* Fetch available AI roles from backend
* @param token - Optional JWT token for authentication
* @returns Array of AI roles
*/
async getAIRoles(token?: string): Promise<AIRole[]> {
if (NO_BACKEND_MODE) {
logApiDebug('AI Roles', 'Using mock roles');
return [...AI_CONFIG.ROLES];
}
if (!token) {
console.warn('[AI Service] getAIRoles called without token, falling back to static roles');
return [...AI_CONFIG.ROLES];
}
const url = buildApiUrl(API_ENDPOINTS.AI.GET_ROLES);
const headers = getApiHeaders(token);
logApiDebug('AI Roles Request', {
url,
hasToken: !!token,
headers: {
...headers,
Authorization: headers.Authorization ? `${headers.Authorization.substring(0, 15)}...` : 'MISSING'
}
});
try {
const response = await fetch(url, {
method: 'GET',
headers,
});
if (!response.ok) {
console.error(`[AI Service] Failed to fetch AI roles: ${response.status}. Falling back to static roles.`);
return [...AI_CONFIG.ROLES];
}
const data = await response.json();
logApiDebug('AI Roles Success', { count: data.length });
return data;
} catch (error) {
console.error('[AI Service] Fetch AI roles error:', error);
// Fallback to config roles if API fails for better UX
return [...AI_CONFIG.ROLES];
}
},
};

View File

@@ -23,6 +23,7 @@ export interface Asset {
author_id: number;
private_key_shard: string;
content_outer_encrypted: string;
heir_email?: string;
}
export interface AssetCreate {
@@ -45,7 +46,7 @@ export interface AssetClaimResponse {
export interface AssetAssign {
asset_id: number;
heir_name: string;
heir_email: string;
}
// =============================================================================
@@ -59,6 +60,7 @@ const MOCK_ASSETS: Asset[] = [
author_id: MOCK_CONFIG.USER.id,
private_key_shard: 'mock_shard_1',
content_outer_encrypted: 'mock_encrypted_content_1',
heir_email: 'heir@example.com',
},
{
id: 2,
@@ -217,7 +219,7 @@ export const assetsService = {
logApiDebug('Assign Asset', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({ message: `Asset assigned to ${assignment.heir_name}` });
resolve({ message: `Asset assigned to ${assignment.heir_email}` });
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
@@ -245,4 +247,44 @@ export const assetsService = {
throw error;
}
},
/**
* Delete an asset
* @param assetId - ID of the asset to delete
* @param token - JWT token for authentication
* @returns Success message
*/
async deleteAsset(assetId: number, token: string): Promise<{ message: string }> {
if (NO_BACKEND_MODE) {
logApiDebug('Delete Asset', `Using mock mode for ID: ${assetId}`);
return new Promise((resolve) => {
setTimeout(() => {
resolve({ message: 'Asset deleted successfully' });
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
const url = buildApiUrl(API_ENDPOINTS.ASSETS.DELETE);
logApiDebug('Delete Asset URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(token),
body: JSON.stringify({ asset_id: assetId }),
});
logApiDebug('Delete Asset Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to delete asset');
}
return await response.json();
} catch (error) {
console.error('Delete asset error:', error);
throw error;
}
},
};

View File

@@ -0,0 +1,96 @@
/**
* LangGraph Service
*
* Implements AI chat logic using LangGraph.js for state management
* and context handling.
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { BaseMessage, HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
import { aiService } from "./ai.service";
import { trimLangChainMessages } from "../utils/token_utils";
// =============================================================================
// Settings
// =============================================================================
/**
* Define the State using Annotation (Standard for latest LangGraph.js)
*/
const GraphAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
});
// =============================================================================
// Graph Definition
// =============================================================================
/**
* The main node that calls our existing AI API
*/
async function callModel(state: typeof GraphAnnotation.State, config: any) {
const { messages } = state;
const { token } = config.configurable || {};
// 1. Trim messages to stay under token limit
const trimmedMessages = trimLangChainMessages(messages);
// 2. Convert LangChain messages to our internal AIMessage format for the API
const apiMessages = trimmedMessages.map(m => {
let role: 'system' | 'user' | 'assistant' = 'user';
const type = (m as any)._getType?.() || (m instanceof SystemMessage ? 'system' : m instanceof HumanMessage ? 'human' : m instanceof AIMessage ? 'ai' : 'user');
if (type === 'system') role = 'system';
else if (type === 'human') role = 'user';
else if (type === 'ai') role = 'assistant';
return {
role,
content: m.content.toString()
};
});
// 3. Call the proxy service
const response = await aiService.chat(apiMessages, token);
const content = response.choices[0]?.message?.content || "No response generated";
// 4. Return the new message to satisfy the Graph (it will be appended due to reducer)
return {
messages: [new AIMessage(content)]
};
}
// =============================================================================
// Service Export
// =============================================================================
export const langGraphService = {
/**
* Run the chat graph with history
*/
async execute(
currentMessages: BaseMessage[],
userToken: string,
): Promise<string> {
// Define the graph
const workflow = new StateGraph(GraphAnnotation)
.addNode("agent", callModel)
.addEdge(START, "agent")
.addEdge("agent", END);
const app = workflow.compile();
// Execute the graph
const result = await app.invoke(
{ messages: currentMessages },
{ configurable: { token: userToken } }
);
// Return the content of the last message (the AI response)
const lastMsg = result.messages[result.messages.length - 1];
return lastMsg.content.toString();
}
};

View File

@@ -14,6 +14,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEYS = {
CHAT_HISTORY: '@sentinel:chat_history',
CURRENT_MESSAGES: '@sentinel:current_messages',
ASSET_BACKUP: '@sentinel:asset_backup',
} as const;
// =============================================================================
@@ -115,6 +116,32 @@ export const storageService = {
} catch (e) {
console.error('Error clearing storage data:', e);
}
},
/**
* Save the plaintext backup of an asset locally
*/
async saveAssetBackup(assetId: number, content: string, userId: string | number): Promise<void> {
try {
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
await AsyncStorage.setItem(key, content);
console.log(`[Storage] Saved asset backup for user ${userId}, asset ${assetId}`);
} catch (e) {
console.error(`Error saving asset backup for asset ${assetId}:`, e);
}
},
/**
* Retrieve the plaintext backup of an asset locally
*/
async getAssetBackup(assetId: number, userId: string | number): Promise<string | null> {
try {
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
return await AsyncStorage.getItem(key);
} catch (e) {
console.error(`Error getting asset backup for asset ${assetId}:`, e);
return null;
}
}
};

View File

@@ -28,6 +28,8 @@ export interface VaultAsset {
createdAt: Date;
updatedAt: Date;
isEncrypted: boolean;
heirEmail?: string;
rawData?: any; // For debug logging
}
// Sentinel Types
@@ -102,3 +104,13 @@ export interface LoginResponse {
token_type: string;
user: User;
}
// AI Types
export interface AIRole {
id: string;
name: string;
description: string;
systemPrompt: string;
icon: string;
iconFamily: string;
}

View File

@@ -0,0 +1,22 @@
/**
* Mock for Node.js async_hooks
* Used to fix LangGraph.js compatibility with React Native
*/
export class AsyncLocalStorage {
disable() { }
getStore() {
return undefined;
}
run(store: any, callback: (...args: any[]) => any, ...args: any[]) {
return callback(...args);
}
exit(callback: (...args: any[]) => any, ...args: any[]) {
return callback(...args);
}
enterWith(store: any) { }
}
export default {
AsyncLocalStorage,
};

202
src/utils/crypto_core.ts Normal file
View File

@@ -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<Buffer> {
// 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 "【解密失败】:密钥错误或数据被篡改";
}
}
}

View File

@@ -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<string, any> = {
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);
}

76
src/utils/token_utils.ts Normal file
View File

@@ -0,0 +1,76 @@
/**
* Token Utilities
*
* Shared logic for trimming messages to stay within token limits.
*/
import { BaseMessage, SystemMessage } from "@langchain/core/messages";
import { AIMessage as ServiceAIMessage } from "../services/ai.service";
export const TOKEN_LIMIT = 10000;
const CHARS_PER_TOKEN = 3; // Conservative estimate: 1 token ≈ 3 chars
export const MAX_CHARS = TOKEN_LIMIT * CHARS_PER_TOKEN;
/**
* Trims LangChain messages to fit within token limit
*/
export function trimLangChainMessages(messages: BaseMessage[]): BaseMessage[] {
let totalLength = 0;
const trimmed: BaseMessage[] = [];
// Always keep the system message if it's at the start
let systemMsg: BaseMessage | null = null;
if (messages.length > 0 && (messages[0] instanceof SystemMessage || (messages[0] as any)._getType?.() === 'system')) {
systemMsg = messages[0];
totalLength += systemMsg.content.toString().length;
}
// Iterate backwards and add messages until we hit the char limit
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
const msg = messages[i];
const len = msg.content.toString().length;
if (totalLength + len > MAX_CHARS) break;
trimmed.unshift(msg);
totalLength += len;
}
if (systemMsg) {
trimmed.unshift(systemMsg);
}
return trimmed;
}
/**
* Trims internal AIMessage format messages to fit within token limit
*/
export function trimInternalMessages(messages: ServiceAIMessage[]): ServiceAIMessage[] {
let totalLength = 0;
const trimmed: ServiceAIMessage[] = [];
// Always keep the system message if it's at the start
let systemMsg: ServiceAIMessage | null = null;
if (messages.length > 0 && messages[0].role === 'system') {
systemMsg = messages[0];
totalLength += systemMsg.content.length;
}
// Iterate backwards and add messages until we hit the char limit
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
const msg = messages[i];
const len = msg.content.length;
if (totalLength + len > MAX_CHARS) break;
trimmed.unshift(msg);
totalLength += len;
}
if (systemMsg) {
trimmed.unshift(systemMsg);
}
return trimmed;
}

View File

@@ -14,8 +14,13 @@ export interface ApiAsset {
id: number;
title: string;
type?: string;
author_id?: number;
private_key_shard?: string;
content_outer_encrypted?: string;
created_at?: string;
updated_at?: string;
heir_id?: number;
heir_email?: string;
}
// -----------------------------------------------------------------------------
@@ -31,6 +36,8 @@ export const VAULT_ASSET_TYPES: VaultAssetType[] = [
'custom',
];
export const initialVaultAssets: VaultAsset[] = [];
// -----------------------------------------------------------------------------
// Mapping
// -----------------------------------------------------------------------------
@@ -50,6 +57,8 @@ export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset {
createdAt: api.created_at ? new Date(api.created_at) : new Date(),
updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(),
isEncrypted: true,
heirEmail: api.heir_email,
rawData: api,
};
}
@@ -60,41 +69,3 @@ export function mapApiAssetsToVaultAssets(apiList: ApiAsset[]): VaultAsset[] {
return apiList.map(mapApiAssetToVaultAsset);
}
// -----------------------------------------------------------------------------
// Mock / initial data (fallback when API is unavailable)
// -----------------------------------------------------------------------------
export const initialVaultAssets: VaultAsset[] = [
{
id: '1',
type: 'private_key',
label: 'ETH Main Wallet Key',
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-10'),
isEncrypted: true,
},
{
id: '2',
type: 'game_account',
label: 'Steam Account Credentials',
createdAt: new Date('2024-01-08'),
updatedAt: new Date('2024-01-08'),
isEncrypted: true,
},
{
id: '3',
type: 'document',
label: 'Insurance Policy Scan',
createdAt: new Date('2024-01-05'),
updatedAt: new Date('2024-01-05'),
isEncrypted: true,
},
{
id: '4',
type: 'will',
label: 'Testament Draft v2',
createdAt: new Date('2024-01-02'),
updatedAt: new Date('2024-01-15'),
isEncrypted: true,
},
];