11 Commits

Author SHA1 Message Date
Ada
50e78c84c9 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 11:57:16 -08:00
Ada
8e6c621f7b feat(vault): show mnemonic flow will show at first time ; or user reset vault state; 2026-02-01 11:02:14 -08:00
Ada
7b8511f080 fix(bug): Could not find MIME for Buffer <null> 2026-02-01 10:36:21 -08:00
Ada
f6fa19d0b2 feat(vault): add get/create assets API in workflow
TODO: update vault.service.ts. Use MNEMONIC workflow to create real private_key_shard and content_inner_encrypted
2026-02-01 09:19:45 -08:00
Ada
536513ab3f fix(bug): Could not find MIME for Buffer <null> 2026-02-01 09:12:29 -08:00
Ada
240a7eea8b Merge branch 'Steven' 2026-01-31 21:21:55 -08:00
lusixing
22dc3abf65 Merge branch 'multi_prompt_local_storage' 2026-01-31 11:13:41 -08:00
lusixing
ed1f6fc49d multi_prompt_local_storage 2026-01-31 11:11:25 -08:00
Ada
218b2e8b29 add vault first time control 2026-01-30 21:26:38 -08:00
Ada
fb1377eb4b Generate SSS shares from mnemonic words 2026-01-30 16:31:09 -08:00
Ada
c07f1f20d5 fix the warning 2026-01-30 15:22:13 -08:00
23 changed files with 1917 additions and 371 deletions

View File

@@ -19,14 +19,10 @@
"bundleIdentifier": "com.sentinel.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#459E9E"
},
"package": "com.sentinel.app"
},
"web": {
"favicon": "./assets/favicon.png",
"favicon": "./assets/icon.png",
"bundler": "metro"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 70 B

42
package-lock.json generated
View File

@@ -82,7 +82,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -486,6 +485,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
"integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/traverse": "^7.28.5"
@@ -502,6 +502,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
"integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -517,6 +518,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
"integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -532,6 +534,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
"integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
@@ -549,6 +552,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz",
"integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/traverse": "^7.28.6"
@@ -649,6 +653,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
},
@@ -769,6 +774,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz",
"integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
@@ -955,6 +961,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.18.6",
"@babel/helper-plugin-utils": "^7.18.6"
@@ -1020,6 +1027,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
"integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1066,6 +1074,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz",
"integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6"
@@ -1134,6 +1143,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz",
"integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
"@babel/helper-plugin-utils": "^7.28.6"
@@ -1150,6 +1160,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
"integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1165,6 +1176,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz",
"integrity": "sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
"@babel/helper-plugin-utils": "^7.28.6"
@@ -1181,6 +1193,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
"integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1196,6 +1209,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz",
"integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/plugin-transform-destructuring": "^7.28.5"
@@ -1212,6 +1226,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz",
"integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
@@ -1291,6 +1306,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz",
"integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
@@ -1336,6 +1352,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
"integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1351,6 +1368,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
"integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
@@ -1383,6 +1401,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
"integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1",
@@ -1401,6 +1420,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
"integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1"
@@ -1433,6 +1453,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
"integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1497,6 +1518,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
"integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1"
@@ -1592,6 +1614,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
"integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1717,6 +1740,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz",
"integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
"@babel/helper-plugin-utils": "^7.28.6"
@@ -1733,6 +1757,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
"integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1829,6 +1854,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
"integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1863,6 +1889,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
"integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
@@ -1878,6 +1905,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz",
"integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
"@babel/helper-plugin-utils": "^7.28.6"
@@ -1910,6 +1938,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz",
"integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.28.5",
"@babel/helper-plugin-utils": "^7.28.6"
@@ -2028,6 +2057,7 @@
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
"integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-plugin-utils": "^7.0.0",
"@babel/types": "^7.4.4",
@@ -2645,7 +2675,6 @@
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-4.0.1.tgz",
"integrity": "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react-native": "*"
}
@@ -3702,7 +3731,6 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.18.tgz",
"integrity": "sha512-mIT9MiL/vMm4eirLcmw2h6h/Nm5FICtnYSdohq4vTLA2FF/6PNhByM7s8ffqoVfE5L0uAa6Xda1B7oddolUiGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-navigation/core": "^6.4.17",
"escape-string-regexp": "^4.0.0",
@@ -3884,7 +3912,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -4505,7 +4532,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5530,6 +5556,7 @@
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5654,7 +5681,6 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz",
"integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "0.22.27",
@@ -9182,7 +9208,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -9266,7 +9291,6 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz",
"integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.6.3",
"@react-native/assets-registry": "0.76.9",
@@ -9368,7 +9392,6 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz",
"integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -9379,7 +9402,6 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz",
"integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"warn-once": "^0.1.0"

View File

@@ -64,6 +64,39 @@ export const API_ENDPOINTS = {
},
} as const;
// =============================================================================
// 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.
// - 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).
const VAULT_KEY_PREFIX = 'sentinel_vault';
/** Base key names (for reference). Prefer getVaultStorageKeys(userId) for all reads/writes. */
export const VAULT_STORAGE_KEYS = {
INITIALIZED: 'sentinel_vault_initialized',
SHARE_DEVICE: 'sentinel_vault_s0',
} 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.
* - userId null → guest namespace (_guest). userId set → per-user namespace (_u{userId}).
*/
export function getVaultStorageKeys(userId: number | string | null): {
INITIALIZED: string;
SHARE_DEVICE: string;
} {
const suffix = userId != null ? `_u${userId}` : '_guest';
return {
INITIALIZED: `${VAULT_KEY_PREFIX}_initialized${suffix}`,
SHARE_DEVICE: `${VAULT_KEY_PREFIX}_s0${suffix}`,
};
}
// =============================================================================
// Helper Functions
// =============================================================================
@@ -138,6 +171,44 @@ export const AI_CONFIG = {
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
*/
MOCK_RESPONSE_DELAY: 500,
/**
* AI Roles configuration
*/
ROLES: [
{
id: 'reflective',
name: 'Reflective Assistant',
description: 'Helps you dive deep into your thoughts and feelings through meaningful reflection.',
systemPrompt: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.',
icon: 'journal-outline',
iconFamily: 'Ionicons',
},
{
id: 'creative',
name: 'Creative Spark',
description: 'A partner for brainstorming, creative writing, and exploring new ideas.',
systemPrompt: 'You are a creative brainstorming partner. Help the user explore new ideas, write stories, or look at things from a fresh perspective.',
icon: 'bulb-outline',
iconFamily: 'Ionicons',
},
{
id: 'planner',
name: 'Action Planner',
description: 'Focused on turning thoughts into actionable plans and organized goals.',
systemPrompt: 'You are a productivity coach. Help the user break down their thoughts into actionable steps and clear goals.',
icon: 'list-outline',
iconFamily: 'Ionicons',
},
{
id: 'empathetic',
name: 'Empathetic Guide',
description: 'Provides a safe, non-judgmental space for emotional support and empathy.',
systemPrompt: 'You are a supportive and empathetic friend. Listen to the user\'s concerns and provide emotional support without judgment.',
icon: 'heart-outline',
iconFamily: 'Ionicons',
},
],
} as const;
// =============================================================================

View File

@@ -9,6 +9,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginRequest, RegisterRequest } from '../types';
import { authService } from '../services/auth.service';
import { storageService } from '../services/storage.service';
// =============================================================================
// Type Definitions
@@ -137,12 +138,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
/**
* Sign out and clear stored auth
* Sign out and clear stored auth and session data
*/
const signOut = () => {
setUser(null);
setToken(null);
clearAuth();
//storageService.clearAllData();
};
return (

6
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* React hooks for Sentinel
*/
export { useVaultAssets } from './useVaultAssets';
export type { CreateAssetResult, UseVaultAssetsReturn } from './useVaultAssets';

160
src/hooks/useVaultAssets.ts Normal file
View File

@@ -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<React.SetStateAction<VaultAsset[]>>;
/** Refetch from GET /assets/get */
refreshAssets: () => Promise<void>;
/** Create asset via POST /assets/create; on success refreshes list */
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
/** 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<VaultAsset[]>(initialVaultAssets);
const [isSealing, setIsSealing] = useState(false);
const [createError, setCreateError] = useState<string | null>(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<CreateAssetResult> => {
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,
};
}

View File

@@ -31,6 +31,8 @@ import * as ImagePicker from 'expo-image-picker';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { aiService } from '../services/ai.service';
import { useAuth } from '../context/AuthContext';
import { AI_CONFIG } from '../config';
import { storageService } from '../services/storage.service';
// =============================================================================
// Type Definitions
@@ -57,7 +59,7 @@ interface ChatSession {
// =============================================================================
export default function FlowScreen() {
const { token, signOut } = useAuth();
const { token, user, signOut } = useAuth();
const scrollViewRef = useRef<ScrollView>(null);
// Current conversation state
@@ -67,6 +69,11 @@ export default function FlowScreen() {
const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// AI Role state
const [selectedRole, setSelectedRole] = useState(AI_CONFIG.ROLES[0]);
const [showRoleModal, setShowRoleModal] = useState(false);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// History modal state
const [showHistoryModal, setShowHistoryModal] = useState(false);
const modalSlideAnim = useRef(new Animated.Value(0)).current;
@@ -104,14 +111,78 @@ export default function FlowScreen() {
day: 'numeric'
});
// Auto-scroll to bottom when new messages arrive
// Load history on mount
useEffect(() => {
const loadHistory = async () => {
if (!user) return;
try {
console.log('[FlowScreen] Loading chat history...');
const savedHistory = await storageService.getChatHistory(user.id);
if (savedHistory && savedHistory.length > 0) {
const formattedHistory = savedHistory.map((session: any) => ({
...session,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
messages: session.messages.map((msg: any) => ({
...msg,
createdAt: new Date(msg.createdAt)
}))
}));
setChatHistory(formattedHistory);
console.log('[FlowScreen] Chat history loaded:', formattedHistory.length, 'sessions');
} else {
console.log('[FlowScreen] No chat history found');
}
} catch (error) {
console.error('Failed to load history:', error);
}
};
loadHistory();
}, [user]);
// Load messages whenever role changes
useEffect(() => {
const loadRoleMessages = async () => {
if (!user) return;
try {
const savedMessages = await storageService.getCurrentChat(selectedRole.id, user.id);
if (savedMessages) {
const formattedMessages = savedMessages.map((msg: any) => ({
...msg,
createdAt: new Date(msg.createdAt)
}));
setMessages(formattedMessages);
} else {
setMessages([]);
}
} catch (error) {
console.error(`Failed to load messages for role ${selectedRole.id}:`, error);
setMessages([]);
}
};
loadRoleMessages();
}, [selectedRole.id, user]);
// 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 (messages.length > 0) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages]);
}, [messages, selectedRole.id, user]);
// Save history when it changes
useEffect(() => {
if (user) {
storageService.saveChatHistory(chatHistory, user.id);
}
}, [chatHistory, user]);
// Modal animation control
const openHistoryModal = () => {
@@ -168,8 +239,8 @@ export default function FlowScreen() {
setMessages(prev => [...prev, userMsg]);
try {
// Call AI proxy
const aiResponse = await aiService.sendMessage(userMessage, token);
// Call AI proxy with selected role's system prompt
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole.systemPrompt);
// Add AI response
const aiMsg: ChatMessage = {
@@ -335,8 +406,11 @@ export default function FlowScreen() {
setChatHistory(prev => [newSession, ...prev]);
}
// Clear current messages
// Clear current messages and storage for this role
setMessages([]);
if (user) {
storageService.saveCurrentChat(selectedRole.id, [], user.id);
}
closeHistoryModal();
};
@@ -451,9 +525,9 @@ export default function FlowScreen() {
<View style={styles.emptyIcon}>
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
</View>
<Text style={styles.emptyTitle}>Start a conversation</Text>
<Text style={styles.emptyTitle}>Chatting with {selectedRole.name}</Text>
<Text style={styles.emptySubtitle}>
Ask me anything or share your thoughts
{selectedRole.description}
</Text>
</View>
);
@@ -505,6 +579,23 @@ export default function FlowScreen() {
</View>
</View>
{/* Role Header Dropdown */}
<TouchableOpacity
style={styles.headerRoleButton}
onPress={() => setShowRoleModal(true)}
activeOpacity={0.7}
>
<Ionicons
name={selectedRole.icon as any}
size={16}
color={colors.nautical.teal}
/>
<Text style={styles.headerRoleText} numberOfLines={1}>
{selectedRole.name}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
</TouchableOpacity>
{/* History Button */}
<TouchableOpacity
style={styles.historyButton}
@@ -607,7 +698,6 @@ export default function FlowScreen() {
</SafeAreaView>
</LinearGradient>
{/* History Modal - Background appears instantly, content slides up */}
<Modal
visible={showHistoryModal}
animationType="none"
@@ -670,6 +760,89 @@ export default function FlowScreen() {
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Role Selection Modal */}
<Modal
visible={showRoleModal}
animationType="fade"
transparent
onRequestClose={() => setShowRoleModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowRoleModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
<View style={[styles.modalContent, styles.roleModalContent]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Choose AI Assistant</Text>
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
{AI_CONFIG.ROLES.map((role) => (
<View key={role.id} style={styles.roleItemContainer}>
<View
style={[
styles.roleItem,
selectedRole.id === role.id && styles.roleItemActive
]}
>
<TouchableOpacity
style={styles.roleSelectionArea}
onPress={() => {
setSelectedRole(role as any);
setShowRoleModal(false);
}}
>
<View style={[
styles.roleItemIcon,
selectedRole.id === role.id && styles.roleItemIconActive
]}>
<Ionicons
name={role.icon as any}
size={20}
color={selectedRole.id === role.id ? '#fff' : colors.nautical.teal}
/>
</View>
<Text style={[
styles.roleItemName,
selectedRole.id === role.id && styles.roleItemNameActive
]}>
{role.name}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton}
onPress={() => {
setExpandedRoleId(expandedRoleId === role.id ? null : role.id);
}}
>
<Ionicons
name={expandedRoleId === role.id ? "close-circle-outline" : "information-circle-outline"}
size={24}
color={expandedRoleId === role.id ? colors.nautical.coral : colors.flow.textSecondary}
/>
</TouchableOpacity>
</View>
{expandedRoleId === role.id && (
<View style={styles.roleDescription}>
<Text style={styles.roleDescriptionText}>{role.description}</Text>
</View>
)}
</View>
))}
</ScrollView>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowRoleModal(false)}
>
<Text style={styles.closeButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
</View>
);
}
@@ -697,12 +870,33 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: spacing.base,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
flex: 1,
},
headerRoleButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: borderRadius.full,
marginHorizontal: spacing.sm,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
maxWidth: '40%',
},
headerRoleText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.flow.text,
marginHorizontal: 4,
},
iconCircle: {
width: 44,
@@ -773,6 +967,96 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
// Role selection styles
roleDropdown: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
marginBottom: spacing.md,
...shadows.soft,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
},
roleIcon: {
marginRight: spacing.sm,
},
roleDropdownText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.flow.text,
marginRight: spacing.xs,
},
roleModalContent: {
paddingBottom: spacing.xl,
},
roleList: {
marginTop: spacing.sm,
maxHeight: 400,
},
roleItemContainer: {
marginBottom: spacing.sm,
},
roleItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: borderRadius.lg,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'transparent',
overflow: 'hidden',
},
roleItemActive: {
backgroundColor: colors.nautical.paleAqua,
borderColor: colors.nautical.lightMint,
},
roleSelectionArea: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
roleItemIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.flow.backgroundGradientStart,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
roleItemIconActive: {
backgroundColor: colors.nautical.teal,
},
roleItemName: {
fontSize: typography.fontSize.base,
fontWeight: '500',
color: colors.flow.text,
},
roleItemNameActive: {
fontWeight: '700',
color: colors.nautical.teal,
},
infoButton: {
padding: spacing.md,
justifyContent: 'center',
alignItems: 'center',
},
roleDescription: {
paddingHorizontal: spacing.md + 36 + spacing.md, // icon width + margins
paddingBottom: spacing.sm,
paddingTop: 0,
},
roleDescriptionText: {
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
fontStyle: 'italic',
lineHeight: 18,
},
// Message bubble styles
messageBubble: {
flexDirection: 'row',

View File

@@ -14,9 +14,11 @@ import {
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '../context/AuthContext';
import { Heir, HeirStatus, PaymentStrategy } from '../types';
import HeritageScreen from './HeritageScreen';
import { getVaultStorageKeys } from '../config';
// Mock heirs data
const initialHeirs: Heir[] = [
@@ -220,6 +222,7 @@ export default function MeScreen() {
const [showHeritageModal, setShowHeritageModal] = useState(false);
const [showThemeModal, setShowThemeModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
// Heritage / Fleet Legacy states
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
@@ -245,6 +248,7 @@ export default function MeScreen() {
});
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
const [resetVaultFeedback, setResetVaultFeedback] = useState<{ status: 'idle' | 'success' | 'error'; message: string }>({ status: 'idle', message: '' });
const [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
@@ -294,18 +298,39 @@ export default function MeScreen() {
};
const handleAbandonIsland = () => {
Alert.alert(
'Sign Out',
'Are you sure you want to sign out?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: signOut
},
]
);
console.log('[MeScreen] Sign out button clicked');
setShowSignOutModal(true);
};
const handleConfirmSignOut = () => {
console.log('[MeScreen] User confirmed sign out');
setShowSignOutModal(false);
signOut();
};
const handleResetVault = async () => {
setResetVaultFeedback({ status: 'idle', message: '' });
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
try {
await AsyncStorage.multiRemove([
vaultKeys.INITIALIZED,
vaultKeys.SHARE_DEVICE,
]);
setResetVaultFeedback({
status: 'success',
message: 'Vault state has been reset. Next time you open Shadow Vault you will see the mnemonic flow again.',
});
} catch (e) {
setResetVaultFeedback({
status: 'error',
message: 'Failed to reset vault state. Please try again.',
});
}
};
const handleCloseSanctumModal = () => {
setResetVaultFeedback({ status: 'idle', message: '' });
setShowSanctumModal(false);
};
return (
@@ -749,7 +774,7 @@ export default function MeScreen() {
visible={showSanctumModal}
animationType="fade"
transparent
onRequestClose={() => setShowSanctumModal(false)}
onRequestClose={handleCloseSanctumModal}
>
<View style={styles.spiritOverlay}>
<View style={styles.spiritModal}>
@@ -885,12 +910,51 @@ export default function MeScreen() {
<Text style={styles.sanctumValue}>View</Text>
</View>
</View>
{__DEV__ && (
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>DEV ONLY</Text>
<TouchableOpacity
style={styles.devResetButton}
onPress={handleResetVault}
activeOpacity={0.85}
>
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
<Text style={styles.devResetText}>Reset Vault State</Text>
</TouchableOpacity>
<Text style={styles.sanctumHint}>Clear S0 (SHARE_DEVICE) from storage. Next vault open uses mnemonic flow.</Text>
{resetVaultFeedback.status !== 'idle' && (
<View
style={[
styles.resetVaultFeedback,
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackSuccess : styles.resetVaultFeedbackError,
]}
>
<Ionicons
name={resetVaultFeedback.status === 'success' ? 'checkmark-circle' : 'alert-circle'}
size={20}
color={resetVaultFeedback.status === 'success' ? colors.sentinel?.statusNormal ?? '#6BBF8A' : colors.nautical.coral}
/>
<Text
style={[
styles.resetVaultFeedbackText,
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackTextSuccess : styles.resetVaultFeedbackTextError,
]}
>
{resetVaultFeedback.status === 'success' ? 'Success' : 'Error'}
{' — '}
{resetVaultFeedback.message}
</Text>
</View>
)}
</View>
)}
</ScrollView>
<View style={styles.tideModalButtons}>
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowSanctumModal(false)}
onPress={handleCloseSanctumModal}
>
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Save</Text>
@@ -898,7 +962,7 @@ export default function MeScreen() {
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowSanctumModal(false)}
onPress={handleCloseSanctumModal}
>
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Close</Text>
@@ -1412,6 +1476,46 @@ export default function MeScreen() {
</View>
</View>
</Modal>
{/* Sign Out Confirmation Modal */}
<Modal
visible={showSignOutModal}
animationType="fade"
transparent
onRequestClose={() => setShowSignOutModal(false)}
>
<View style={styles.spiritOverlay}>
<View style={styles.signOutModal}>
<View style={styles.signOutHeader}>
<View style={styles.signOutIcon}>
<Feather name="log-out" size={32} color={colors.nautical.coral} />
</View>
<Text style={styles.signOutTitle}>Sign Out</Text>
<Text style={styles.signOutMessage}>
Are you sure you want to sign out? You'll need to log in again to access your account.
</Text>
</View>
<View style={styles.signOutButtons}>
<TouchableOpacity
style={styles.signOutCancelButton}
onPress={() => setShowSignOutModal(false)}
activeOpacity={0.85}
>
<Text style={styles.signOutCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.signOutConfirmButton}
onPress={handleConfirmSignOut}
activeOpacity={0.85}
>
<Text style={styles.signOutConfirmText}>Sign Out</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
}
@@ -1814,6 +1918,23 @@ const styles = StyleSheet.create({
fontSize: typography.fontSize.sm,
color: colors.me.textSecondary,
},
devResetButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
backgroundColor: `${colors.nautical.coral}15`,
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: `${colors.nautical.coral}40`,
alignSelf: 'flex-start',
},
devResetText: {
fontSize: typography.fontSize.sm,
color: colors.nautical.coral,
fontWeight: '600',
},
sanctumAlert: {
flexDirection: 'row',
alignItems: 'center',
@@ -1827,6 +1948,34 @@ const styles = StyleSheet.create({
fontSize: typography.fontSize.sm,
color: colors.nautical.coral,
},
resetVaultFeedback: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
borderRadius: borderRadius.lg,
padding: spacing.base,
marginTop: spacing.md,
},
resetVaultFeedbackSuccess: {
backgroundColor: 'rgba(107, 191, 138, 0.2)',
borderWidth: 1,
borderColor: 'rgba(107, 191, 138, 0.5)',
},
resetVaultFeedbackError: {
backgroundColor: 'rgba(229, 115, 115, 0.2)',
borderWidth: 1,
borderColor: 'rgba(229, 115, 115, 0.5)',
},
resetVaultFeedbackText: {
flex: 1,
fontSize: typography.fontSize.sm,
},
resetVaultFeedbackTextSuccess: {
color: '#2E7D5E',
},
resetVaultFeedbackTextError: {
color: colors.nautical.coral,
},
confirmPulseButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -2333,4 +2482,71 @@ const styles = StyleSheet.create({
justifyContent: 'center',
zIndex: 10,
},
// Sign Out Modal Styles
signOutModal: {
backgroundColor: colors.me.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.xl,
marginHorizontal: spacing.xl,
maxWidth: 400,
width: '100%',
...shadows.medium,
},
signOutHeader: {
alignItems: 'center',
marginBottom: spacing.xl,
},
signOutIcon: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${colors.nautical.coral}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.base,
},
signOutTitle: {
fontSize: typography.fontSize.xl,
fontWeight: '700',
color: colors.me.text,
marginBottom: spacing.sm,
},
signOutMessage: {
fontSize: typography.fontSize.base,
color: colors.me.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.5,
},
signOutButtons: {
flexDirection: 'row',
gap: spacing.md,
},
signOutCancelButton: {
flex: 1,
paddingVertical: spacing.base,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
backgroundColor: colors.me.cardBorder,
alignItems: 'center',
justifyContent: 'center',
},
signOutCancelText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.me.text,
},
signOutConfirmButton: {
flex: 1,
paddingVertical: spacing.base,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
backgroundColor: colors.nautical.coral,
alignItems: 'center',
justifyContent: 'center',
},
signOutConfirmText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: '#fff',
},
});

View File

@@ -15,11 +15,22 @@ import { colors, typography, spacing, borderRadius, shadows } from '../theme/col
import { SystemStatus, KillSwitchLog } from '../types';
import VaultScreen from './VaultScreen';
// Animation timing constants
const ANIMATION_DURATION = {
pulse: 1200,
glow: 1500,
rotate: 30000,
heartbeatPress: 150,
} as const;
// Icon names type for type safety
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
// Status configuration with nautical theme
const statusConfig: Record<SystemStatus, {
color: string;
label: string;
icon: string;
icon: StatusIconName;
description: string;
gradientColors: [string, string];
}> = {
@@ -48,28 +59,14 @@ const statusConfig: Record<SystemStatus, {
// Mock data
const initialLogs: KillSwitchLog[] = [
{
id: '1',
action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date('2024-01-18T09:30:00'),
},
{
id: '2',
action: 'SUBSCRIPTION_VERIFIED',
timestamp: new Date('2024-01-17T00:00:00'),
},
{
id: '3',
action: 'JOURNAL_ACTIVITY',
timestamp: new Date('2024-01-16T15:42:00'),
},
{
id: '4',
action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date('2024-01-15T11:20:00'),
},
{ id: '1', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-18T09:30:00') },
{ id: '2', action: 'SUBSCRIPTION_VERIFIED', timestamp: new Date('2024-01-17T00:00:00') },
{ id: '3', action: 'JOURNAL_ACTIVITY', timestamp: new Date('2024-01-16T15:42:00') },
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20:00') },
];
export { VAULT_STORAGE_KEYS } from '../config';
export default function SentinelScreen() {
const [status, setStatus] = useState<SystemStatus>('normal');
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
@@ -81,104 +78,98 @@ export default function SentinelScreen() {
const [showVault, setShowVault] = useState(false);
useEffect(() => {
// Pulse animation
Animated.loop(
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1200,
duration: ANIMATION_DURATION.pulse,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1200,
duration: ANIMATION_DURATION.pulse,
useNativeDriver: true,
}),
])
).start();
);
pulseAnimation.start();
// Glow animation
Animated.loop(
const glowAnimation = Animated.loop(
Animated.sequence([
Animated.timing(glowAnim, {
toValue: 1,
duration: 1500,
duration: ANIMATION_DURATION.glow,
useNativeDriver: true,
}),
Animated.timing(glowAnim, {
toValue: 0.5,
duration: 1500,
duration: ANIMATION_DURATION.glow,
useNativeDriver: true,
}),
])
).start();
);
glowAnimation.start();
// Slow rotate for ship wheel
Animated.loop(
const rotateAnimation = Animated.loop(
Animated.timing(rotateAnim, {
toValue: 1,
duration: 30000,
duration: ANIMATION_DURATION.rotate,
useNativeDriver: true,
})
).start();
}, []);
);
rotateAnimation.start();
const openVault = () => {
setShowVault(true);
return () => {
pulseAnimation.stop();
glowAnimation.stop();
rotateAnimation.stop();
};
}, [pulseAnim, glowAnim, rotateAnim]);
const openVault = () => setShowVault(true);
const handleHeartbeat = () => {
// Animate pulse
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.15,
duration: 150,
duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 150,
duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true,
}),
]).start();
// Add new log
const newLog: KillSwitchLog = {
id: Date.now().toString(),
action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date(),
};
setLogs([newLog, ...logs]);
setLogs((prevLogs) => [newLog, ...prevLogs]);
// Reset status if warning
if (status === 'warning') {
setStatus('normal');
}
};
const formatDateTime = (date: Date) => {
return date.toLocaleString('en-US', {
const formatDateTime = (date: Date) =>
date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days} days ago`;
}
if (hours > 0) {
return `${hours}h ${minutes}m ago`;
}
if (hours > 24) return `${Math.floor(hours / 24)} days ago`;
if (hours > 0) return `${hours}h ${minutes}m ago`;
return `${minutes}m ago`;
};
@@ -218,7 +209,7 @@ export default function SentinelScreen() {
transform: [{ scale: pulseAnim }],
opacity: glowAnim,
backgroundColor: `${currentStatus.color}20`,
}
},
]}
/>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
@@ -226,7 +217,7 @@ export default function SentinelScreen() {
colors={currentStatus.gradientColors}
style={styles.statusCircle}
>
<Ionicons name={currentStatus.icon as any} size={56} color="#fff" />
<Ionicons name={currentStatus.icon} size={56} color="#fff" />
</LinearGradient>
</Animated.View>
<Text style={[styles.statusLabel, { color: currentStatus.color }]}>
@@ -256,24 +247,16 @@ export default function SentinelScreen() {
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
</View>
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
<Text style={styles.metricValue}>
{formatTimeAgo(lastSubscriptionCheck)}
</Text>
<Text style={styles.metricTime}>
{formatDateTime(lastSubscriptionCheck)}
</Text>
<Text style={styles.metricValue}>{formatTimeAgo(lastSubscriptionCheck)}</Text>
<Text style={styles.metricTime}>{formatDateTime(lastSubscriptionCheck)}</Text>
</View>
<View style={styles.metricCard}>
<View style={styles.metricIconContainer}>
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
</View>
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
<Text style={styles.metricValue}>
{formatTimeAgo(lastFlowActivity)}
</Text>
<Text style={styles.metricTime}>
{formatDateTime(lastFlowActivity)}
</Text>
<Text style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
</View>
</View>
@@ -292,6 +275,8 @@ export default function SentinelScreen() {
style={styles.vaultAccessButton}
onPress={openVault}
activeOpacity={0.8}
accessibilityLabel="Open Shadow Vault"
accessibilityRole="button"
>
<Text style={styles.vaultAccessButtonText}>Open</Text>
</TouchableOpacity>
@@ -302,6 +287,8 @@ export default function SentinelScreen() {
style={styles.heartbeatButton}
onPress={handleHeartbeat}
activeOpacity={0.9}
accessibilityLabel="Signal the watch - Confirm your presence"
accessibilityRole="button"
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
@@ -330,9 +317,7 @@ export default function SentinelScreen() {
<View style={styles.logDot} />
<View style={styles.logContent}>
<Text style={styles.logAction}>{log.action}</Text>
<Text style={styles.logTime}>
{formatDateTime(log.timestamp)}
</Text>
<Text style={styles.logTime}>{formatDateTime(log.timestamp)}</Text>
</View>
</View>
))}
@@ -353,36 +338,27 @@ export default function SentinelScreen() {
style={styles.vaultCloseButton}
onPress={() => setShowVault(false)}
activeOpacity={0.85}
accessibilityLabel="Close vault"
accessibilityRole="button"
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
},
container: { flex: 1 },
gradient: { flex: 1 },
safeArea: { flex: 1 },
scrollView: { flex: 1 },
scrollContent: {
padding: spacing.lg,
paddingBottom: 120,
},
header: {
marginBottom: spacing.xl,
},
header: { marginBottom: spacing.xl },
headerTitleRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -488,9 +464,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.xl,
...shadows.medium,
},
heartbeatGradient: {
padding: spacing.lg,
},
heartbeatGradient: { padding: spacing.lg },
heartbeatContent: {
flexDirection: 'row',
alignItems: 'center',
@@ -544,9 +518,7 @@ const styles = StyleSheet.create({
marginTop: 6,
marginRight: spacing.md,
},
logContent: {
flex: 1,
},
logContent: { flex: 1 },
logAction: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.text,
@@ -559,7 +531,6 @@ const styles = StyleSheet.create({
color: colors.sentinel.textSecondary,
fontFamily: typography.fontFamily.mono,
},
// Shadow Vault Access Card
vaultAccessCard: {
flexDirection: 'row',
alignItems: 'center',
@@ -579,9 +550,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginRight: spacing.md,
},
vaultAccessContent: {
flex: 1,
},
vaultAccessContent: { flex: 1 },
vaultAccessTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
@@ -603,7 +572,6 @@ const styles = StyleSheet.create({
fontWeight: '700',
fontSize: typography.fontSize.sm,
},
// Vault Modal
vaultModalContainer: {
flex: 1,
backgroundColor: colors.vault.background,

View File

@@ -24,6 +24,9 @@ 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';
import { getVaultStorageKeys } from '../config';
import { mnemonicToEntropy, splitSecret, serializeShare } from '../utils/sss';
// Asset type configuration with nautical theme
const assetTypeConfig: Record<VaultAssetType, { icon: string; iconType: 'ionicons' | 'feather' | 'material' | 'fontawesome5'; label: string }> = {
@@ -89,40 +92,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 +143,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<VaultAsset[]>(initialAssets);
const {
assets,
setAssets,
refreshAssets,
createAsset: createVaultAsset,
isSealing,
createError: addError,
clearCreateError: clearAddError,
} = useVaultAssets(isUnlocked);
const [showAddModal, setShowAddModal] = useState(false);
const [selectedType, setSelectedType] = useState<VaultAssetType>('custom');
const [newLabel, setNewLabel] = useState('');
@@ -159,6 +170,7 @@ export default function VaultScreen() {
const [accountProvider, setAccountProvider] = useState<'bank' | 'steam' | 'facebook' | 'custom'>('bank');
const [showMnemonic, setShowMnemonic] = useState(false);
const [showLegacyAssignCta, setShowLegacyAssignCta] = useState(false);
const [hasS0, setHasS0] = useState<boolean | null>(null);
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
const [mnemonicParts, setMnemonicParts] = useState<string[][]>([]);
const [mnemonicStep, setMnemonicStep] = useState<1 | 2 | 3 | 4 | 5>(1);
@@ -170,19 +182,37 @@ 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 vaultKeys = React.useMemo(() => getVaultStorageKeys(user?.id ?? null), [user?.id]);
const [isCapturing, setIsCapturing] = useState(false);
const [treasureContent, setTreasureContent] = useState('');
const mnemonicRef = useRef<View>(null);
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Detect S0 (TEE/SE) for current user: if present, later open shows biometric only; if not, mnemonic flow
useEffect(() => {
if (!isUnlocked) {
const timer = setTimeout(() => {
setShowBiometric(true);
}, 500);
return () => clearTimeout(timer);
let cancelled = false;
AsyncStorage.getItem(vaultKeys.SHARE_DEVICE)
.then((v) => {
if (!cancelled) setHasS0(!!v);
})
.catch(() => {
if (!cancelled) setHasS0(false);
});
return () => { cancelled = true; };
}, [vaultKeys.SHARE_DEVICE]);
// Only when S0 exists and vault not unlocked: show biometric after short delay.
// When hasS0 is false or null, never show biometric — go straight to mnemonic flow.
useEffect(() => {
if (hasS0 !== true) {
setShowBiometric(false);
return;
}
}, []);
if (isUnlocked) return;
const timer = setTimeout(() => setShowBiometric(true), 500);
return () => clearTimeout(timer);
}, [isUnlocked, hasS0]);
useEffect(() => {
if (isUnlocked) {
@@ -325,28 +355,49 @@ export default function VaultScreen() {
const handleHeirDecision = (share: boolean) => {
// Placeholder for future heir flow
setShowMnemonic(false);
setIsUnlocked(true);
finishMnemonicThenShowBiometric();
};
const handleSubmitAssignment = () => {
// Placeholder for submitting assignment to API
setShowMnemonic(false);
setIsUnlocked(true);
finishMnemonicThenShowBiometric();
};
const handleHeirYes = () => {
setShowMnemonic(false);
setIsUnlocked(true);
finishMnemonicThenShowBiometric();
setShowLegacyAssignCta(true);
};
const handleHeirNo = () => {
setShowMnemonic(false);
setIsUnlocked(true);
finishMnemonicThenShowBiometric();
setShowLegacyAssignCta(true);
};
/** After mnemonic flow: persist S0 (simulated TEE/SE via AsyncStorage), then show biometric; unlock on success */
const finishMnemonicThenShowBiometric = async () => {
try {
const wordList = bip39.wordlists.english;
const entropy = mnemonicToEntropy(mnemonicWords, wordList);
const shares = splitSecret(entropy);
const s0 = shares[0]; // device share (S0)
// S0 is stored in AsyncStorage under user-scoped key — app-level storage, not hardware TEE/SE
await AsyncStorage.setItem(vaultKeys.SHARE_DEVICE, serializeShare(s0));
await AsyncStorage.setItem(vaultKeys.INITIALIZED, '1');
setHasS0(true);
setShowMnemonic(false);
setShowBiometric(true);
} catch (e) {
if (__DEV__) console.warn('finishMnemonicThenShowBiometric', e);
setShowMnemonic(false);
setShowBiometric(true);
}
};
const handleBiometricSuccess = () => {
setShowBiometric(false);
setIsUnlocked(true);
};
const handleOpenLegacyAssign = () => {
setSelectedHeir(null);
setSelectedHeirAsset(null);
@@ -375,29 +426,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]);
if (result.success) {
setNewLabel('');
setTreasureContent('');
setSelectedType('custom');
setShowAddModal(false);
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 +559,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 +575,10 @@ export default function VaultScreen() {
style={styles.mnemonicOverlay}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View ref={mnemonicRef} collapsable={false}>
<LinearGradient
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
style={styles.mnemonicCard}
ref={mnemonicRef}
>
<TouchableOpacity
style={styles.mnemonicClose}
@@ -821,6 +897,7 @@ export default function VaultScreen() {
<View style={[styles.stepDot, mnemonicStep !== 1 && styles.stepDotActive]} />
</View>
</LinearGradient>
</View>
</KeyboardAvoidingView>
</Modal>
);
@@ -852,7 +929,7 @@ export default function VaultScreen() {
<TouchableOpacity
style={styles.unlockButton}
onPress={() => setShowBiometric(true)}
onPress={hasS0 ? () => setShowBiometric(true) : handleUnlock}
activeOpacity={0.8}
>
<LinearGradient
@@ -861,8 +938,10 @@ export default function VaultScreen() {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name="finger-print" size={20} color={colors.vault.background} />
<Text style={styles.unlockButtonText}>Captain's Verification</Text>
<Ionicons name={hasS0 ? 'finger-print' : 'key'} size={20} color={colors.vault.background} />
<Text style={styles.unlockButtonText}>
{hasS0 === true ? "Captain's Verification" : hasS0 === false ? 'Enter Vault' : 'Loading…'}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
@@ -870,8 +949,8 @@ export default function VaultScreen() {
</LinearGradient>
<BiometricModal
visible={showBiometric}
onSuccess={handleUnlock}
visible={hasS0 === true && showBiometric}
onSuccess={handleBiometricSuccess}
onCancel={() => setShowBiometric(false)}
title="Enter the Vault"
message="Verify your identity to access your treasures"
@@ -963,6 +1042,7 @@ export default function VaultScreen() {
style={styles.addButton}
onPress={() => {
resetAddFlow();
clearAddError();
setShowAddModal(true);
}}
activeOpacity={0.9}
@@ -1005,7 +1085,7 @@ export default function VaultScreen() {
</View>
<View style={styles.stepRow}>
{['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 +1114,14 @@ export default function VaultScreen() {
{addStep === 1 && (
<>
<Text style={styles.modalLabel}>TREASURE TITLE</Text>
<TextInput
style={styles.input}
placeholder="e.g., Main wallet mnemonic"
placeholderTextColor={colors.nautical.sage}
value={newLabel}
onChangeText={setNewLabel}
/>
<Text style={styles.modalLabel}>TREASURE TYPE</Text>
<ScrollView
horizontal
@@ -1093,9 +1181,25 @@ export default function VaultScreen() {
);
})}
</View>
<Text style={styles.modalLabel}>CONTENT</Text>
<TextInput
style={[styles.input, styles.inputMultiline]}
placeholder="Enter content to seal (plaintext is encrypted locally before upload)"
placeholderTextColor={colors.nautical.sage}
value={treasureContent}
onChangeText={setTreasureContent}
multiline
numberOfLines={6}
textAlignVertical="top"
/>
<View style={styles.encryptionNote}>
<MaterialCommunityIcons name="anchor" size={16} color={colors.nautical.teal} />
<Text style={styles.encryptionNoteText}>
Data is encrypted on-device. Plaintext is shredded after sealing.
</Text>
</View>
</>
)}
{selectedType === 'game_account' && (
<>
<Text style={styles.modalLabel}>ACCOUNT PROVIDER</Text>
@@ -1135,9 +1239,6 @@ export default function VaultScreen() {
<Ionicons name="log-in-outline" size={18} color={colors.nautical.cream} />
<Text style={styles.loginButtonText}>Open App to Login</Text>
</TouchableOpacity>
</>
)}
<Text style={styles.modalLabel}>TREASURE NAME</Text>
<TextInput
style={styles.input}
@@ -1146,13 +1247,8 @@ export default function VaultScreen() {
value={newLabel}
onChangeText={setNewLabel}
/>
<View style={styles.encryptionNote}>
<MaterialCommunityIcons name="anchor" size={16} color={colors.nautical.teal} />
<Text style={styles.encryptionNoteText}>
Data is encrypted on-device. Plaintext is shredded after sealing.
</Text>
</View>
</>
)}
</>
)}
@@ -1201,14 +1297,24 @@ export default function VaultScreen() {
</>
)}
{addError ? (
<View style={styles.addErrorBox}>
<Ionicons name="warning" size={18} color="#c2410c" />
<Text style={styles.addErrorText}>{addError}</Text>
</View>
) : null}
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
if (addStep === 1) {
setShowAddModal(false);
setTreasureContent('');
clearAddError();
} else {
setAddStep(addStep - 1);
clearAddError();
}
}}
>
@@ -1248,7 +1354,7 @@ export default function VaultScreen() {
end={{ x: 1, y: 0 }}
>
<MaterialCommunityIcons name="lock" size={18} color="#fff" />
<Text style={styles.confirmButtonText}>Seal Treasure</Text>
<Text style={styles.confirmButtonText}>{isSealing ? 'Sealing...' : 'Seal Treasure'}</Text>
</LinearGradient>
</TouchableOpacity>
)}
@@ -1344,7 +1450,7 @@ export default function VaultScreen() {
<Text style={styles.actionText}>Export Cipher Pack</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionRow} activeOpacity={0.8}>
<MaterialCommunityIcons name="timer-reset" size={18} color={colors.vault.primary} />
<MaterialCommunityIcons name="refresh" size={18} color={colors.vault.primary} />
<Text style={styles.actionText}>Reset Sentinel Timer</Text>
</TouchableOpacity>
</View>
@@ -1835,6 +1941,10 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.nautical.lightMint,
},
inputMultiline: {
minHeight: 120,
paddingTop: spacing.base,
},
encryptionNote: {
flexDirection: 'row',
alignItems: 'center',
@@ -1850,6 +1960,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,

View File

@@ -143,13 +143,14 @@ export const aiService = {
* Simple helper for single message chat
* @param content - User message content
* @param token - JWT token for authentication
* @param systemPrompt - Optional custom system prompt
* @returns AI response text
*/
async sendMessage(content: string, token?: string): Promise<string> {
async sendMessage(content: string, token?: string, systemPrompt?: string): Promise<string> {
const messages: AIMessage[] = [
{
role: 'system',
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
},
{
role: 'user',

View File

@@ -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();

View File

@@ -23,3 +23,9 @@ export {
type DeclareGualeRequest,
type DeclareGualeResponse
} from './admin.service';
export {
createVaultPayload,
createAssetPayload,
type CreateVaultPayloadResult,
type CreateAssetPayloadResult,
} from './vault.service';

View File

@@ -0,0 +1,120 @@
/**
* Storage Service
*
* Handles local persistence of chat history and active conversations
* using AsyncStorage with user-specific isolation.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
// =============================================================================
// Constants
// =============================================================================
const STORAGE_KEYS = {
CHAT_HISTORY: '@sentinel:chat_history',
CURRENT_MESSAGES: '@sentinel:current_messages',
} as const;
// =============================================================================
// Service Implementation
// =============================================================================
export const storageService = {
/**
* Get user-specific storage key
*/
getUserKey(baseKey: string, userId: string | number): string {
return `${baseKey}:user_${userId}`;
},
/**
* Save the complete list of chat sessions to local storage for a specific user
*/
async saveChatHistory(history: any[], userId: string | number): Promise<void> {
try {
const jsonValue = JSON.stringify(history);
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
await AsyncStorage.setItem(key, jsonValue);
console.log(`[Storage] Saved chat history for user ${userId}:`, history.length, 'sessions');
} catch (e) {
console.error('Error saving chat history:', e);
}
},
/**
* Load the list of chat sessions from local storage for a specific user
*/
async getChatHistory(userId: string | number): Promise<any[]> {
try {
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
const jsonValue = await AsyncStorage.getItem(key);
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
console.log(`[Storage] Loaded chat history for user ${userId}:`, result.length, 'sessions');
return result;
} catch (e) {
console.error('Error getting chat history:', e);
return [];
}
},
/**
* Save the current active conversation messages for a specific role and user
*/
async saveCurrentChat(roleId: string, messages: any[], userId: string | number): Promise<void> {
try {
const jsonValue = JSON.stringify(messages);
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
await AsyncStorage.setItem(key, jsonValue);
console.log(`[Storage] Saved current chat for user ${userId}, role ${roleId}:`, messages.length, 'messages');
} catch (e) {
console.error(`Error saving current chat for role ${roleId}:`, e);
}
},
/**
* Load the current active conversation messages for a specific role and user
*/
async getCurrentChat(roleId: string, userId: string | number): Promise<any[]> {
try {
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
const jsonValue = await AsyncStorage.getItem(key);
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
console.log(`[Storage] Loaded current chat for user ${userId}, role ${roleId}:`, result.length, 'messages');
return result;
} catch (e) {
console.error(`Error getting current chat for role ${roleId}:`, e);
return [];
}
},
/**
* Clear all stored chat data for a specific user
*/
async clearUserData(userId: string | number): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const userPrefix = `:user_${userId}`;
const userKeys = keys.filter(key => key.includes(userPrefix));
await AsyncStorage.multiRemove(userKeys);
console.log(`[Storage] Cleared all data for user ${userId}:`, userKeys.length, 'keys removed');
} catch (e) {
console.error('Error clearing user data:', e);
}
},
/**
* Clear all stored chat data (all users)
*/
async clearAllData(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const sentinelKeys = keys.filter(key => key.startsWith('@sentinel:'));
await AsyncStorage.multiRemove(sentinelKeys);
console.log('[Storage] Cleared all data:', sentinelKeys.length, 'keys removed');
} catch (e) {
console.error('Error clearing storage data:', e);
}
}
};

View File

@@ -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_encryptedhex 字符串)
*
* 使用方式:在任意页面调用 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_encryptedAES-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<CreateVaultPayloadResult> {
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<CreateAssetPayloadResult> {
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,
};
}

6
src/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Utility functions for Sentinel
*/
export * from './sss';
export * from './vaultAssets';

268
src/utils/sss.ts Normal file
View File

@@ -0,0 +1,268 @@
/**
* Shamir's Secret Sharing (SSS) Implementation
*
* This implements a (3,2) threshold scheme where:
* - Secret is split into 3 shares
* - Any 2 shares can recover the original secret
*
* Correspondence with crypto_core_demo (Python):
* - sp_trust_sharding.py: split_to_shares(), recover_from_shares()
* - Same algorithm: f(x) = secret + a*x (mod P), Lagrange interpolation
* - Difference: entropy conversion. Python uses BIP-39 (mnemonic.to_entropy);
* we use custom word list index-based encoding for compatibility with
* existing MNEMONIC_WORDS. SSS split/recover logic is identical.
*/
// Use a large prime for the finite field
// We use 2^127 - 1 (a Mersenne prime) which fits well in BigInt
// This is smaller than the Python version's 2^521 - 1 but sufficient for our 128-bit entropy
const PRIME = BigInt('170141183460469231731687303715884105727'); // 2^127 - 1
/**
* Represents an SSS share as a coordinate point (x, y)
*/
export interface SSSShare {
x: number;
y: bigint;
label: string; // 'device' | 'cloud' | 'heir'
}
/**
* Generate a cryptographically secure random BigInt in range [0, max)
*/
function secureRandomBigInt(max: bigint): bigint {
// Get the number of bytes needed
const byteLength = Math.ceil(max.toString(2).length / 8);
const randomBytes = new Uint8Array(byteLength);
// Use crypto.getRandomValues for secure randomness
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(randomBytes);
} else {
// Fallback for environments without crypto
for (let i = 0; i < byteLength; i++) {
randomBytes[i] = Math.floor(Math.random() * 256);
}
}
// Convert to BigInt
let result = BigInt(0);
for (let i = 0; i < randomBytes.length; i++) {
result = (result << BigInt(8)) + BigInt(randomBytes[i]);
}
// Ensure result is within range
return result % max;
}
/**
* Convert mnemonic words to entropy (as BigInt)
* Each word is mapped to its index, then combined into a single large number
*/
export function mnemonicToEntropy(words: string[], wordList: readonly string[]): bigint {
let entropy = BigInt(0);
const wordListLength = BigInt(wordList.length);
for (const word of words) {
const index = wordList.indexOf(word);
if (index === -1) {
throw new Error(`Word "${word}" not found in word list`);
}
entropy = entropy * wordListLength + BigInt(index);
}
return entropy;
}
/**
* Convert entropy back to mnemonic words
*/
export function entropyToMnemonic(entropy: bigint, wordCount: number, wordList: readonly string[]): string[] {
const words: string[] = [];
const wordListLength = BigInt(wordList.length);
let remaining = entropy;
for (let i = 0; i < wordCount; i++) {
const index = Number(remaining % wordListLength);
words.unshift(wordList[index]);
remaining = remaining / wordListLength;
}
return words;
}
/**
* Modular inverse using extended Euclidean algorithm
* Returns x such that (a * x) % p === 1
*/
function modInverse(a: bigint, p: bigint): bigint {
let [oldR, r] = [a % p, p];
let [oldS, s] = [BigInt(1), BigInt(0)];
while (r !== BigInt(0)) {
const quotient = oldR / r;
[oldR, r] = [r, oldR - quotient * r];
[oldS, s] = [s, oldS - quotient * s];
}
// Ensure positive result
return ((oldS % p) + p) % p;
}
/**
* Modular arithmetic helper to ensure positive results
*/
function mod(n: bigint, p: bigint): bigint {
return ((n % p) + p) % p;
}
/**
* Split a secret into 3 shares using SSS (3,2) threshold scheme
*
* Uses linear polynomial: f(x) = secret + a*x (mod p)
* where 'a' is a random coefficient
*
* Any 2 points on this line can recover the y-intercept (secret)
*/
export function splitSecret(secret: bigint): SSSShare[] {
// Generate random coefficient for the polynomial
const a = secureRandomBigInt(PRIME);
// Polynomial: f(x) = secret + a*x (mod PRIME)
const f = (x: number): bigint => {
return mod(secret + a * BigInt(x), PRIME);
};
// Generate 3 shares at x = 1, 2, 3
return [
{ x: 1, y: f(1), label: 'device' },
{ x: 2, y: f(2), label: 'cloud' },
{ x: 3, y: f(3), label: 'heir' },
];
}
/**
* Recover the secret from any 2 shares using Lagrange interpolation
*
* For 2 points (x1, y1) and (x2, y2), the secret (y-intercept) is:
* S = (x2*y1 - x1*y2) / (x2 - x1) (mod p)
*/
export function recoverSecret(shareA: SSSShare, shareB: SSSShare): bigint {
const { x: x1, y: y1 } = shareA;
const { x: x2, y: y2 } = shareB;
// Numerator: x2*y1 - x1*y2
const numerator = mod(
BigInt(x2) * y1 - BigInt(x1) * y2,
PRIME
);
// Denominator: x2 - x1
const denominator = mod(BigInt(x2 - x1), PRIME);
// Division in modular arithmetic = multiply by modular inverse
const invDenominator = modInverse(denominator, PRIME);
return mod(numerator * invDenominator, PRIME);
}
/**
* Format a share for display (truncated for readability)
* Shows first 8 and last 4 characters of the y-value
*/
export function formatShareForDisplay(share: SSSShare): string {
const yStr = share.y.toString();
if (yStr.length <= 16) {
return `(${share.x}, ${yStr})`;
}
return `(${share.x}, ${yStr.slice(0, 8)}...${yStr.slice(-4)})`;
}
/**
* Format a share as a compact display string (for UI cards)
* Returns a shorter format showing the share index and a hash-like preview
*/
export function formatShareCompact(share: SSSShare): string {
const yStr = share.y.toString();
// Create a "fingerprint" from the y value
const fingerprint = yStr.slice(0, 4) + '-' + yStr.slice(4, 8) + '-' + yStr.slice(-4);
return fingerprint;
}
/**
* Serialize a share to a string for storage/transmission
*/
export function serializeShare(share: SSSShare): string {
return JSON.stringify({
x: share.x,
y: share.y.toString(),
label: share.label,
});
}
/**
* Deserialize a share from a string
*/
export function deserializeShare(str: string): SSSShare {
const parsed = JSON.parse(str);
return {
x: parsed.x,
y: BigInt(parsed.y),
label: parsed.label,
};
}
/**
* Main function to generate mnemonic and SSS shares
* This is the entry point for the vault initialization flow
*/
export interface VaultKeyData {
mnemonic: string[];
shares: SSSShare[];
entropy: bigint;
}
export function generateVaultKeys(
wordList: readonly string[],
wordCount: number = 12
): VaultKeyData {
// Generate random mnemonic
const mnemonic: string[] = [];
for (let i = 0; i < wordCount; i++) {
const index = Math.floor(Math.random() * wordList.length);
mnemonic.push(wordList[index]);
}
// Convert to entropy
const entropy = mnemonicToEntropy(mnemonic, wordList);
// Split into shares
const shares = splitSecret(entropy);
return { mnemonic, shares, entropy };
}
/**
* Verify that shares can recover the original entropy
* Useful for testing and validation
*/
export function verifyShares(
shares: SSSShare[],
originalEntropy: bigint
): boolean {
// Test all 3 combinations of 2 shares
const combinations = [
[shares[0], shares[1]], // Device + Cloud
[shares[1], shares[2]], // Cloud + Heir
[shares[0], shares[2]], // Device + Heir
];
for (const [a, b] of combinations) {
const recovered = recoverSecret(a, b);
if (recovered !== originalEntropy) {
return false;
}
}
return true;
}

100
src/utils/vaultAssets.ts Normal file
View File

@@ -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,
},
];

107
src/utils/vaultCrypto.ts Normal file
View File

@@ -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<ArrayBuffer> {
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<Uint8Array> {
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<string> {
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;
}