diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..329ca54 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * React hooks for Sentinel + */ + +export { useVaultAssets } from './useVaultAssets'; +export type { CreateAssetResult, UseVaultAssetsReturn } from './useVaultAssets'; diff --git a/src/hooks/useVaultAssets.ts b/src/hooks/useVaultAssets.ts new file mode 100644 index 0000000..ea51fca --- /dev/null +++ b/src/hooks/useVaultAssets.ts @@ -0,0 +1,160 @@ +/** + * useVaultAssets: Encapsulates /assets/get and /assets/create for VaultScreen. + * - Fetches assets when vault is unlocked and token exists. + * - Exposes createAsset with 401/network error handling and list refresh on success. + */ + +import { useState, useEffect, useCallback } from 'react'; +import * as bip39 from 'bip39'; +import { useAuth } from '../context/AuthContext'; +import { assetsService } from '../services/assets.service'; +import { createAssetPayload } from '../services/vault.service'; +import { + initialVaultAssets, + mapApiAssetsToVaultAssets, + type ApiAsset, +} from '../utils/vaultAssets'; +import type { VaultAsset } from '../types'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +export interface CreateAssetResult { + success: boolean; + isUnauthorized?: boolean; + error?: string; +} + +export interface UseVaultAssetsReturn { + /** Current list (mock until API succeeds) */ + assets: VaultAsset[]; + /** Replace list (e.g. after external refresh) */ + setAssets: React.Dispatch>; + /** Refetch from GET /assets/get */ + refreshAssets: () => Promise; + /** Create asset via POST /assets/create; on success refreshes list */ + createAsset: (params: { title: string; content: string }) => Promise; + /** True while create request is in flight */ + isSealing: boolean; + /** Error message from last create failure (non-401) */ + createError: string | null; + /** Clear createError */ + clearCreateError: () => void; +} + +// ----------------------------------------------------------------------------- +// Hook +// ----------------------------------------------------------------------------- + +/** + * 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 [assets, setAssets] = useState(initialVaultAssets); + const [isSealing, setIsSealing] = useState(false); + const [createError, setCreateError] = useState(null); + + const refreshAssets = useCallback(async () => { + if (!token) return; + try { + const list = await assetsService.getMyAssets(token); + if (Array.isArray(list)) { + setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[])); + } + } catch { + // Keep current assets (mock or previous fetch) + } + }, [token]); + + // Fetch list when unlocked and token exists + useEffect(() => { + if (!isUnlocked || !token) return; + let cancelled = false; + assetsService + .getMyAssets(token) + .then((list) => { + if (!cancelled && Array.isArray(list)) { + setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[])); + } + }) + .catch(() => { + // Keep initial (mock) assets + }); + return () => { + cancelled = true; + }; + }, [isUnlocked, token]); + + const createAsset = useCallback( + async ({ + title, + content, + }: { + title: string; + content: string; + }): Promise => { + if (!token) { + return { success: false, error: 'Not logged in.' }; + } + setIsSealing(true); + setCreateError(null); + try { + const wordList = bip39.wordlists.english; + const payload = await createAssetPayload( + title.trim(), + content.trim(), + wordList, + 'note', + 0 + ); + await assetsService.createAsset( + { + title: payload.title, + private_key_shard: payload.private_key_shard, + content_inner_encrypted: payload.content_inner_encrypted, + }, + 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 create.'); + 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 (see API_BASE_URL in config).' + : rawMessage; + setCreateError(friendlyMessage); + return { success: false, error: friendlyMessage }; + } finally { + setIsSealing(false); + } + }, + [token, refreshAssets, signOut] + ); + + const clearCreateError = useCallback(() => setCreateError(null), []); + + return { + assets, + setAssets, + refreshAssets, + createAsset, + isSealing, + createError, + clearCreateError, + }; +} diff --git a/src/screens/VaultScreen.tsx b/src/screens/VaultScreen.tsx index 33bc48d..0f629f1 100644 --- a/src/screens/VaultScreen.tsx +++ b/src/screens/VaultScreen.tsx @@ -24,6 +24,7 @@ import { colors, typography, spacing, borderRadius, shadows } from '../theme/col import { VaultAsset, VaultAssetType, Heir } from '../types'; import BiometricModal from '../components/common/BiometricModal'; import { useAuth } from '../context/AuthContext'; +import { useVaultAssets } from '../hooks/useVaultAssets'; // Asset type configuration with nautical theme const assetTypeConfig: Record = { @@ -89,40 +90,40 @@ type HeirAssignment = { }; // 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 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) { @@ -140,7 +141,15 @@ const renderAssetTypeIcon = (config: typeof assetTypeConfig[VaultAssetType], siz export default function VaultScreen() { const [isUnlocked, setIsUnlocked] = useState(false); const [showBiometric, setShowBiometric] = useState(false); - const [assets, setAssets] = useState(initialAssets); + const { + assets, + setAssets, + refreshAssets, + createAsset: createVaultAsset, + isSealing, + createError: addError, + clearCreateError: clearAddError, + } = useVaultAssets(isUnlocked); const [showAddModal, setShowAddModal] = useState(false); const [selectedType, setSelectedType] = useState('custom'); const [newLabel, setNewLabel] = useState(''); @@ -170,8 +179,9 @@ export default function VaultScreen() { const [replaceQuery, setReplaceQuery] = useState(''); const [progressIndex, setProgressIndex] = useState(0); const [progressAnim] = useState(new Animated.Value(0)); - const { user } = useAuth(); + const { user, token } = useAuth(); const [isCapturing, setIsCapturing] = useState(false); + const [treasureContent, setTreasureContent] = useState(''); const mnemonicRef = useRef(null); const progressTimerRef = useRef | null>(null); @@ -375,29 +385,52 @@ export default function VaultScreen() { setAccountProvider('bank'); }; - const handleAddAsset = () => { - if (!newLabel.trim()) return; + const handleAddAsset = async () => { + if (!newLabel.trim() || !treasureContent.trim()) return; if (!addVerified) return; if (selectedType === 'private_key' && !rehearsalConfirmed) return; + if (!token) { + Alert.alert('Not logged in', 'Please sign in first to add a Treasure.'); + return; + } - const newAsset: VaultAsset = { - id: Date.now().toString(), - type: selectedType, - label: newLabel, - createdAt: new Date(), - updatedAt: new Date(), - isEncrypted: true, - }; + const result = await createVaultAsset({ + title: newLabel.trim(), + content: treasureContent.trim(), + }); - setAssets([newAsset, ...assets]); - setNewLabel(''); - setSelectedType('custom'); - setShowAddModal(false); - setAddVerified(false); - setRehearsalConfirmed(false); - - setShowUploadSuccess(true); - setTimeout(() => setShowUploadSuccess(false), 2500); + if (result.success) { + setNewLabel(''); + setTreasureContent(''); + setSelectedType('custom'); + setAddVerified(false); + setRehearsalConfirmed(false); + setShowAddModal(false); + clearAddError(); + setShowUploadSuccess(true); + setTimeout(() => setShowUploadSuccess(false), 2500); + if (typeof Alert !== 'undefined' && Alert.alert) { + Alert.alert('Success', 'Treasure sealed and saved successfully.'); + } + return; + } + + if (result.isUnauthorized) { + setShowAddModal(false); + clearAddError(); + if (typeof Alert !== 'undefined' && Alert.alert) { + Alert.alert( + 'Unauthorized', + 'Your session has expired or you are not logged in. Please sign in again.', + [{ text: 'OK' }] + ); + } + return; + } + + if (result.error && typeof Alert !== 'undefined' && Alert.alert) { + Alert.alert('Failed', result.error); + } }; const formatDate = (date: Date) => { @@ -485,7 +518,9 @@ export default function VaultScreen() { ? '12/24 Words' : selectedConfig?.label || '--'; const canSeal = !!newLabel.trim() + && !!treasureContent.trim() && addVerified + && !isSealing && (selectedType !== 'private_key' || rehearsalConfirmed); const mnemonicModal = ( @@ -499,10 +534,10 @@ export default function VaultScreen() { style={styles.mnemonicOverlay} behavior={Platform.OS === 'ios' ? 'padding' : undefined} > + + ); @@ -963,6 +999,7 @@ export default function VaultScreen() { style={styles.addButton} onPress={() => { resetAddFlow(); + clearAddError(); setShowAddModal(true); }} activeOpacity={0.9} @@ -1005,7 +1042,7 @@ export default function VaultScreen() { - {['Type', 'Method', 'Verify'].map((label, index) => { + {['Title', 'Content', 'Verify'].map((label, index) => { const stepIndex = index + 1; const isActive = addStep === stepIndex; const isDone = addStep > stepIndex; @@ -1034,6 +1071,14 @@ export default function VaultScreen() { {addStep === 1 && ( <> + TREASURE TITLE + TREASURE TYPE + CONTENT + + + + + Data is encrypted on-device. Plaintext is shredded after sealing. + + )} - {selectedType === 'game_account' && ( <> ACCOUNT PROVIDER @@ -1135,24 +1196,16 @@ export default function VaultScreen() { Open App to Login + TREASURE NAME + )} - - TREASURE NAME - - - - - - Data is encrypted on-device. Plaintext is shredded after sealing. - - )} @@ -1201,14 +1254,24 @@ export default function VaultScreen() { )} + {addError ? ( + + + {addError} + + ) : null} + { if (addStep === 1) { setShowAddModal(false); + setTreasureContent(''); + clearAddError(); } else { setAddStep(addStep - 1); + clearAddError(); } }} > @@ -1248,7 +1311,7 @@ export default function VaultScreen() { end={{ x: 1, y: 0 }} > - Seal Treasure + {isSealing ? 'Sealing...' : 'Seal Treasure'} )} @@ -1344,7 +1407,7 @@ export default function VaultScreen() { Export Cipher Pack - + Reset Sentinel Timer @@ -1835,6 +1898,10 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: colors.nautical.lightMint, }, + inputMultiline: { + minHeight: 120, + paddingTop: spacing.base, + }, encryptionNote: { flexDirection: 'row', alignItems: 'center', @@ -1850,6 +1917,23 @@ const styles = StyleSheet.create({ color: colors.nautical.teal, lineHeight: typography.fontSize.sm * 1.4, }, + addErrorBox: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(194, 65, 12, 0.12)', + borderRadius: borderRadius.lg, + padding: spacing.md, + marginBottom: spacing.md, + gap: spacing.sm, + borderWidth: 1, + borderColor: 'rgba(194, 65, 12, 0.3)', + }, + addErrorText: { + flex: 1, + fontSize: typography.fontSize.sm, + color: '#c2410c', + lineHeight: typography.fontSize.sm * 1.4, + }, modalButtons: { flexDirection: 'row', gap: spacing.md, diff --git a/src/services/assets.service.ts b/src/services/assets.service.ts index 84e30de..c0e3197 100644 --- a/src/services/assets.service.ts +++ b/src/services/assets.service.ts @@ -142,11 +142,16 @@ export const assetsService = { body: JSON.stringify(asset), }); - logApiDebug('Create Asset Response Status', response.status); + const responseStatus = response.status; + logApiDebug('Create Asset Response Status', responseStatus); if (!response.ok) { const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || 'Failed to create asset'); + const detail = errorData.detail || 'Failed to create asset'; + const message = responseStatus === 401 ? `Unauthorized (401): ${detail}` : detail; + const err = new Error(message) as Error & { status?: number }; + err.status = responseStatus; + throw err; } return await response.json(); diff --git a/src/services/index.ts b/src/services/index.ts index 1eb48a7..91af557 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -23,3 +23,9 @@ export { type DeclareGualeRequest, type DeclareGualeResponse } from './admin.service'; +export { + createVaultPayload, + createAssetPayload, + type CreateVaultPayloadResult, + type CreateAssetPayloadResult, +} from './vault.service'; diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts new file mode 100644 index 0000000..b0e2dad --- /dev/null +++ b/src/services/vault.service.ts @@ -0,0 +1,81 @@ +/** + * Vault Service: 为 /assets/create 生成 private_key_shard 与 content_inner_encrypted + * + * 流程(与后端 test_scenario / SentinelVault 一致): + * 1. 用 SSS 生成助记词并分片 → 选一个分片作为 private_key_shard(存后端,继承时返回) + * 2. 用助记词派生 AES 密钥,对明文做 AES-GCM 加密 → content_inner_encrypted(hex 字符串) + * + * 使用方式:在任意页面调用 createVaultPayload(plaintext, wordList),得到可直接传给 assetsService.createAsset 的字段。 + */ + +import { + generateVaultKeys, + serializeShare, + type SSSShare, + type VaultKeyData, +} from '../utils/sss'; +import { deriveKey, encryptDataGCM, bytesToHex } from '../utils/vaultCrypto'; + +export interface CreateVaultPayloadResult { + /** 传给后端的 private_key_shard(存一个 SSS 分片的序列化字符串,如云端分片) */ + private_key_shard: string; + /** 传给后端的 content_inner_encrypted(AES-GCM 密文的 hex) */ + content_inner_encrypted: string; + /** 本次生成的助记词(用户需妥善保管,恢复时需任意 2 个分片) */ + mnemonic: string[]; + /** 三个分片:device / cloud / heir,可与后端返回的 server_shard 组合恢复助记词 */ + shares: SSSShare[]; +} + +export interface CreateAssetPayloadResult { + title: string; + type: string; + private_key_shard: string; + content_inner_encrypted: string; +} + +/** + * 生成金库:助记词 + SSS 分片 + 内层加密内容 + * @param plaintext 要加密的明文(如遗产说明、账号密码等) + * @param wordList 助记词词表(与 sss 使用的词表一致) + * @param shareIndexForServer 哪个分片存后端,0=device, 1=cloud, 2=heir,默认 1(云端) + */ +export async function createVaultPayload( + plaintext: string, + wordList: readonly string[], + shareIndexForServer: 0 | 1 | 2 = 1 +): Promise { + const { mnemonic, shares }: VaultKeyData = generateVaultKeys(wordList, 12); + const mnemonicPhrase = mnemonic.join(' '); + const key = await deriveKey(mnemonicPhrase); + const encrypted = await encryptDataGCM(key, plaintext); + const content_inner_encrypted = bytesToHex(encrypted); + const shareForServer = shares[shareIndexForServer]; + const private_key_shard = serializeShare(shareForServer); + + return { + private_key_shard, + content_inner_encrypted, + mnemonic, + shares, + }; +} + +/** + * 生成可直接用于 POST /assets/create 的请求体(含 title / type) + */ +export async function createAssetPayload( + title: string, + plaintext: string, + wordList: readonly string[], + assetType: string = 'note', + shareIndexForServer: 0 | 1 | 2 = 1 +): Promise { + const vault = await createVaultPayload(plaintext, wordList, shareIndexForServer); + return { + title, + type: assetType, + private_key_shard: vault.private_key_shard, + content_inner_encrypted: vault.content_inner_encrypted, + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 2520692..5d3e799 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,4 @@ */ export * from './sss'; +export * from './vaultAssets'; diff --git a/src/utils/vaultAssets.ts b/src/utils/vaultAssets.ts new file mode 100644 index 0000000..9b8c3e2 --- /dev/null +++ b/src/utils/vaultAssets.ts @@ -0,0 +1,100 @@ +/** + * Vault assets: API ↔ UI mapping and initial mock data. + * Used by useVaultAssets and VaultScreen for /assets/get and /assets/create flows. + */ + +import type { VaultAsset, VaultAssetType } from '../types'; + +// ----------------------------------------------------------------------------- +// Types +// ----------------------------------------------------------------------------- + +/** Shape returned by GET /assets/get (backend AssetOut) */ +export interface ApiAsset { + id: number; + title: string; + type?: string; + created_at?: string; + updated_at?: string; +} + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +export const VAULT_ASSET_TYPES: VaultAssetType[] = [ + 'game_account', + 'private_key', + 'document', + 'photo', + 'will', + 'custom', +]; + +// ----------------------------------------------------------------------------- +// Mapping +// ----------------------------------------------------------------------------- + +/** + * Map backend API asset to VaultAsset for UI. + */ +export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset { + const type: VaultAssetType = + api.type && VAULT_ASSET_TYPES.includes(api.type as VaultAssetType) + ? (api.type as VaultAssetType) + : 'custom'; + return { + id: String(api.id), + type, + label: api.title, + createdAt: api.created_at ? new Date(api.created_at) : new Date(), + updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(), + isEncrypted: true, + }; +} + +/** + * Map array of API assets to VaultAsset[]. + */ +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, + }, +]; diff --git a/src/utils/vaultCrypto.ts b/src/utils/vaultCrypto.ts new file mode 100644 index 0000000..ad83e2f --- /dev/null +++ b/src/utils/vaultCrypto.ts @@ -0,0 +1,107 @@ +/** + * Vault crypto: PBKDF2 key derivation + AES-256-GCM encrypt/decrypt. + * Matches backend SentinelVault semantics (PBKDF2 from mnemonic, AES-GCM). + * Uses Web Crypto API (crypto.subtle). Requires secure context / React Native polyfill if needed. + */ + +const SALT = new TextEncoder().encode('Sentinel_Salt_2026'); +const PBKDF2_ITERATIONS = 100000; +const AES_KEY_LEN = 256; +const GCM_IV_LEN = 16; +const GCM_TAG_LEN = 16; + +function getCrypto(): Crypto { + if (typeof crypto !== 'undefined' && crypto.subtle) return crypto; + throw new Error('vaultCrypto: crypto.subtle not available'); +} + +/** + * Derive a 32-byte AES key from mnemonic phrase (space-separated words). + */ +export async function deriveKey(mnemonicPhrase: string, salt: Uint8Array = SALT): Promise { + const crypto = getCrypto(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(mnemonicPhrase), + 'PBKDF2', + false, + ['deriveBits'] + ); + const saltBuf = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer; + const bits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: saltBuf, + iterations: PBKDF2_ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + AES_KEY_LEN + ); + return bits; +} + +/** + * Encrypt plaintext with AES-256-GCM. Returns nonce(16) + tag(16) + ciphertext (matches Python SentinelVault). + */ +export async function encryptDataGCM(key: ArrayBuffer, plaintext: string): Promise { + const crypto = getCrypto(); + const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LEN)); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + const encoded = new TextEncoder().encode(plaintext); + const ciphertextWithTag = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: GCM_TAG_LEN * 8 }, + cryptoKey, + encoded + ); + const out = new Uint8Array(iv.length + ciphertextWithTag.byteLength); + out.set(iv, 0); + out.set(new Uint8Array(ciphertextWithTag), iv.length); + return out; +} + +/** + * Decrypt blob from encryptDataGCM (nonce(16) + ciphertext+tag). + */ +export async function decryptDataGCM(key: ArrayBuffer, blob: Uint8Array): Promise { + const crypto = getCrypto(); + const iv = blob.subarray(0, GCM_IV_LEN); + const ciphertextWithTag = blob.subarray(GCM_IV_LEN); + const ivBuf = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer; + const ctBuf = ciphertextWithTag.buffer.slice( + ciphertextWithTag.byteOffset, + ciphertextWithTag.byteOffset + ciphertextWithTag.byteLength + ) as ArrayBuffer; + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + const dec = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: ivBuf, tagLength: GCM_TAG_LEN * 8 }, + cryptoKey, + ctBuf + ); + return new TextDecoder().decode(dec); +} + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +export function hexToBytes(hex: string): Uint8Array { + const len = hex.length / 2; + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +}