mirror of
https://github.com/cryptoadvance/specter-desktop.git
synced 2026-08-13 12:33:29 +02:00
Feature: Encrypted user data storage (#1453)
* Interim commit of user_secret added to User * user_secret bugfixes; different, faster encryption * interim commit * Update to work on a per-field basis * BaseServiceStorage; bugfixes; test cases * Updated hashes; remove debugging * Update persistence.py * Remove debugging * More test cases; test org cleanup; pulling out Services * Cleanup to remove Services * Bugfix cleanup * Bugfixes, cleanup * Bugfixes; rename to `password_hash` * Bugfix; only write `encrypted_storage_version` when needed * Moves User creation into UserManager, per PR feedback New test case included * exception handling per PR feedback * Replacing `user_secret` encryption with Fernet * Removes unused methods * Updates encrypted_user_secret as needed as iteration count increases over time Co-authored-by: Kim Neunert <k9ert@gmx.de>
This commit is contained in:
parent
659baad94f
commit
997e2318d7
16 changed files with 662 additions and 195 deletions
|
|
@ -94,7 +94,7 @@ Run the server:
|
|||
|
||||
```sh
|
||||
cd specter-desktop
|
||||
export FLASK_ENV=development && python3 -m cryptoadvance.specter server --config DevelopmentConfig
|
||||
python3 -m cryptoadvance.specter server --config DevelopmentConfig --debug
|
||||
```
|
||||
|
||||
#### If `pip install` fails on `cryptography==3.4.x`
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ flask_wtf==0.14.3
|
|||
pgpy==0.5.4
|
||||
cbor==1.0.0
|
||||
mnemonic==0.20
|
||||
cryptography==3.4.7
|
||||
197
requirements.txt
197
requirements.txt
|
|
@ -1,32 +1,34 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# This file is autogenerated by pip-compile with python 3.8
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile --generate-hashes requirements.in
|
||||
#
|
||||
aniso8601==9.0.1 \
|
||||
--hash=sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f \
|
||||
--hash=sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973 \
|
||||
--hash=sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973
|
||||
# via flask-restful
|
||||
babel==2.9.1 \
|
||||
--hash=sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9 \
|
||||
--hash=sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0 \
|
||||
--hash=sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0
|
||||
# via flask-babel
|
||||
base58==2.1.0 \
|
||||
--hash=sha256:171a547b4a3c61e1ae3807224a6f7aec75e364c4395e7562649d7335768001a2 \
|
||||
--hash=sha256:8225891d501b68c843ffe30b86371f844a21c6ba00da76f52f9b998ba771fb48 \
|
||||
--hash=sha256:8225891d501b68c843ffe30b86371f844a21c6ba00da76f52f9b998ba771fb48
|
||||
# via bitbox02
|
||||
bitbox02==5.3.0 \
|
||||
--hash=sha256:797e6904d431f6d2ef711f169e7ce8fffc125cc8c5b3efb8187fd451f45635e1 \
|
||||
--hash=sha256:fe0e8aeb9b32fd7d76bb3e9838895973a74dfd532a8fb8ac174a1a60214aee26 \
|
||||
--hash=sha256:fe0e8aeb9b32fd7d76bb3e9838895973a74dfd532a8fb8ac174a1a60214aee26
|
||||
# via hwi
|
||||
cbor==1.0.0 \
|
||||
--hash=sha256:13225a262ddf5615cbd9fd55a76a0d53069d18b07d2e9f19c39e6acb8609bbb6 \
|
||||
--hash=sha256:13225a262ddf5615cbd9fd55a76a0d53069d18b07d2e9f19c39e6acb8609bbb6
|
||||
# via -r requirements.in
|
||||
certifi==2019.9.11 \
|
||||
--hash=sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50 \
|
||||
--hash=sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef \
|
||||
# via -r requirements.in, requests
|
||||
--hash=sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef
|
||||
# via
|
||||
# -r requirements.in
|
||||
# requests
|
||||
cffi==1.14.5 \
|
||||
--hash=sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813 \
|
||||
--hash=sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373 \
|
||||
|
|
@ -76,20 +78,22 @@ cffi==1.14.5 \
|
|||
--hash=sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406 \
|
||||
--hash=sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333 \
|
||||
--hash=sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d \
|
||||
--hash=sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c \
|
||||
--hash=sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c
|
||||
# via cryptography
|
||||
chardet==3.0.4 \
|
||||
--hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \
|
||||
--hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
|
||||
--hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691
|
||||
# via -r requirements.in
|
||||
charset-normalizer==2.0.6 \
|
||||
--hash=sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6 \
|
||||
--hash=sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f \
|
||||
--hash=sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f
|
||||
# via requests
|
||||
click==7.1.2 \
|
||||
--hash=sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a \
|
||||
--hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc \
|
||||
# via -r requirements.in, flask
|
||||
--hash=sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask
|
||||
cryptography==3.4.7 \
|
||||
--hash=sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d \
|
||||
--hash=sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959 \
|
||||
|
|
@ -102,42 +106,55 @@ cryptography==3.4.7 \
|
|||
--hash=sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250 \
|
||||
--hash=sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca \
|
||||
--hash=sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d \
|
||||
--hash=sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9 \
|
||||
# via noiseprotocol, pgpy, pyopenssl
|
||||
--hash=sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9
|
||||
# via
|
||||
# -r requirements.in
|
||||
# noiseprotocol
|
||||
# pgpy
|
||||
# pyopenssl
|
||||
ecdsa==0.17.0 \
|
||||
--hash=sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676 \
|
||||
--hash=sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa \
|
||||
# via bitbox02, hwi
|
||||
--hash=sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa
|
||||
# via
|
||||
# bitbox02
|
||||
# hwi
|
||||
embit==0.4.10 \
|
||||
--hash=sha256:f6484bc495b45da27f3eb7fbe21a24c00cd72c0ab83c6e195660cf17db5cb5e2 \
|
||||
# via -r requirements.in
|
||||
flask-babel==2.0.0 \
|
||||
--hash=sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468 \
|
||||
--hash=sha256:f9faf45cdb2e1a32ea2ec14403587d4295108f35017a7821a2b1acb8cfd9257d \
|
||||
# via -r requirements.in
|
||||
flask-cors==3.0.10 \
|
||||
--hash=sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438 \
|
||||
--hash=sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de \
|
||||
# via -r requirements.in
|
||||
flask-httpauth==4.4.0 \
|
||||
--hash=sha256:bcaaa7a35a3cba0b2eafd4f113b3016bf70eb78087456d96484c3c18928b813a \
|
||||
--hash=sha256:d9131122cdc5709dda63790f6e9b3142d8101447d424b0b95ffd4ee279f49539 \
|
||||
# via -r requirements.in
|
||||
flask-login==0.5.0 \
|
||||
--hash=sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b \
|
||||
--hash=sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0 \
|
||||
# via -r requirements.in
|
||||
flask-restful==0.3.9 \
|
||||
--hash=sha256:4970c49b6488e46c520b325f54833374dc2b98e211f1b272bd4b0c516232afe2 \
|
||||
--hash=sha256:ccec650b835d48192138c85329ae03735e6ced58e9b2d9c2146d6c84c06fa53e \
|
||||
--hash=sha256:f6484bc495b45da27f3eb7fbe21a24c00cd72c0ab83c6e195660cf17db5cb5e2
|
||||
# via -r requirements.in
|
||||
flask==1.1.4 \
|
||||
--hash=sha256:0fbeb6180d383a9186d0d6ed954e0042ad9f18e0e8de088b2b419d526927d196 \
|
||||
--hash=sha256:c34f04500f2cbbea882b1acb02002ad6fe6b7ffa64a6164577995657f50aed22 \
|
||||
# via -r requirements.in, flask-babel, flask-cors, flask-httpauth, flask-login, flask-restful, flask-wtf
|
||||
--hash=sha256:c34f04500f2cbbea882b1acb02002ad6fe6b7ffa64a6164577995657f50aed22
|
||||
# via
|
||||
# -r requirements.in
|
||||
# flask-babel
|
||||
# flask-cors
|
||||
# flask-httpauth
|
||||
# flask-login
|
||||
# flask-restful
|
||||
# flask-wtf
|
||||
flask-babel==2.0.0 \
|
||||
--hash=sha256:e6820a052a8d344e178cdd36dd4bb8aea09b4bda3d5f9fa9f008df2c7f2f5468 \
|
||||
--hash=sha256:f9faf45cdb2e1a32ea2ec14403587d4295108f35017a7821a2b1acb8cfd9257d
|
||||
# via -r requirements.in
|
||||
flask-cors==3.0.10 \
|
||||
--hash=sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438 \
|
||||
--hash=sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de
|
||||
# via -r requirements.in
|
||||
flask-httpauth==4.4.0 \
|
||||
--hash=sha256:bcaaa7a35a3cba0b2eafd4f113b3016bf70eb78087456d96484c3c18928b813a \
|
||||
--hash=sha256:d9131122cdc5709dda63790f6e9b3142d8101447d424b0b95ffd4ee279f49539
|
||||
# via -r requirements.in
|
||||
flask-login==0.5.0 \
|
||||
--hash=sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b \
|
||||
--hash=sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0
|
||||
# via -r requirements.in
|
||||
flask-restful==0.3.9 \
|
||||
--hash=sha256:4970c49b6488e46c520b325f54833374dc2b98e211f1b272bd4b0c516232afe2 \
|
||||
--hash=sha256:ccec650b835d48192138c85329ae03735e6ced58e9b2d9c2146d6c84c06fa53e
|
||||
# via -r requirements.in
|
||||
flask_wtf==0.14.3 \
|
||||
--hash=sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2 \
|
||||
--hash=sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720 \
|
||||
--hash=sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720
|
||||
# via -r requirements.in
|
||||
hidapi==0.10.1 \
|
||||
--hash=sha256:095798ae1b3d6892fb0eb7ba1ab06054f6fafe6d09bc3714d80fdbf227c98f87 \
|
||||
|
|
@ -160,28 +177,34 @@ hidapi==0.10.1 \
|
|||
--hash=sha256:b1becc9f09c85c473e91cf869b592d5d87fb8b89672988de33776b20b4c53ce1 \
|
||||
--hash=sha256:b686b2b547890c8ed17ebeabded0050ce377180a56daefa20822b4d66d3a5dea \
|
||||
--hash=sha256:df4a23cd03f00d5cdc603252650df82cdd1923ceef6811cb029cc9d11a9a7a61 \
|
||||
--hash=sha256:f49a0de45217366b85597c2edb4be8bd61c9f26f533b854b058dded4352dd89d \
|
||||
# via bitbox02, hwi
|
||||
--hash=sha256:f49a0de45217366b85597c2edb4be8bd61c9f26f533b854b058dded4352dd89d
|
||||
# via
|
||||
# bitbox02
|
||||
# hwi
|
||||
hwi==2.0.1 \
|
||||
--hash=sha256:1a49ec86d4770239408e74c87acdd15b9f72271fbdddf66acd935f068bbf115d \
|
||||
--hash=sha256:cba5e254fcf7ae1b523ec1f0eab693b401d4b3cd949b35a4557a106a98065c23 \
|
||||
--hash=sha256:cba5e254fcf7ae1b523ec1f0eab693b401d4b3cd949b35a4557a106a98065c23
|
||||
# via -r requirements.in
|
||||
idna==2.10 \
|
||||
--hash=sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6 \
|
||||
--hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0 \
|
||||
--hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0
|
||||
# via requests
|
||||
importlib_metadata==4.8.1 \
|
||||
--hash=sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15 \
|
||||
--hash=sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1 \
|
||||
--hash=sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1
|
||||
# via -r requirements.in
|
||||
itsdangerous==1.1.0 \
|
||||
--hash=sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19 \
|
||||
--hash=sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749 \
|
||||
# via flask, flask-wtf
|
||||
--hash=sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749
|
||||
# via
|
||||
# flask
|
||||
# flask-wtf
|
||||
jinja2==2.11.3 \
|
||||
--hash=sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419 \
|
||||
--hash=sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6 \
|
||||
# via flask, flask-babel
|
||||
--hash=sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6
|
||||
# via
|
||||
# flask
|
||||
# flask-babel
|
||||
libusb1==1.9.2 \
|
||||
--hash=sha256:27aec6aa1ff9ca845d0035023f3cf39710afac56903c51cd96a95404d064189e \
|
||||
--hash=sha256:2dff68819350bf8a8c157c7fa40d3efc741cb57868687d1714c8125ee99e8ac8 \
|
||||
|
|
@ -189,7 +212,7 @@ libusb1==1.9.2 \
|
|||
--hash=sha256:a323588902fbd3693f8fddd7eac016700b24116c31b00756b9f52cf06c2a6629 \
|
||||
--hash=sha256:b4f25a2d66f62ec740edba3597038a7e9cd45b43456acfdb7a2bca8c2ad4aa30 \
|
||||
--hash=sha256:c19d49136ef262474dbbac8bd40a2c4b65660220571de8564efec631c56bdc09 \
|
||||
--hash=sha256:c3dd4df43b5c38f65bf599413810d021f5f98396c4b6f66765fb98193aca11b0 \
|
||||
--hash=sha256:c3dd4df43b5c38f65bf599413810d021f5f98396c4b6f66765fb98193aca11b0
|
||||
# via hwi
|
||||
markupsafe==2.0.1 \
|
||||
--hash=sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298 \
|
||||
|
|
@ -225,19 +248,23 @@ markupsafe==2.0.1 \
|
|||
--hash=sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94 \
|
||||
--hash=sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509 \
|
||||
--hash=sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51 \
|
||||
--hash=sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872 \
|
||||
# via jinja2, wtforms
|
||||
--hash=sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872
|
||||
# via
|
||||
# jinja2
|
||||
# wtforms
|
||||
mnemonic==0.20 \
|
||||
--hash=sha256:7c6fb5639d779388027a77944680aee4870f0fcd09b1e42a5525ee2ce4c625f6 \
|
||||
--hash=sha256:acd2168872d0379e7a10873bb3e12bf6c91b35de758135c4fbd1015ef18fafc5 \
|
||||
# via -r requirements.in, hwi
|
||||
--hash=sha256:acd2168872d0379e7a10873bb3e12bf6c91b35de758135c4fbd1015ef18fafc5
|
||||
# via
|
||||
# -r requirements.in
|
||||
# hwi
|
||||
noiseprotocol==0.3.1 \
|
||||
--hash=sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111 \
|
||||
--hash=sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645 \
|
||||
--hash=sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645
|
||||
# via bitbox02
|
||||
pgpy==0.5.4 \
|
||||
--hash=sha256:bdd3da1e006fc8e81cc02232969924d6e8c98a4af1621a925d99bba09164183b \
|
||||
--hash=sha256:c29ad9b2bcba6575c3773410894e77a7552b6a3de184fd99b4da3995986f26a9 \
|
||||
--hash=sha256:c29ad9b2bcba6575c3773410894e77a7552b6a3de184fd99b4da3995986f26a9
|
||||
# via -r requirements.in
|
||||
protobuf==3.17.3 \
|
||||
--hash=sha256:13ee7be3c2d9a5d2b42a1030976f760f28755fcf5863c55b1460fd205e6cd637 \
|
||||
|
|
@ -262,7 +289,7 @@ protobuf==3.17.3 \
|
|||
--hash=sha256:c56c050a947186ba51de4f94ab441d7f04fcd44c56df6e922369cc2e1a92d683 \
|
||||
--hash=sha256:e76d9686e088fece2450dbc7ee905f9be904e427341d289acbe9ad00b78ebd47 \
|
||||
--hash=sha256:f0e59430ee953184a703a324b8ec52f571c6c4259d496a19d1cabcdc19dabc62 \
|
||||
--hash=sha256:ffea251f5cd3c0b9b43c7a7a912777e0bc86263436a87c2555242a348817221b \
|
||||
--hash=sha256:ffea251f5cd3c0b9b43c7a7a912777e0bc86263436a87c2555242a348817221b
|
||||
# via bitbox02
|
||||
psutil==5.7.3 \
|
||||
--hash=sha256:01bc82813fbc3ea304914581954979e637bcc7084e59ac904d870d6eb8bb2bc7 \
|
||||
|
|
@ -275,75 +302,87 @@ psutil==5.7.3 \
|
|||
--hash=sha256:af73f7bcebdc538eda9cc81d19db1db7bf26f103f91081d780bbacfcb620dee2 \
|
||||
--hash=sha256:e02c31b2990dcd2431f4524b93491941df39f99619b0d312dfe1d4d530b08b4b \
|
||||
--hash=sha256:fa38ac15dbf161ab1e941ff4ce39abd64b53fec5ddf60c23290daed2bc7d1157 \
|
||||
--hash=sha256:fbcac492cb082fa38d88587d75feb90785d05d7e12d4565cbf1ecc727aff71b7 \
|
||||
--hash=sha256:fbcac492cb082fa38d88587d75feb90785d05d7e12d4565cbf1ecc727aff71b7
|
||||
# via -r requirements.in
|
||||
pyaes==1.6.1 \
|
||||
--hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f \
|
||||
--hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f
|
||||
# via hwi
|
||||
pyasn1==0.4.8 \
|
||||
--hash=sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d \
|
||||
--hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba \
|
||||
--hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba
|
||||
# via pgpy
|
||||
pycparser==2.20 \
|
||||
--hash=sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0 \
|
||||
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705 \
|
||||
--hash=sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705
|
||||
# via cffi
|
||||
pyopenssl==20.0.1 \
|
||||
--hash=sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51 \
|
||||
--hash=sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b \
|
||||
--hash=sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b
|
||||
# via -r requirements.in
|
||||
pyserial==3.4 \
|
||||
--hash=sha256:6e2d401fdee0eab996cf734e67773a0143b932772ca8b42451440cfed942c627 \
|
||||
--hash=sha256:e0770fadba80c31013896c7e6ef703f72e7834965954a78e71a3049488d4d7d8 \
|
||||
--hash=sha256:e0770fadba80c31013896c7e6ef703f72e7834965954a78e71a3049488d4d7d8
|
||||
# via -r requirements.in
|
||||
pysocks==1.7.1 \
|
||||
--hash=sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299 \
|
||||
--hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \
|
||||
--hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 \
|
||||
--hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0
|
||||
# via -r requirements.in
|
||||
python-dotenv==0.13.0 \
|
||||
--hash=sha256:25c0ff1a3e12f4bde8d592cc254ab075cfe734fc5dd989036716fd17ee7e5ec7 \
|
||||
--hash=sha256:3b9909bc96b0edc6b01586e1eed05e71174ef4e04c71da5786370cebea53ad74 \
|
||||
--hash=sha256:3b9909bc96b0edc6b01586e1eed05e71174ef4e04c71da5786370cebea53ad74
|
||||
# via -r requirements.in
|
||||
pytz==2021.1 \
|
||||
--hash=sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da \
|
||||
--hash=sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798 \
|
||||
# via babel, flask-babel, flask-restful
|
||||
--hash=sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798
|
||||
# via
|
||||
# babel
|
||||
# flask-babel
|
||||
# flask-restful
|
||||
requests==2.26.0 \
|
||||
--hash=sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24 \
|
||||
--hash=sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7 \
|
||||
--hash=sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7
|
||||
# via -r requirements.in
|
||||
semver==2.13.0 \
|
||||
--hash=sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4 \
|
||||
--hash=sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f \
|
||||
--hash=sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f
|
||||
# via bitbox02
|
||||
six==1.16.0 \
|
||||
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
|
||||
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 \
|
||||
# via -r requirements.in, ecdsa, flask-cors, flask-restful, pgpy, protobuf, pyopenssl
|
||||
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
|
||||
# via
|
||||
# -r requirements.in
|
||||
# ecdsa
|
||||
# flask-cors
|
||||
# flask-restful
|
||||
# pgpy
|
||||
# protobuf
|
||||
# pyopenssl
|
||||
stem==1.8.0 \
|
||||
--hash=sha256:a0b48ea6224e95f22aa34c0bc3415f0eb4667ddeae3dfb5e32a6920c185568c2 \
|
||||
--hash=sha256:a0b48ea6224e95f22aa34c0bc3415f0eb4667ddeae3dfb5e32a6920c185568c2
|
||||
# via -r requirements.in
|
||||
typing-extensions==3.10.0.0 \
|
||||
--hash=sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497 \
|
||||
--hash=sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342 \
|
||||
--hash=sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84 \
|
||||
# via bitbox02, hwi
|
||||
--hash=sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84
|
||||
# via
|
||||
# bitbox02
|
||||
# hwi
|
||||
urllib3==1.26.5 \
|
||||
--hash=sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c \
|
||||
--hash=sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098 \
|
||||
--hash=sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098
|
||||
# via requests
|
||||
werkzeug==1.0.1 \
|
||||
--hash=sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43 \
|
||||
--hash=sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c \
|
||||
--hash=sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c
|
||||
# via flask
|
||||
wtforms==2.3.3 \
|
||||
--hash=sha256:7b504fc724d0d1d4d5d5c114e778ec88c37ea53144683e084215eed5155ada4c \
|
||||
--hash=sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c \
|
||||
--hash=sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c
|
||||
# via flask-wtf
|
||||
zipp==3.4.1 \
|
||||
--hash=sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76 \
|
||||
--hash=sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098 \
|
||||
--hash=sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098
|
||||
# via importlib-metadata
|
||||
|
||||
# WARNING: The following packages were not pinned, but pip requires them to be
|
||||
|
|
|
|||
|
|
@ -25,14 +25,14 @@ def verify_password(username, password):
|
|||
if not the_user:
|
||||
return abort(401)
|
||||
g.user = app.specter.user_manager.get_user_by_username(username)
|
||||
if user_verify_password(g.user.password, password):
|
||||
if user_verify_password(g.user.password_hash, password):
|
||||
logger.info(f"Rest-Request for user {username} PASSED password-test")
|
||||
return username
|
||||
else:
|
||||
logger.info(f"Rest-Request for user {username} FAILED password-test")
|
||||
return abort(401)
|
||||
|
||||
return g.user is not None and verify_password(g.user.password, password)
|
||||
return g.user is not None and verify_password(g.user.password_hash, password)
|
||||
|
||||
|
||||
def require_admin(func):
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import random
|
|||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..helpers import deep_update
|
||||
from ..persistence import read_json_file, write_json_file
|
||||
|
|
@ -19,7 +19,7 @@ class ConfigManager(GenericDataManager):
|
|||
"""
|
||||
The ConfigManager manages the configuration persisted in config.json
|
||||
It's not suppose to have any side-effects. Setting and getting only
|
||||
with a lot of validation and computing while setting/getting
|
||||
with a lot of validation and computing while setting/getting.
|
||||
"""
|
||||
|
||||
name_of_json_file = "config.json"
|
||||
|
|
@ -70,6 +70,8 @@ class ConfigManager(GenericDataManager):
|
|||
# TODO: remove
|
||||
"bitcoind": False,
|
||||
}
|
||||
|
||||
# TODO: change this to just override load()
|
||||
self.check_config()
|
||||
|
||||
def check_config(self):
|
||||
|
|
|
|||
|
|
@ -1,25 +1,40 @@
|
|||
import os
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from ..persistence import read_json_file, write_json_file
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenericDataManager:
|
||||
"""
|
||||
A GenericDataManager manages json-data in self.data in a json file. It's meant to
|
||||
be derived from. See OtpManager
|
||||
be derived from. See OtpManager.
|
||||
|
||||
Supports encrypting individual attributes rest. Expects a Fernet key that is unique
|
||||
to the user to encrypt/decrypt.
|
||||
"""
|
||||
|
||||
name_of_json_file = "some_data.json"
|
||||
encrypted_fields = []
|
||||
|
||||
# Track any changes to our encryption implementation so we can migrate up any older
|
||||
# data as we go.
|
||||
encrypted_storage_version = 1
|
||||
|
||||
@classmethod
|
||||
def initial_data(cls):
|
||||
return {}
|
||||
|
||||
name_of_json_file = "some_data.json"
|
||||
|
||||
# of them via json-files in an empty data folder
|
||||
def __init__(self, data_folder):
|
||||
def __init__(self, data_folder, encryption_key=None):
|
||||
# encryption_key indicates that the encrypted_fields (if any) need to be
|
||||
# encrypted at rest.
|
||||
self.data_folder = data_folder
|
||||
self.encryption_key = encryption_key
|
||||
self.load()
|
||||
|
||||
@property
|
||||
|
|
@ -33,30 +48,41 @@ class GenericDataManager:
|
|||
self.data = read_json_file(self.data_file)
|
||||
# otherwise - create one and assign unique id
|
||||
else:
|
||||
logger.debug(f"{self.data_file} not existing. Creating ...")
|
||||
logger.debug(f"{self.data_file} doesn't exist. Creating ...")
|
||||
self.data = self.__class__.initial_data()
|
||||
self._save()
|
||||
|
||||
if self.encryption_key:
|
||||
# Decrypt the encrypted fields and store in memory as plaintext
|
||||
fernet = Fernet(self.encryption_key)
|
||||
|
||||
if (
|
||||
"encrypted_storage_version" in self.data
|
||||
and self.encrypted_storage_version
|
||||
!= self.data["encrypted_storage_version"]
|
||||
):
|
||||
raise Exception(
|
||||
"Upgrading a previous encryption version is not yet implemented"
|
||||
)
|
||||
|
||||
for attr in self.encrypted_fields:
|
||||
if attr in self.data and self.data[attr] is not None:
|
||||
self.data[attr] = fernet.decrypt(self.data[attr].encode()).decode()
|
||||
|
||||
def _save(self):
|
||||
# data_json = convert_to_list_of_dict(self.data)
|
||||
write_json_file(self.data, self.data_file)
|
||||
if self.encryption_key:
|
||||
# Preserve the in-memory data but encrypt a copy to write to disk
|
||||
fernet = Fernet(self.encryption_key)
|
||||
output_dict = copy.deepcopy(self.data)
|
||||
for attr in self.encrypted_fields:
|
||||
if attr in output_dict and output_dict[attr] is not None:
|
||||
output_dict[attr] = fernet.encrypt(
|
||||
output_dict[attr].encode()
|
||||
).decode()
|
||||
else:
|
||||
output_dict = self.data
|
||||
|
||||
@classmethod
|
||||
def convert_to_list_of_type(cls, some_list):
|
||||
"""This is not yet used but might be convenient
|
||||
An array of elements get converted to a list of a specific types
|
||||
Maybe this method simply does nothing, though
|
||||
"""
|
||||
return some_list
|
||||
# example implementation for users:
|
||||
# return [User.from_json(u, self.specter) for u in some_list]
|
||||
if self.encrypted_fields:
|
||||
output_dict["encrypted_storage_version"] = self.encrypted_storage_version
|
||||
|
||||
@classmethod
|
||||
def convert_to_list_of_dict(cls, some_list):
|
||||
"""This is not yet used but might be convenient
|
||||
An array of a specific type gets converted to a list of dicts
|
||||
Maybe this method simply does nothing, though
|
||||
"""
|
||||
return some_list
|
||||
# example implementation for users:
|
||||
# return [u.json for u in some_list]
|
||||
write_json_file(output_dict, self.data_file)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class UserManager:
|
|||
"username": "admin",
|
||||
"password": hash_password("admin"),
|
||||
"is_admin": True,
|
||||
"encrypted_user_secret": None,
|
||||
}
|
||||
]
|
||||
# convert to User instances
|
||||
|
|
@ -58,6 +59,12 @@ class UserManager:
|
|||
user.check()
|
||||
return self.get_user(user)
|
||||
|
||||
def create_user(self, user_id, username, plaintext_password, config):
|
||||
password_hash = hash_password(plaintext_password)
|
||||
user = User(user_id, username, password_hash, config, self.specter)
|
||||
user.decrypt_user_secret(plaintext_password)
|
||||
return self.add_user(user)
|
||||
|
||||
@property
|
||||
def admin(self):
|
||||
"""There is always one admin"""
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ def read_json_file(path):
|
|||
try:
|
||||
with open(path, "r") as f:
|
||||
content = json.load(f)
|
||||
|
||||
# if failed - try reading from the backup
|
||||
except Exception as e:
|
||||
# if no backup exists - raise
|
||||
|
|
@ -70,9 +71,11 @@ def _write_json_file(content, path, lock=None):
|
|||
try:
|
||||
with open(path, "w") as f:
|
||||
json.dump(content, f, indent=4)
|
||||
# check if write was sucessfull
|
||||
|
||||
# check if write was sucessful
|
||||
with open(path, "r") as f:
|
||||
c = json.load(f)
|
||||
|
||||
# if not - move back backup
|
||||
except Exception as e:
|
||||
# remove damaged file
|
||||
|
|
|
|||
|
|
@ -133,8 +133,14 @@ def init_app(app, hwibridge=False, specter=None):
|
|||
def user_loader(id):
|
||||
return specter.user_manager.get_user(id)
|
||||
|
||||
def login(id):
|
||||
login_user(user_loader(id))
|
||||
def login(id, password: str = None):
|
||||
user = user_loader(id)
|
||||
login_user(user)
|
||||
|
||||
if password:
|
||||
# Use the password while we have it to decrypt any protected
|
||||
# user data (e.g. services).
|
||||
user.decrypt_user_secret(password)
|
||||
|
||||
app.login = login
|
||||
# Attach specter instance so child views (e.g. hwi) can access it
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ def login():
|
|||
app.login("admin")
|
||||
app.logger.info("AUDIT: Successful Login no credentials")
|
||||
return redirect_login(request)
|
||||
|
||||
if auth["method"] == "rpcpasswordaspin":
|
||||
# TODO: check the password via RPC-call
|
||||
if (
|
||||
|
|
@ -42,11 +43,12 @@ def login():
|
|||
or not app.specter.default_node.rpc.test_connection()
|
||||
):
|
||||
if app.specter.default_node.password == request.form["password"]:
|
||||
app.login("admin")
|
||||
app.login("admin", request.form["password"])
|
||||
app.logger.info(
|
||||
"AUDIT: Successfull Login via RPC-credentials (node disconnected)"
|
||||
)
|
||||
return redirect_login(request)
|
||||
|
||||
flash(
|
||||
_(
|
||||
"We could not check your password, maybe Bitcoin Core is not running or not configured?"
|
||||
|
|
@ -65,23 +67,26 @@ def login():
|
|||
rpc = app.specter.default_node.rpc.clone()
|
||||
rpc.password = request.form["password"]
|
||||
if rpc.test_connection():
|
||||
app.login("admin")
|
||||
app.login("admin", request.form["password"])
|
||||
app.logger.info("AUDIT: Successfull Login via RPC-credentials")
|
||||
return redirect_login(request)
|
||||
|
||||
elif auth["method"] == "passwordonly":
|
||||
password = request.form["password"]
|
||||
if verify_password(app.specter.user_manager.admin.password, password):
|
||||
app.login("admin")
|
||||
if verify_password(app.specter.user_manager.admin.password_hash, password):
|
||||
app.login("admin", request.form["password"])
|
||||
return redirect_login(request)
|
||||
|
||||
elif auth["method"] == "usernamepassword":
|
||||
# TODO: This way both "User" and "user" will pass as usernames, should there be strict check on that here? Or should we keep it like this?
|
||||
username = request.form["username"]
|
||||
password = request.form["password"]
|
||||
user = app.specter.user_manager.get_user_by_username(username)
|
||||
if user:
|
||||
if verify_password(user.password, password):
|
||||
app.login(user.id)
|
||||
if verify_password(user.password_hash, password):
|
||||
app.login(user.id, request.form["password"])
|
||||
return redirect_login(request)
|
||||
|
||||
# Either invalid method or incorrect credentials
|
||||
flash(_("Invalid username or password"), "error")
|
||||
app.logger.info("AUDIT: Invalid password login attempt")
|
||||
|
|
@ -141,9 +146,14 @@ def register():
|
|||
"explorers": {"main": "", "test": "", "regtest": "", "signet": ""},
|
||||
"hwi_bridge_url": "/hwi/api/",
|
||||
}
|
||||
password_hash = hash_password(password)
|
||||
user = User(user_id, username, password_hash, config, app.specter)
|
||||
app.specter.user_manager.add_user(user)
|
||||
|
||||
user = app.specter.user_manager.create_user(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
plaintext_password=password,
|
||||
config=config,
|
||||
)
|
||||
|
||||
flash(
|
||||
_(
|
||||
"You have registered successfully, \
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from ..helpers import (
|
|||
)
|
||||
from ..persistence import write_devices, write_wallet
|
||||
from ..specter_error import ExtProcTimeoutException, handle_exception
|
||||
from ..user import hash_password
|
||||
from ..util.sha256sum import sha256sum
|
||||
from ..util.shell import get_last_lines_from_file
|
||||
from ..util.tor import start_hidden_service, stop_hidden_services
|
||||
|
|
@ -398,10 +397,12 @@ def auth():
|
|||
else:
|
||||
specter_username = None
|
||||
specter_password = None
|
||||
|
||||
if current_user.is_admin:
|
||||
method = request.form["method"]
|
||||
rate_limit = request.form["rate_limit"]
|
||||
registration_link_timeout = request.form["registration_link_timeout"]
|
||||
|
||||
min_chars = int(auth["password_min_chars"])
|
||||
if specter_username:
|
||||
if current_user.username != specter_username:
|
||||
|
|
@ -439,34 +440,37 @@ def auth():
|
|||
current_version=current_version,
|
||||
rand=rand,
|
||||
)
|
||||
current_user.password = hash_password(specter_password)
|
||||
current_user.set_password(specter_password)
|
||||
current_user.save_info()
|
||||
if current_user.is_admin:
|
||||
app.specter.update_auth(method, rate_limit, registration_link_timeout)
|
||||
if method in ["rpcpasswordaspin", "passwordonly", "usernamepassword"]:
|
||||
if method == "passwordonly":
|
||||
new_password = request.form.get("specter_password_only", "")
|
||||
if new_password:
|
||||
if len(new_password) < min_chars:
|
||||
flash(
|
||||
_(
|
||||
"Please enter a password of a least {} characters"
|
||||
).format(min_chars),
|
||||
"error",
|
||||
)
|
||||
return render_template(
|
||||
"settings/auth_settings.jinja",
|
||||
method=method,
|
||||
rate_limit=rate_limit,
|
||||
registration_link_timeout=registration_link_timeout,
|
||||
users=users,
|
||||
specter=app.specter,
|
||||
current_version=current_version,
|
||||
rand=rand,
|
||||
)
|
||||
new_password = request.form.get("specter_password_only")
|
||||
if new_password and len(new_password) < min_chars:
|
||||
flash(
|
||||
_(
|
||||
"Please enter a password of a least {} characters"
|
||||
).format(min_chars),
|
||||
"error",
|
||||
)
|
||||
return render_template(
|
||||
"settings/auth_settings.jinja",
|
||||
method=method,
|
||||
rate_limit=rate_limit,
|
||||
registration_link_timeout=registration_link_timeout,
|
||||
users=users,
|
||||
specter=app.specter,
|
||||
current_version=current_version,
|
||||
rand=rand,
|
||||
)
|
||||
elif not new_password:
|
||||
# Set to the default
|
||||
new_password = "admin"
|
||||
|
||||
current_user.set_password(new_password)
|
||||
current_user.save_info()
|
||||
|
||||
current_user.password = hash_password(new_password)
|
||||
current_user.save_info()
|
||||
if method == "usernamepassword":
|
||||
users = [
|
||||
user
|
||||
|
|
@ -481,6 +485,7 @@ def auth():
|
|||
app.config["LOGIN_DISABLED"] = True
|
||||
|
||||
app.specter.check()
|
||||
|
||||
elif action == "adduser":
|
||||
if current_user.is_admin:
|
||||
new_otp = secrets.token_urlsafe(16)
|
||||
|
|
@ -510,10 +515,12 @@ def auth():
|
|||
_("Error: Only the admin account can issue new registration links"),
|
||||
"error",
|
||||
)
|
||||
|
||||
elif action == "deleteuser":
|
||||
delete_user = request.form["deleteuser"]
|
||||
user = app.specter.user_manager.get_user(delete_user)
|
||||
if current_user.is_admin:
|
||||
# TODO: delete should be done by UserManager
|
||||
app.specter.delete_user(user)
|
||||
users.remove(user)
|
||||
flash(
|
||||
|
|
@ -521,6 +528,7 @@ def auth():
|
|||
)
|
||||
else:
|
||||
flash(_("Error: Only the admin account can delete users"), "error")
|
||||
|
||||
return render_template(
|
||||
"settings/auth_settings.jinja",
|
||||
method=method,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,31 @@
|
|||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import hashlib
|
||||
import binascii
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from flask_login import UserMixin
|
||||
from .specter_error import SpecterError
|
||||
|
||||
from .specter_error import SpecterError, handle_exception
|
||||
from .persistence import read_json_file, write_json_file, delete_folder
|
||||
from .managers.wallet_manager import WalletManager
|
||||
from .managers.device_manager import DeviceManager
|
||||
from .helpers import deep_update
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
def hash_password(plaintext_password):
|
||||
"""Hash a password for storing."""
|
||||
salt = binascii.b2a_base64(hashlib.sha256(os.urandom(60)).digest()).strip()
|
||||
pwdhash = (
|
||||
binascii.b2a_base64(
|
||||
hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 10000)
|
||||
hashlib.pbkdf2_hmac(
|
||||
"sha256", plaintext_password.encode("utf-8"), salt, 10000
|
||||
)
|
||||
)
|
||||
.strip()
|
||||
.decode()
|
||||
|
|
@ -36,17 +45,68 @@ def verify_password(stored_password, provided_password):
|
|||
|
||||
|
||||
class User(UserMixin):
|
||||
def __init__(self, id, username, password, config, specter, is_admin=False):
|
||||
"""
|
||||
The user_secret is used to encrypt/decrypt other user-specific data
|
||||
(e.g. services data). It is encrypted for storage using the user's
|
||||
password (the method == "none" auth type cannot enable options that
|
||||
require encrypted storage because it has no password). user_secret is
|
||||
decrypted on login (see: SpecterFlask.login()) and stored in memory
|
||||
in plaintext.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
config,
|
||||
specter,
|
||||
encrypted_user_secret=None,
|
||||
is_admin=False,
|
||||
services=None,
|
||||
):
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.password_hash = password_hash
|
||||
self.config = config
|
||||
self.encrypted_user_secret = encrypted_user_secret
|
||||
self.plaintext_user_secret = None
|
||||
self.is_admin = is_admin
|
||||
self.uid = specter.config["uid"]
|
||||
self.specter = specter
|
||||
self.wallet_manager = None
|
||||
self.device_manager = None
|
||||
self.manager = None
|
||||
self.services = services
|
||||
|
||||
# Iterations will need to be increased over time to keep ahead of CPU advances.
|
||||
self.encryption_iterations = 390000
|
||||
|
||||
# TODO: User obj instantiation belongs in UserManager
|
||||
@classmethod
|
||||
def from_json(cls, user_dict, specter):
|
||||
try:
|
||||
user_args = {
|
||||
"id": user_dict["id"],
|
||||
"username": user_dict["username"],
|
||||
"password_hash": user_dict[
|
||||
"password"
|
||||
], # TODO: Migrate attr name to "password_hash"?
|
||||
"config": {},
|
||||
"specter": specter,
|
||||
"encrypted_user_secret": user_dict.get("encrypted_user_secret", None),
|
||||
"services": user_dict.get("services", None),
|
||||
}
|
||||
if not user_dict["is_admin"]:
|
||||
user_args["config"] = user_dict["config"]
|
||||
return cls(**user_args)
|
||||
else:
|
||||
user_args["is_admin"] = True
|
||||
return cls(**user_args)
|
||||
|
||||
except Exception as e:
|
||||
handle_exception(e)
|
||||
raise SpecterError(f"Unable to parse user JSON.:{e}")
|
||||
|
||||
@property
|
||||
def folder_id(self):
|
||||
|
|
@ -54,51 +114,85 @@ class User(UserMixin):
|
|||
return ""
|
||||
return f"_{self.id}"
|
||||
|
||||
@property
|
||||
def password(self):
|
||||
return self._password
|
||||
def _encrypt_user_secret(self, plaintext_password):
|
||||
"""
|
||||
Implementation taken from the pyca/cryptography docs:
|
||||
https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet
|
||||
"""
|
||||
salt = os.urandom(16)
|
||||
|
||||
@password.setter
|
||||
def password(self, value):
|
||||
"""pass a json or a plain-password here"""
|
||||
try:
|
||||
if value.get("salt") and value.get("pwdhash"):
|
||||
self._password = value
|
||||
except:
|
||||
salted_hashed_password = hash_password(value)
|
||||
self._password = salted_hashed_password
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=self.encryption_iterations,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(plaintext_password.encode()))
|
||||
f = Fernet(key)
|
||||
token = f.encrypt(self.plaintext_user_secret)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, user_dict, specter):
|
||||
# TODO: Unify admin in backwards compatible way
|
||||
try:
|
||||
if not user_dict["is_admin"]:
|
||||
return cls(
|
||||
user_dict["id"],
|
||||
user_dict["username"],
|
||||
user_dict["password"],
|
||||
user_dict["config"],
|
||||
specter,
|
||||
self.encrypted_user_secret = {
|
||||
"token": token.decode(),
|
||||
"salt": base64.b64encode(salt).decode(),
|
||||
"iterations": self.encryption_iterations,
|
||||
}
|
||||
|
||||
def decrypt_user_secret(self, plaintext_password):
|
||||
# see: https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet
|
||||
if not self.encrypted_user_secret:
|
||||
self._generate_user_secret(plaintext_password)
|
||||
|
||||
token = self.encrypted_user_secret["token"].encode()
|
||||
salt = base64.b64decode(self.encrypted_user_secret["salt"])
|
||||
iterations = self.encrypted_user_secret["iterations"]
|
||||
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=iterations,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(plaintext_password.encode()))
|
||||
f = Fernet(key)
|
||||
|
||||
self.plaintext_user_secret = f.decrypt(token)
|
||||
|
||||
# If this encrypted_user_secret has an outdated (weaker) number of iterations,
|
||||
# re-encrypt with the current higher iteration count.
|
||||
if iterations < self.encryption_iterations:
|
||||
self._encrypt_user_secret(plaintext_password)
|
||||
|
||||
def _generate_user_secret(self, plaintext_password):
|
||||
# Encryption using the user_secret uses a Fernet key. But the Fernet
|
||||
# key itself will be encrypted with the user's password.
|
||||
self.plaintext_user_secret = Fernet.generate_key()
|
||||
self._encrypt_user_secret(plaintext_password)
|
||||
self.save_info()
|
||||
|
||||
def set_password(self, plaintext_password):
|
||||
# Hash the incoming plaintext password and update the encrypted
|
||||
# user_secret as needed.
|
||||
self.password_hash = hash_password(plaintext_password)
|
||||
|
||||
# Must keep encrypted_user_secret in sync with password changes
|
||||
if self.encrypted_user_secret is None:
|
||||
self._generate_user_secret(plaintext_password)
|
||||
else:
|
||||
if self.plaintext_user_secret is None:
|
||||
raise Exception(
|
||||
"encrypted_user_secret wasn't decrypted during user login"
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
user_dict["id"],
|
||||
user_dict["username"],
|
||||
user_dict["password"],
|
||||
{},
|
||||
specter,
|
||||
is_admin=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise SpecterError(f"Unable to parse user JSON.:{e}")
|
||||
self._encrypt_user_secret(plaintext_password)
|
||||
|
||||
@property
|
||||
def json(self):
|
||||
user_dict = {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
"password": self.password_hash, # TODO: Migrate attr name to "password_hash"?
|
||||
"is_admin": self.is_admin,
|
||||
"encrypted_user_secret": self.encrypted_user_secret,
|
||||
"services": self.services,
|
||||
}
|
||||
if not self.is_admin:
|
||||
user_dict["config"] = self.config
|
||||
|
|
@ -147,6 +241,7 @@ class User(UserMixin):
|
|||
else:
|
||||
self.device_manager.update(data_folder=devices_folder)
|
||||
|
||||
# TODO: Refactor this into UserManager
|
||||
def save_info(self, delete=False):
|
||||
if self.manager is None:
|
||||
self.manager = self.specter.user_manager
|
||||
|
|
@ -155,11 +250,14 @@ class User(UserMixin):
|
|||
|
||||
# update specter users
|
||||
if not existing and not delete:
|
||||
self.specter.add_user(self)
|
||||
self.specter.user_manager.add_user(self)
|
||||
if existing and delete:
|
||||
self.specter.delete_user(self)
|
||||
self.manager.save()
|
||||
|
||||
# TODO: Refactor calling code to explicitly call User.save() rather than embedding
|
||||
# self.save_info() on every update and setter. It ends up saving to disk multiple
|
||||
# times for a single Settings submit.
|
||||
def update_asset_label(self, asset, label, chain):
|
||||
if "asset_labels" not in self.config:
|
||||
self.config["asset_labels"] = {}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from cryptoadvance.specter.rpc import BitcoinRPC
|
|||
from cryptoadvance.specter.server import create_app, init_app
|
||||
from cryptoadvance.specter.specter import Specter
|
||||
from cryptoadvance.specter.specter_error import SpecterError
|
||||
from cryptoadvance.specter.user import User
|
||||
from cryptoadvance.specter.user import User, hash_password
|
||||
from cryptoadvance.specter.util.wallet_importer import WalletImporter
|
||||
from cryptoadvance.specter.util.common import str2bool
|
||||
from cryptoadvance.specter.util.shell import which
|
||||
|
|
@ -388,16 +388,17 @@ def specter_regtest_configured(bitcoin_regtest, devices_filled_data_folder):
|
|||
specter = Specter(data_folder=devices_filled_data_folder, config=config)
|
||||
assert specter.chain == "regtest"
|
||||
# Create a User
|
||||
someuser: User = specter.user_manager.add_user(
|
||||
someuser = specter.user_manager.add_user(
|
||||
User.from_json(
|
||||
{
|
||||
user_dict={
|
||||
"id": "someuser",
|
||||
"username": "someuser",
|
||||
"password": "somepassword",
|
||||
"password": hash_password("somepassword"),
|
||||
"config": {},
|
||||
"is_admin": False,
|
||||
"services": None,
|
||||
},
|
||||
specter,
|
||||
specter=specter,
|
||||
)
|
||||
)
|
||||
specter.user_manager.save()
|
||||
|
|
|
|||
68
tests/test_genericdata_storage.py
Normal file
68
tests/test_genericdata_storage.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import json, logging, pytest
|
||||
from cryptoadvance.specter.managers.genericdata_manager import GenericDataManager
|
||||
from cryptoadvance.specter.specter import Specter
|
||||
from cryptoadvance.specter.user import User, hash_password
|
||||
|
||||
|
||||
class ExampleStorage(GenericDataManager):
|
||||
"""
|
||||
Pretend storage class just for testing
|
||||
"""
|
||||
|
||||
encrypted_fields = [
|
||||
"testfield2",
|
||||
]
|
||||
|
||||
|
||||
def test_storage_field_encrypt_decrypt(empty_data_folder):
|
||||
"""
|
||||
Storage class should be able to use the associated User's decrypted user_secret
|
||||
to encrypt and decrypt the specified encrypted_fields to and from on-disk
|
||||
json. When loaded into memory, all fields -- whether encrypted or not -- should
|
||||
be plaintext readable.
|
||||
"""
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
|
||||
password = "somepassword"
|
||||
user = User.from_json(
|
||||
user_dict={
|
||||
"id": "someuser",
|
||||
"username": "someuser",
|
||||
"password": hash_password("somepassword"),
|
||||
"config": {},
|
||||
"is_admin": False,
|
||||
"services": None,
|
||||
},
|
||||
specter=specter,
|
||||
)
|
||||
|
||||
# User must provide their password in order to decrypt their user_secret which is
|
||||
# then used to decrypt/encrypt their service storage
|
||||
user.decrypt_user_secret(password)
|
||||
|
||||
storage = ExampleStorage(
|
||||
data_folder=specter.data_folder, encryption_key=user.plaintext_user_secret
|
||||
)
|
||||
storage.data["testfield1"] = "This data is not encrypted"
|
||||
storage.data["testfield2"] = "This data WILL BE encrypted"
|
||||
storage._save()
|
||||
|
||||
# Read the resulting storage file
|
||||
with open(storage.data_file, "r") as storage_json_file:
|
||||
data_on_disk = json.load(storage_json_file)
|
||||
print(data_on_disk)
|
||||
|
||||
# Plaintext fields are readable...
|
||||
assert data_on_disk["testfield1"] == storage.data["testfield1"]
|
||||
|
||||
# ...while encrypted fields are not
|
||||
assert data_on_disk["testfield2"] != storage.data["testfield2"]
|
||||
|
||||
# Re-instantiate the storage so it has to load from the saved file...
|
||||
storage_2 = ExampleStorage(
|
||||
data_folder=specter.data_folder, encryption_key=user.plaintext_user_secret
|
||||
)
|
||||
|
||||
# ...and verify the field decryption
|
||||
assert storage_2.data["testfield2"] == storage.data["testfield2"]
|
||||
assert data_on_disk["testfield2"] != storage_2.data["testfield2"]
|
||||
165
tests/test_user.py
Normal file
165
tests/test_user.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import json, logging, pytest
|
||||
from cryptoadvance.specter.specter import Specter
|
||||
from cryptoadvance.specter.user import User, hash_password, verify_password
|
||||
from cryptoadvance.specter.managers.user_manager import UserManager
|
||||
|
||||
|
||||
def test_password_hash():
|
||||
"""
|
||||
verify_password should succeed when presented with hash_password and the same
|
||||
password that was hashed. It should fail when given a different password.
|
||||
"""
|
||||
password = "somepassword"
|
||||
password_hash = hash_password(password)
|
||||
assert verify_password(password_hash, password)
|
||||
assert not verify_password(password_hash, "wrongpassword")
|
||||
|
||||
|
||||
def test_generate_user_secret_on_decrypt_user_secret(empty_data_folder):
|
||||
"""
|
||||
Should generate a user_secret if one does not yet exist when decrypt_user_secret
|
||||
is called (happens during the login flow).
|
||||
"""
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
|
||||
password = "somepassword"
|
||||
user = User.from_json(
|
||||
user_dict={
|
||||
"id": "someuser",
|
||||
"username": "someuser",
|
||||
"password": hash_password(password),
|
||||
"config": {},
|
||||
"is_admin": False,
|
||||
"services": None,
|
||||
},
|
||||
specter=specter,
|
||||
)
|
||||
|
||||
assert user.encrypted_user_secret is None
|
||||
assert user.plaintext_user_secret is None
|
||||
|
||||
# Even though there's no user_secret yet, the flow calls decrypt anyway...
|
||||
user.decrypt_user_secret(password)
|
||||
|
||||
# ...and a new user_secret is created and stored encrypted and plaintext
|
||||
assert user.encrypted_user_secret is not None
|
||||
assert user.plaintext_user_secret is not None
|
||||
|
||||
|
||||
def test_generate_user_secret_on_set_password(empty_data_folder):
|
||||
"""
|
||||
Should generate a user_secret if one does not yet exist when the User's password
|
||||
is changed via set_password.
|
||||
"""
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
|
||||
password = "somepassword"
|
||||
user = User.from_json(
|
||||
user_dict={
|
||||
"id": "someuser",
|
||||
"username": "someuser",
|
||||
"password": hash_password(password),
|
||||
"config": {},
|
||||
"is_admin": False,
|
||||
"services": None,
|
||||
},
|
||||
specter=specter,
|
||||
)
|
||||
|
||||
assert user.encrypted_user_secret is None
|
||||
assert user.plaintext_user_secret is None
|
||||
|
||||
new_password = "mynewpassphrase"
|
||||
user.set_password(new_password)
|
||||
|
||||
assert user.encrypted_user_secret is not None
|
||||
assert user.plaintext_user_secret is not None
|
||||
|
||||
# Reset the plaintext user_secret and test decryption
|
||||
user.plaintext_user_secret = None
|
||||
user.decrypt_user_secret(new_password)
|
||||
|
||||
assert user.plaintext_user_secret is not None
|
||||
|
||||
|
||||
def test_reencrypt_user_secret_on_set_password(empty_data_folder):
|
||||
"""
|
||||
Should re-encrypt the user_secret when the user changes their password.
|
||||
"""
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
|
||||
password = "somepassword"
|
||||
user = User.from_json(
|
||||
user_dict={
|
||||
"id": "someuser",
|
||||
"username": "someuser",
|
||||
"password": hash_password(password),
|
||||
"config": {},
|
||||
"is_admin": False,
|
||||
"services": None,
|
||||
},
|
||||
specter=specter,
|
||||
)
|
||||
|
||||
# Force generation of a new user_secret
|
||||
user.decrypt_user_secret(password)
|
||||
assert user.encrypted_user_secret is not None
|
||||
assert user.plaintext_user_secret is not None
|
||||
|
||||
first_encrypted_user_secret = user.encrypted_user_secret
|
||||
first_plaintext_user_secret = user.plaintext_user_secret
|
||||
|
||||
new_password = "mynewpassphrase"
|
||||
user.set_password(new_password)
|
||||
|
||||
# The new encrypted_user_secret will be different...
|
||||
assert first_encrypted_user_secret != user.encrypted_user_secret
|
||||
|
||||
# ...but the plaintext_user_secret remains unchanged
|
||||
assert first_plaintext_user_secret == user.plaintext_user_secret
|
||||
|
||||
|
||||
def test_reencrypt_user_secret_on_iterations_increase(empty_data_folder):
|
||||
"""
|
||||
Should re-encrypt the user_secret when the User.encryption_iterations is increased
|
||||
"""
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
|
||||
password = "somepassword"
|
||||
user = User.from_json(
|
||||
user_dict={
|
||||
"id": "someuser",
|
||||
"username": "someuser",
|
||||
"password": hash_password(password),
|
||||
"config": {},
|
||||
"is_admin": False,
|
||||
"services": None,
|
||||
},
|
||||
specter=specter,
|
||||
)
|
||||
|
||||
# Override current default iterations setting
|
||||
original_encryption_iterations = user.encryption_iterations
|
||||
user.encryption_iterations -= 10000
|
||||
|
||||
# Force generation of a new user_secret
|
||||
user.decrypt_user_secret(password)
|
||||
assert user.encrypted_user_secret is not None
|
||||
assert user.plaintext_user_secret is not None
|
||||
assert user.encrypted_user_secret["iterations"] < original_encryption_iterations
|
||||
|
||||
first_encrypted_user_secret = user.encrypted_user_secret
|
||||
first_plaintext_user_secret = user.plaintext_user_secret
|
||||
|
||||
# Reset iterations to default
|
||||
user.encryption_iterations = original_encryption_iterations
|
||||
|
||||
# On decrypt, should automatically re-encrypt the `user_secret`
|
||||
user.decrypt_user_secret(password)
|
||||
assert user.encrypted_user_secret["iterations"] == original_encryption_iterations
|
||||
|
||||
# The new encrypted_user_secret will be different...
|
||||
assert first_encrypted_user_secret != user.encrypted_user_secret
|
||||
|
||||
# ...but the plaintext_user_secret remains unchanged
|
||||
assert first_plaintext_user_secret == user.plaintext_user_secret
|
||||
33
tests/test_user_manager.py
Normal file
33
tests/test_user_manager.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import json
|
||||
from cryptoadvance.specter.specter import Specter
|
||||
from cryptoadvance.specter.managers.user_manager import UserManager
|
||||
|
||||
|
||||
def test_create_user(empty_data_folder):
|
||||
"""
|
||||
Should add a new User to the `users` list, generate a user_secret, and write
|
||||
the new User to json storage.
|
||||
"""
|
||||
specter = Specter(data_folder=empty_data_folder)
|
||||
user_manager = UserManager(specter=specter)
|
||||
|
||||
password = "somepassword"
|
||||
user_id = "someuser"
|
||||
username = "someuser"
|
||||
config = {}
|
||||
|
||||
user = user_manager.create_user(
|
||||
user_id=user_id, username=username, plaintext_password=password, config=config
|
||||
)
|
||||
|
||||
# new User was added to `users`
|
||||
assert user in user_manager.users
|
||||
|
||||
# Generated a `user_secret`
|
||||
assert user.encrypted_user_secret is not None
|
||||
assert user.plaintext_user_secret is not None
|
||||
|
||||
# Already written to persistent storage
|
||||
with open(user_manager.users_file) as user_json_file:
|
||||
user_json = json.load(user_json_file)
|
||||
assert user_id in [u.get("id") for u in user_json]
|
||||
Loading…
Add table
Add a link
Reference in a new issue