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:
kdmukai 2021-11-11 08:25:00 -06:00 committed by GitHub
parent 659baad94f
commit 997e2318d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 662 additions and 195 deletions

View file

@ -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`

View file

@ -22,3 +22,4 @@ flask_wtf==0.14.3
pgpy==0.5.4
cbor==1.0.0
mnemonic==0.20
cryptography==3.4.7

View file

@ -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

View file

@ -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):

View file

@ -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):

View file

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

View 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"""

View file

@ -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

View 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

View file

@ -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, \

View file

@ -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,

View file

@ -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"] = {}

View file

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

View 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
View 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

View 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]