mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 15:28:44 +00:00
Compare commits
783 Commits
publish
...
1b6b0ab5c5
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1b6b0ab5c5 | ||
![]() |
87999b1888 | ||
![]() |
6928b4ddbf | ||
![]() |
73183d53a5 | ||
![]() |
c9ad16f150 | ||
![]() |
bd86bdbb3a | ||
![]() |
8d5374ef5b | ||
![]() |
468608a369 | ||
![]() |
56e652089a | ||
![]() |
c353c04472 | ||
![]() |
2559920a14 | ||
![]() |
57935bdfc1 | ||
![]() |
55fdee522c | ||
![]() |
af4eb72385 | ||
![]() |
90c304ff6e | ||
![]() |
7b1ef2abe2 | ||
![]() |
5194adf145 | ||
![]() |
8f74ac27f4 | ||
![]() |
1232630dba | ||
![]() |
62983df69c | ||
![]() |
b4238791aa | ||
![]() |
d1fccbc4f2 | ||
![]() |
50532597b8 | ||
![]() |
bb733bb194 | ||
![]() |
785acf938c | ||
![]() |
4973095a5c | ||
![]() |
69f1619816 | ||
![]() |
e1b821bc55 | ||
![]() |
a21efa91db | ||
![]() |
5109f96ce7 | ||
![]() |
19577163cf | ||
![]() |
b0e4ab9bc6 | ||
![]() |
3ff3e4e1d6 | ||
![]() |
08c4453326 | ||
![]() |
fddc169433 | ||
![]() |
28f552313f | ||
![]() |
294eef9725 | ||
![]() |
5d9281156b | ||
![]() |
c4297731b9 | ||
![]() |
f4df398738 | ||
![]() |
b0a2f17cc8 | ||
![]() |
b9525db9ae | ||
![]() |
199d02ab72 | ||
![]() |
c1a018e484 | ||
![]() |
4a537b7063 | ||
![]() |
d7ff7b2354 | ||
![]() |
f57bfcd7fa | ||
![]() |
d390bf8620 | ||
![]() |
fdd2530635 | ||
![]() |
fd9d3fa51b | ||
![]() |
a5f719363e | ||
![]() |
4c4394b026 | ||
![]() |
75df28dd60 | ||
![]() |
ccdc442bb8 | ||
![]() |
d492f76116 | ||
![]() |
064292df01 | ||
![]() |
1d91044e59 | ||
![]() |
c95a69e562 | ||
![]() |
593b173e95 | ||
![]() |
3ef0446e26 | ||
![]() |
b33dc5148d | ||
![]() |
344c2c82e7 | ||
![]() |
d98a158c83 | ||
![]() |
8737905e93 | ||
![]() |
d2c00eb0d6 | ||
![]() |
171f92167e | ||
![]() |
bbfe0c50a9 | ||
![]() |
61e3c2accc | ||
![]() |
00edb44442 | ||
![]() |
ba3c57ceb8 | ||
![]() |
28382cc649 | ||
![]() |
38a392a7c9 | ||
![]() |
8c9e325c76 | ||
![]() |
d77fb142a0 | ||
![]() |
bfc181c32b | ||
![]() |
26a4a74131 | ||
![]() |
5fe1b651a8 | ||
![]() |
051454ff2e | ||
![]() |
87f1e35487 | ||
![]() |
45304b41c2 | ||
![]() |
b15c0c17b7 | ||
![]() |
173a697b88 | ||
![]() |
5cdb4ecac5 | ||
![]() |
d2b8b6cb65 | ||
![]() |
7521014a61 | ||
![]() |
0e0ea183c8 | ||
![]() |
48e0632771 | ||
![]() |
d92385eff9 | ||
![]() |
92142a3e1b | ||
![]() |
ed7763195e | ||
![]() |
8079cd05b9 | ||
![]() |
979ba6f678 | ||
![]() |
af53e7f12c | ||
![]() |
54314cc5b3 | ||
![]() |
fd419fb943 | ||
![]() |
f571ded60c | ||
![]() |
b3b703985d | ||
![]() |
363b54b656 | ||
![]() |
1a35bb42bd | ||
![]() |
8cc2e75741 | ||
![]() |
edcf2787ee | ||
![]() |
072db52650 | ||
![]() |
a864da5751 | ||
![]() |
37a1d4b4cf | ||
![]() |
f0a7fb7da1 | ||
![]() |
b49e37b6e1 | ||
![]() |
94d0b80dce | ||
![]() |
4f11db5aa4 | ||
![]() |
099c24921f | ||
![]() |
7725701b50 | ||
![]() |
b795d1236a | ||
![]() |
6888fa2431 | ||
![]() |
1870614d8a | ||
![]() |
34f19e1b2b | ||
![]() |
41848fbcc3 | ||
![]() |
2aae6db22d | ||
![]() |
f36c12122e | ||
![]() |
68eaa34d76 | ||
![]() |
c2d80aa438 | ||
![]() |
87cf2d837b | ||
![]() |
ade2d99572 | ||
![]() |
91bea60928 | ||
![]() |
dc7673c7e0 | ||
![]() |
726a8f7aa4 | ||
![]() |
181f486afb | ||
![]() |
5e8375aad5 | ||
![]() |
20ee8a891b | ||
![]() |
fa4826fe2d | ||
![]() |
90b60a6682 | ||
![]() |
3744cf9f30 | ||
![]() |
2949cc22c9 | ||
![]() |
9c5e6a12a0 | ||
![]() |
89cbef1aa4 | ||
![]() |
d21ad78a02 | ||
![]() |
6260e81eaa | ||
![]() |
a78d587307 | ||
![]() |
19881dbeeb | ||
![]() |
224143eb76 | ||
![]() |
1f669746db | ||
![]() |
0d883b2736 | ||
![]() |
6a20728db4 | ||
![]() |
8e703e3282 | ||
![]() |
9cc7e4d0d7 | ||
![]() |
036e2e59be | ||
![]() |
3823603712 | ||
![]() |
f16a771a6c | ||
![]() |
1a194aec04 | ||
![]() |
f70f70e749 | ||
![]() |
4d7f28b400 | ||
![]() |
054ffd3383 | ||
![]() |
2b22fd7d5e | ||
![]() |
9cfd40ce7b | ||
![]() |
fdfdbc883b | ||
![]() |
264caff711 | ||
![]() |
b03530afba | ||
![]() |
8b8416c09f | ||
![]() |
9d71db0cf2 | ||
![]() |
68d8e03927 | ||
![]() |
0dda7ebe5b | ||
![]() |
9dbe22d332 | ||
![]() |
6d110679c5 | ||
![]() |
30da26f086 | ||
![]() |
d58c836fe6 | ||
![]() |
c64ca912b8 | ||
![]() |
f8f43dc2b5 | ||
![]() |
b40a7416ab | ||
![]() |
b5024d99de | ||
![]() |
aeee3b91d9 | ||
![]() |
292b443158 | ||
![]() |
7fc098e8f2 | ||
![]() |
42f9f0c4bb | ||
![]() |
bc8307f611 | ||
![]() |
bf129e5dca | ||
![]() |
2b959aa33f | ||
![]() |
cc077a9762 | ||
![]() |
d7a39c88d3 | ||
![]() |
1ca84ba946 | ||
![]() |
738667ca2d | ||
![]() |
6fa9f0839e | ||
![]() |
2f95944318 | ||
![]() |
6a31ec7e99 | ||
![]() |
f03a890776 | ||
![]() |
942cb1d89a | ||
![]() |
e655369eee | ||
![]() |
1301b79279 | ||
![]() |
e5ebfdcad4 | ||
![]() |
8e78a72257 | ||
![]() |
041e40bc1b | ||
![]() |
49675211e4 | ||
![]() |
30261094d2 | ||
![]() |
911fd6705d | ||
![]() |
7bb67030cb | ||
![]() |
8568e38d36 | ||
![]() |
675adfb84b | ||
![]() |
f0f7aee9e6 | ||
![]() |
aa688bc49a | ||
![]() |
77c4c33818 | ||
![]() |
d868d2204b | ||
![]() |
3a19ef9c2a | ||
![]() |
68341db0fe | ||
![]() |
3dc10ae448 | ||
![]() |
23a3ae3928 | ||
![]() |
f664a6c40f | ||
![]() |
44ce005cdc | ||
![]() |
01fe849f90 | ||
![]() |
d75cc760d3 | ||
![]() |
42aa945b00 | ||
![]() |
a9c5deb800 | ||
![]() |
b72452a734 | ||
![]() |
2c44f51fc4 | ||
![]() |
59c06041fd | ||
![]() |
b0db9806b3 | ||
![]() |
6f885bd65e | ||
![]() |
c3ed4c08ee | ||
![]() |
68f47052c3 | ||
![]() |
a16310b04b | ||
![]() |
1e544a7d41 | ||
![]() |
cb37783354 | ||
![]() |
5423c41b06 | ||
![]() |
2794b67d83 | ||
![]() |
aad41929bf | ||
![]() |
4f09ad5c26 | ||
![]() |
3cdf391742 | ||
![]() |
032caed3d0 | ||
![]() |
2294656f36 | ||
![]() |
9d9f8a8bae | ||
![]() |
9d80f7b607 | ||
![]() |
e5f1158101 | ||
![]() |
c7df96aac5 | ||
![]() |
5acd1d489d | ||
![]() |
f66e8b4776 | ||
![]() |
10a03384d0 | ||
![]() |
7631d32bc6 | ||
![]() |
6dabbaa31e | ||
![]() |
4228d82295 | ||
![]() |
ccca399b09 | ||
![]() |
36061493ac | ||
![]() |
f1bf65385c | ||
![]() |
906e3921a2 | ||
![]() |
7aeba78245 | ||
![]() |
087b3bd657 | ||
![]() |
186e39cc91 | ||
![]() |
8c9fe07609 | ||
![]() |
2f0eb44a44 | ||
![]() |
aeb146f862 | ||
![]() |
7f503f0787 | ||
![]() |
7a8c0aef86 | ||
![]() |
dcd095d1af | ||
![]() |
b4f792ad67 | ||
![]() |
cc8fba9f12 | ||
![]() |
20896812a4 | ||
![]() |
144751ef02 | ||
![]() |
05d4bd94b6 | ||
![]() |
9adb539b84 | ||
![]() |
64339b0c20 | ||
![]() |
4ccdd2b3df | ||
![]() |
61f1f5c02f | ||
![]() |
77757152d7 | ||
![]() |
265817b67d | ||
![]() |
e3bd669668 | ||
![]() |
f58ed03e6f | ||
![]() |
6d82ef1815 | ||
![]() |
a4ddd120c8 | ||
![]() |
47da67b37c | ||
![]() |
27977612de | ||
![]() |
ceb2eb28ad | ||
![]() |
f83234c568 | ||
![]() |
37fc2779ad | ||
![]() |
73f5a80b33 | ||
![]() |
78bf5a4c64 | ||
![]() |
db3a60fc5c | ||
![]() |
365a098d70 | ||
![]() |
7cdb67e82e | ||
![]() |
0a078bbcf4 | ||
![]() |
8a5d1717f8 | ||
![]() |
05eae9bddd | ||
![]() |
3c1f6991a4 | ||
![]() |
a7a2e70321 | ||
![]() |
3020b681b8 | ||
![]() |
d3bd3b9e0a | ||
![]() |
4e787e362e | ||
![]() |
6d6a284096 | ||
![]() |
b76112f11b | ||
![]() |
b74c0993ca | ||
![]() |
d8cb21e527 | ||
![]() |
b57e19b657 | ||
![]() |
3f169747d1 | ||
![]() |
b4d60782af | ||
![]() |
f664c4099c | ||
![]() |
7dba8e138d | ||
![]() |
cfc7e455d5 | ||
![]() |
dcb5c6e805 | ||
![]() |
d4d475438f | ||
![]() |
627d69cf30 | ||
![]() |
c15776e37e | ||
![]() |
2f7b41c5dd | ||
![]() |
bb1d692798 | ||
![]() |
b621cffae6 | ||
![]() |
3937ccfb75 | ||
![]() |
0dd5b1f301 | ||
![]() |
715952fba6 | ||
![]() |
acb1126287 | ||
![]() |
bcd8002e1d | ||
![]() |
3ad7c1ab94 | ||
![]() |
4a20817094 | ||
![]() |
d3f2cb8256 | ||
![]() |
a3d45a117c | ||
![]() |
2d39d7a5bd | ||
![]() |
f677f4b445 | ||
![]() |
5b0051f76f | ||
![]() |
0490583fee | ||
![]() |
0edf4e5c83 | ||
![]() |
01fa1f4997 | ||
![]() |
ea2451f4a0 | ||
![]() |
5eb5e29094 | ||
![]() |
747e2be5a9 | ||
![]() |
db90d9caf0 | ||
![]() |
cb1e18c8ba | ||
![]() |
cf3803c422 | ||
![]() |
24a606fb70 | ||
![]() |
5850b68c9a | ||
![]() |
529eb5a0a6 | ||
![]() |
e0318c7850 | ||
![]() |
64a84c59d7 | ||
![]() |
aaf7b79e59 | ||
![]() |
8e7224dfd2 | ||
![]() |
61ffb073b5 | ||
![]() |
3e83616a4f | ||
![]() |
85ce777333 | ||
![]() |
b70585c55e | ||
![]() |
39114b0b8a | ||
![]() |
98f8bfa679 | ||
![]() |
b7042a70db | ||
![]() |
ed3f0da731 | ||
![]() |
f5653c9bb1 | ||
![]() |
e542248778 | ||
![]() |
3b0825633c | ||
![]() |
375fd571fa | ||
![]() |
59dbb885aa | ||
![]() |
160a8fac51 | ||
![]() |
bf5281804e | ||
![]() |
557af9745a | ||
![]() |
ec52d2eda0 | ||
![]() |
49544c0200 | ||
![]() |
e6ca36b8b7 | ||
![]() |
398f8fa59f | ||
![]() |
ff1f8bb4e1 | ||
![]() |
47f26292b1 | ||
![]() |
bdf6a038c2 | ||
![]() |
411c5df4b4 | ||
![]() |
9fc117b105 | ||
![]() |
63a5cd3190 | ||
![]() |
c80495eca6 | ||
![]() |
1507ba9553 | ||
![]() |
a01e0f0037 | ||
![]() |
a9d1d2f242 | ||
![]() |
64c174c385 | ||
![]() |
04862bce48 | ||
![]() |
2832b0150e | ||
![]() |
bfe65c9707 | ||
![]() |
c1f1adbb8f | ||
![]() |
d7df6679bd | ||
![]() |
856ef3dc32 | ||
![]() |
ad3f1bc80c | ||
![]() |
78c398ba4b | ||
![]() |
a94762e8a1 | ||
![]() |
70e05970f0 | ||
![]() |
ade572815a | ||
![]() |
c6a87e000d | ||
![]() |
6855c85329 | ||
![]() |
c3e2ff4b4b | ||
![]() |
c2512319ab | ||
![]() |
fedd0c352a | ||
![]() |
4dbfde4bf5 | ||
![]() |
c2809032fd | ||
![]() |
e508ff900a | ||
![]() |
ea579aaa5d | ||
![]() |
3aa944bb49 | ||
![]() |
f31e2663b6 | ||
![]() |
a6e18ae9c5 | ||
![]() |
c6d4b937cb | ||
![]() |
615be7d325 | ||
![]() |
8561e68d36 | ||
![]() |
5fce7836d9 | ||
![]() |
3502e34428 | ||
![]() |
724c0b883f | ||
![]() |
20dfc35f7e | ||
![]() |
29690d7c7b | ||
![]() |
876d98a785 | ||
![]() |
11b3707087 | ||
![]() |
d529877888 | ||
![]() |
b42ad0561c | ||
![]() |
64664cb0bb | ||
![]() |
b0ba723bdd | ||
![]() |
5eab7f879c | ||
![]() |
ddfe17b77b | ||
![]() |
8fe79a012b | ||
![]() |
d71a4912bd | ||
![]() |
98f841790a | ||
![]() |
d89fa7f707 | ||
![]() |
3d754b50f1 | ||
![]() |
4491dd35df | ||
![]() |
e175e29813 | ||
![]() |
a83001a799 | ||
![]() |
c1f195d74f | ||
![]() |
edea2d3a6f | ||
![]() |
462cef20c3 | ||
![]() |
726e88de19 | ||
![]() |
29243a37b8 | ||
![]() |
66dfb9d205 | ||
![]() |
c83dab7793 | ||
![]() |
7ef7246361 | ||
![]() |
b8a2e29f21 | ||
![]() |
37c78f608a | ||
![]() |
c8d09d6294 | ||
![]() |
dcda452da8 | ||
![]() |
eb1ab7662d | ||
![]() |
2874bf0f82 | ||
![]() |
d93f47e3ee | ||
![]() |
bc2c22ac10 | ||
![]() |
d679d52b66 | ||
![]() |
ae26190928 | ||
![]() |
474f2d134b | ||
![]() |
9976e5473f | ||
![]() |
df0279ac03 | ||
![]() |
6512b2f501 | ||
![]() |
beb690ba72 | ||
![]() |
9ee1bfad17 | ||
![]() |
3ff9843750 | ||
![]() |
450ff06d4e | ||
![]() |
728c4be6e1 | ||
![]() |
9f3a65b5b4 | ||
![]() |
47ea11b533 | ||
![]() |
d14385c4d2 | ||
![]() |
cd0f9624ae | ||
![]() |
51233dff9b | ||
![]() |
fac31cd99f | ||
![]() |
ecf8f4e722 | ||
![]() |
6c22e28512 | ||
![]() |
025fee3045 | ||
![]() |
5019c99c11 | ||
![]() |
e7021f480f | ||
![]() |
6c54d4d8e3 | ||
![]() |
f68adf9170 | ||
![]() |
72faee02b6 | ||
![]() |
6fca250856 | ||
![]() |
8bd9a75629 | ||
![]() |
7af92195c4 | ||
![]() |
c23b2e4913 | ||
![]() |
87149517d8 | ||
![]() |
c4bb8dfa64 | ||
![]() |
0818408f48 | ||
![]() |
576437223a | ||
![]() |
e5858cba38 | ||
![]() |
c787651899 | ||
![]() |
f3c223a9a1 | ||
![]() |
dfa85ad863 | ||
![]() |
1f7c538015 | ||
![]() |
8579cf7f3d | ||
![]() |
cca03b2f2e | ||
![]() |
49a5329bf6 | ||
![]() |
28f27de8e8 | ||
![]() |
e4093d7334 | ||
![]() |
b83ec2621e | ||
![]() |
b3c7d796e1 | ||
![]() |
764631b8ba | ||
![]() |
09d1bf51fc | ||
![]() |
d415eca8bd | ||
![]() |
176ba6befd | ||
![]() |
87215a10cc | ||
![]() |
e0b253ea63 | ||
![]() |
5826e18189 | ||
![]() |
9d5593a1f5 | ||
![]() |
f7eaf2897f | ||
![]() |
96cad49e55 | ||
![]() |
182085b639 | ||
![]() |
5fdfc7ca5a | ||
![]() |
97bdd2483d | ||
![]() |
a3caf16dd4 | ||
![]() |
6336fb3fe4 | ||
![]() |
f47baf4132 | ||
![]() |
30dd09b0b4 | ||
![]() |
26f1ba4482 | ||
![]() |
479c034573 | ||
![]() |
0741744f99 | ||
![]() |
b88a93df29 | ||
![]() |
ae9e6ba0d4 | ||
![]() |
b80abff895 | ||
![]() |
387bfad220 | ||
![]() |
073b8c4d47 | ||
![]() |
e8ade741ad | ||
![]() |
ea9665383e | ||
![]() |
e5a8dde59d | ||
![]() |
78368c0e2f | ||
![]() |
c6398b3c99 | ||
![]() |
6b7815f28e | ||
![]() |
07f1843739 | ||
![]() |
04548c44f5 | ||
![]() |
144447fb3d | ||
![]() |
6f21c5cb9d | ||
![]() |
a610272552 | ||
![]() |
76bdd4fde0 | ||
![]() |
cc68f05130 | ||
![]() |
1e5d115f80 | ||
![]() |
0a011f108b | ||
![]() |
54aa609b62 | ||
![]() |
f701124fb1 | ||
![]() |
8370dec5c3 | ||
![]() |
6cca270bd6 | ||
![]() |
73898972f1 | ||
![]() |
04dc4e05da | ||
![]() |
9369bac70f | ||
![]() |
d7547810fe | ||
![]() |
bceaa99228 | ||
![]() |
f46de144a9 | ||
![]() |
23f672575e | ||
![]() |
113fd1181a | ||
![]() |
40bd009b6e | ||
![]() |
bcb38ce79f | ||
![]() |
d831f1b1a2 | ||
![]() |
dbd051a1b0 | ||
![]() |
27d8b8ffa1 | ||
![]() |
bfc0331057 | ||
![]() |
503159ff6d | ||
![]() |
22cc302288 | ||
![]() |
d10f5288c3 | ||
![]() |
5a3b80b4f6 | ||
![]() |
0bfc641815 | ||
![]() |
3e004d3932 | ||
![]() |
2705adf90b | ||
![]() |
dfa560a270 | ||
![]() |
d4b3db7386 | ||
![]() |
754dce086c | ||
![]() |
ca67cf1f92 | ||
![]() |
3de84ec484 | ||
![]() |
2eae65872f | ||
![]() |
6d24ffb2ec | ||
![]() |
e52e2629fe | ||
![]() |
3bcf3312df | ||
![]() |
a61a064d2e | ||
![]() |
fffd287032 | ||
![]() |
b8e6ae3e36 | ||
![]() |
4d559d0339 | ||
![]() |
bdf83fabd8 | ||
![]() |
8fca2b3346 | ||
![]() |
1b4e4773f1 | ||
![]() |
d4bcc7e726 | ||
![]() |
0b4eec55a0 | ||
![]() |
31265edc69 | ||
![]() |
c946f30258 | ||
![]() |
7ececbccbb | ||
![]() |
000a607bbc | ||
![]() |
984e61de8f | ||
![]() |
513f6df459 | ||
![]() |
3719797013 | ||
![]() |
db85caceda | ||
![]() |
41cf6830a8 | ||
![]() |
6926026377 | ||
![]() |
cda56ce0ac | ||
![]() |
56e3cece26 | ||
![]() |
16982f489d | ||
![]() |
59790543ab | ||
![]() |
63d7d21991 | ||
![]() |
ed3cdad21e | ||
![]() |
74f5911bf7 | ||
![]() |
28a86a3e59 | ||
![]() |
55df7a3c56 | ||
![]() |
8c56bfef66 | ||
![]() |
300cf4a0a1 | ||
![]() |
85881f3d64 | ||
![]() |
83bdb9ae7a | ||
![]() |
7e43b5f7f5 | ||
![]() |
9184222514 | ||
![]() |
fb5fd7bfb8 | ||
![]() |
b8a5ed9f66 | ||
![]() |
9d0338de63 | ||
![]() |
9615dcdb31 | ||
![]() |
ee240cbd1e | ||
![]() |
ac68ed9753 | ||
![]() |
8413908c94 | ||
![]() |
c7c365e5b9 | ||
![]() |
ba066bf0d4 | ||
![]() |
a8ba99875f | ||
![]() |
5bf2bc458c | ||
![]() |
af1a33b904 | ||
![]() |
741845799a | ||
![]() |
549b3903b1 | ||
![]() |
78fbe5e88f | ||
![]() |
a502b9f6b4 | ||
![]() |
d87d9ed59f | ||
![]() |
306480896e | ||
![]() |
19d9c45d6f | ||
![]() |
4ea1e98e52 | ||
![]() |
ba40c5108d | ||
![]() |
f28f20b5f0 | ||
![]() |
62c006c4cb | ||
![]() |
c84bc6c81f | ||
![]() |
1c2bb84e75 | ||
![]() |
9dab240c14 | ||
![]() |
e45483c6eb | ||
![]() |
2a329a40bb | ||
![]() |
2f8659c49f | ||
![]() |
8949b1631a | ||
![]() |
6b401d85c8 | ||
![]() |
c6d27fe3f9 | ||
![]() |
e7837bcfbe | ||
![]() |
c6ed25fc04 | ||
![]() |
1d92d5e1ca | ||
![]() |
f9ba5ac41d | ||
![]() |
905b4ec8ba | ||
![]() |
4674f9c440 | ||
![]() |
2f89c02f9b | ||
![]() |
bf17ba8234 | ||
![]() |
cbad7ccf75 | ||
![]() |
aed25f9810 | ||
![]() |
57997e4958 | ||
![]() |
8ee97b4a05 | ||
![]() |
11d78a98c9 | ||
![]() |
6a1fd45427 | ||
![]() |
e396c1f2b7 | ||
![]() |
74398733fd | ||
![]() |
78104681e4 | ||
![]() |
e5dbbac762 | ||
![]() |
a3fd02f0c9 | ||
![]() |
3038646fcb | ||
![]() |
26badd8cd7 | ||
![]() |
189118bb9c | ||
![]() |
0840ee63c0 | ||
![]() |
c110540e06 | ||
![]() |
79488b4373 | ||
![]() |
9d69044895 | ||
![]() |
0e7c3e8a84 | ||
![]() |
6fe4b86a19 | ||
![]() |
b01b73c1d5 | ||
![]() |
00332fb145 | ||
![]() |
c669bf0e9f | ||
![]() |
f422715fc8 | ||
![]() |
863348f194 | ||
![]() |
983b806906 | ||
![]() |
d559477342 | ||
![]() |
921ef43f2a | ||
![]() |
aabc935097 | ||
![]() |
c548a8f42f | ||
![]() |
4893daa1b4 | ||
![]() |
5db024b340 | ||
![]() |
80c67905ae | ||
![]() |
78499b267e | ||
![]() |
357e9f28bd | ||
![]() |
8350504d00 | ||
![]() |
540b33da0e | ||
![]() |
f5dcaf9af4 | ||
![]() |
159f4a413f | ||
![]() |
96d5a1bb57 | ||
![]() |
7f43d79f6e | ||
![]() |
f1018a5c2b | ||
![]() |
6d092d08ba | ||
![]() |
cca860adf5 | ||
![]() |
3d71fc5298 | ||
![]() |
b4dfd4b292 | ||
![]() |
c0269801f8 | ||
![]() |
f86067c1d8 | ||
![]() |
37d4cc260d | ||
![]() |
1c4a8c0aa4 | ||
![]() |
a1737eba9c | ||
![]() |
d333564aa7 | ||
![]() |
28292b33b8 | ||
![]() |
ba53cf2332 | ||
![]() |
51acd21478 | ||
![]() |
526d31325a | ||
![]() |
9eeda816fe | ||
![]() |
6bc8fe70f6 | ||
![]() |
b3f92d12cd | ||
![]() |
2f143b6710 | ||
![]() |
c27eec26e2 | ||
![]() |
dacc591c25 | ||
![]() |
73196e20ec | ||
![]() |
0f67518493 | ||
![]() |
5c0c8fd87b | ||
![]() |
065835d470 | ||
![]() |
e203ce0e1b | ||
![]() |
0ba38c3dc2 | ||
![]() |
a93f7c6628 | ||
![]() |
4f7ff30657 | ||
![]() |
8fd29401d7 | ||
![]() |
e411d2f80a | ||
![]() |
f3f4faf062 | ||
![]() |
865149826e | ||
![]() |
6391654b3e | ||
![]() |
c7cb9aa6ec | ||
![]() |
ec61243c0c | ||
![]() |
bfdadebd5f | ||
![]() |
3ae250bb25 | ||
![]() |
10f447c930 | ||
![]() |
f350d44460 | ||
![]() |
817c8d6330 | ||
![]() |
dad33bf364 | ||
![]() |
f7c2017f1c | ||
![]() |
f4a169ca80 | ||
![]() |
d27e3708c5 | ||
![]() |
7b09bb0fdb | ||
![]() |
1f460b3aae | ||
![]() |
4d5f1e5f14 | ||
![]() |
f4fe208b7f | ||
![]() |
393763c9c8 | ||
![]() |
5f78d20685 | ||
![]() |
00262dad4f | ||
![]() |
196aaa4dcf | ||
![]() |
78a0561163 | ||
![]() |
b52d027ec7 | ||
![]() |
543942da76 | ||
![]() |
ca7f51d226 | ||
![]() |
0e3d65dca2 | ||
![]() |
496950c501 | ||
![]() |
0ef3c2207f | ||
![]() |
86d233f5a9 | ||
![]() |
3e68029ca9 | ||
![]() |
888d50a6a7 | ||
![]() |
9ba7f25962 | ||
![]() |
7745155ee8 | ||
![]() |
5f2604fa5a | ||
![]() |
1e270c9ab1 | ||
![]() |
babb4d49f0 | ||
![]() |
dcff360508 | ||
![]() |
de92a80798 | ||
![]() |
5af3228d4b | ||
![]() |
a08c27132a | ||
![]() |
7ad60c71fe | ||
![]() |
637ba1e291 | ||
![]() |
be3bc28cbc | ||
![]() |
62bca2a374 | ||
![]() |
238a07a8e6 | ||
![]() |
ff9608fee7 | ||
![]() |
8414ee53c6 | ||
![]() |
82b6bbaba3 | ||
![]() |
d4284f1bab | ||
![]() |
cd6ce3eaa8 | ||
![]() |
811c4f883d | ||
![]() |
765d73bd92 | ||
![]() |
b82a816cab | ||
![]() |
d261a244a0 | ||
![]() |
4f4dda1fa2 | ||
![]() |
b46943f7f8 | ||
![]() |
cac1eafab1 | ||
![]() |
afefb5415b | ||
![]() |
e5de6c885e | ||
![]() |
fc56ec169f | ||
![]() |
077893a389 | ||
![]() |
172314b86b | ||
![]() |
efa52d3593 | ||
![]() |
29b06d5b40 | ||
![]() |
e528485833 | ||
![]() |
8d64a94d83 | ||
![]() |
8a70b65e02 | ||
![]() |
fb27d49ad7 | ||
![]() |
a50fbe9aa9 | ||
![]() |
a32bfd4523 | ||
![]() |
e8e7e67fc3 | ||
![]() |
9358f620c2 | ||
![]() |
2609064016 | ||
![]() |
b8cf22e0bb | ||
![]() |
b1d58b206b | ||
![]() |
26030611b1 | ||
![]() |
01248b9d2a | ||
![]() |
fd1db2cdde | ||
![]() |
d3320d9ff9 | ||
![]() |
4e184f9e7d | ||
![]() |
c8a96a292a | ||
![]() |
10bb880a4d | ||
![]() |
b6a8604b1f | ||
![]() |
61a94a8fcc | ||
![]() |
d1d11a46ac | ||
![]() |
6052a1543b | ||
![]() |
5caf316644 | ||
![]() |
daa3a0c377 | ||
![]() |
5238a62b10 | ||
![]() |
7129e82110 | ||
![]() |
40d16101e0 | ||
![]() |
de5be5f09b | ||
![]() |
050e8ec782 | ||
![]() |
04a0c3ee54 | ||
![]() |
11bdbb9962 | ||
![]() |
6892455364 | ||
![]() |
39ec8026db |
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
29
.github/workflows/briefcase.yml
vendored
Normal file
29
.github/workflows/briefcase.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Build GUI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'seedpass-gui*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pip-tools briefcase
|
||||
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
|
||||
git diff --exit-code requirements.lock
|
||||
pip install --require-hashes -r requirements.lock
|
||||
- name: Build with Briefcase
|
||||
run: briefcase build
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: seedpass-gui
|
||||
path: dist/**
|
27
.github/workflows/dependency-audit.yml
vendored
Normal file
27
.github/workflows/dependency-audit.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Dependency Audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pip-tools pip-audit
|
||||
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
|
||||
git diff --exit-code requirements.lock
|
||||
pip install --require-hashes -r requirements.lock
|
||||
- name: Run pip-audit
|
||||
run: pip-audit -r requirements.lock --ignore-vuln GHSA-wj6h-64fc-37mp
|
53
.github/workflows/python-ci.yml
vendored
53
.github/workflows/python-ci.yml
vendored
@@ -9,6 +9,20 @@ on:
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
jobs:
|
||||
secret-scan:
|
||||
name: Secret Scan
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'schedule'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Run gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_CONFIG: .gitleaks.toml
|
||||
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -59,18 +73,18 @@ jobs:
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('src/requirements.txt') }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
- name: Set up Python dependencies
|
||||
id: deps
|
||||
- name: Verify lockfile and install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r src/requirements.txt
|
||||
- name: Run pip-audit
|
||||
run: |
|
||||
pip install pip-audit
|
||||
pip-audit -r requirements.lock
|
||||
pip install pip-tools
|
||||
pip-compile --generate-hashes --output-file=requirements.lock src/requirements.txt
|
||||
git diff --exit-code requirements.lock
|
||||
pip install --require-hashes -r requirements.lock
|
||||
- name: Run dependency scan
|
||||
run: scripts/dependency_scan.sh --ignore-vuln GHSA-wj6h-64fc-37mp
|
||||
- name: Determine stress args
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -81,10 +95,27 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'schedule'
|
||||
run: echo "NOSTR_E2E=1" >> $GITHUB_ENV
|
||||
- name: Run tests with coverage
|
||||
timeout-minutes: 16
|
||||
shell: bash
|
||||
run: |
|
||||
pytest ${STRESS_ARGS} --cov=src --cov-report=xml --cov-report=term-missing \
|
||||
--cov-fail-under=20 src/tests
|
||||
run: scripts/run_ci_tests.sh
|
||||
- name: Run desktop tests
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
TOGA_BACKEND: toga_dummy
|
||||
run: scripts/run_gui_tests.sh
|
||||
- name: Upload pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-log-${{ matrix.os }}
|
||||
path: pytest.log
|
||||
- name: Upload GUI pytest log
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gui-pytest-log-${{ matrix.os }}
|
||||
path: pytest_gui.log
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
40
.github/workflows/tests.yml
vendored
Normal file
40
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Poetry
|
||||
run: pipx install poetry
|
||||
- name: Install dependencies
|
||||
run: poetry install
|
||||
- name: Check formatting
|
||||
run: poetry run black --check .
|
||||
- name: Run security audit
|
||||
run: |
|
||||
poetry run pip-audit || echo "::warning::pip-audit found vulnerabilities"
|
||||
shell: bash
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
poetry run coverage run -m pytest
|
||||
poetry run coverage xml
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.os }}-py${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
11
.gitignore
vendored
11
.gitignore
vendored
@@ -33,3 +33,14 @@ coverage.xml
|
||||
# Other
|
||||
.hypothesis
|
||||
totp_export.json.enc
|
||||
|
||||
# src
|
||||
|
||||
src/seedpass.egg-info/PKG-INFO
|
||||
src/seedpass.egg-info/SOURCES.txt
|
||||
src/seedpass.egg-info/dependency_links.txt
|
||||
src/seedpass.egg-info/entry_points.txt
|
||||
src/seedpass.egg-info/top_level.txt
|
||||
|
||||
# Allow vendored dependencies to be committed
|
||||
!src/vendor/
|
||||
|
8
.gitleaks.toml
Normal file
8
.gitleaks.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
title = "SeedPass gitleaks config"
|
||||
|
||||
[allowlist]
|
||||
description = "Paths and patterns to ignore when scanning for secrets"
|
||||
# Add file paths that contain test data or other non-sensitive strings
|
||||
paths = []
|
||||
# Add regular expressions that match false positive secrets
|
||||
regexes = []
|
10
AGENTS.md
10
AGENTS.md
@@ -9,7 +9,7 @@ This project is written in **Python**. Follow these instructions when working wi
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r src/requirements.txt
|
||||
pip install --require-hashes -r requirements.lock
|
||||
```
|
||||
|
||||
2. Run the test suite using **pytest**:
|
||||
@@ -39,6 +39,14 @@ This project is written in **Python**. Follow these instructions when working wi
|
||||
|
||||
Following these practices helps keep the code base consistent and secure.
|
||||
|
||||
## Legacy Index Migration
|
||||
|
||||
- Always provide a migration path for index archives and import/export routines.
|
||||
- Support older SeedPass versions whose indexes lacked salts or password-based encryption by detecting legacy formats and upgrading them to the current schema.
|
||||
- Ensure migrations unlock older account indexes and allow Nostr synchronization.
|
||||
- Add regression tests covering these migrations whenever the index format or encryption changes.
|
||||
|
||||
|
||||
## Integrating New Entry Types
|
||||
|
||||
SeedPass supports multiple `kind` values in its JSON entry files. When adding a
|
||||
|
694
README.md
694
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
|
||||
**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
|
||||
|
||||
[Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp)
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
|
||||
**⚠️ Disclaimer**
|
||||
|
||||
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.
|
||||
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.
|
||||
|
||||
**🚨 Breaking Change**
|
||||
|
||||
Recent releases derive passwords and other artifacts using a fully deterministic algorithm that behaves consistently across Python versions. This improvement means artifacts generated with earlier versions of SeedPass will not match those produced now. Regenerate any previously derived data or retain the old version if you need to reproduce older passwords or keys.
|
||||
|
||||
---
|
||||
### Supported OS
|
||||
@@ -18,21 +22,26 @@ This software was not developed by an experienced security expert and should be
|
||||
✔ Windows 10/11 • macOS 12+ • Any modern Linux
|
||||
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [1. Clone the Repository](#1-clone-the-repository)
|
||||
- [2. Create a Virtual Environment](#2-create-a-virtual-environment)
|
||||
- [3. Activate the Virtual Environment](#3-activate-the-virtual-environment)
|
||||
- [4. Install Dependencies](#4-install-dependencies)
|
||||
- [Optional GUI](#optional-gui)
|
||||
- [Usage](#usage)
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Managing Multiple Seeds](#managing-multiple-seeds)
|
||||
- [Additional Entry Types](#additional-entry-types)
|
||||
- [Recovery](#recovery)
|
||||
- [Building a standalone executable](#building-a-standalone-executable)
|
||||
- [Packaging with Briefcase](#packaging-with-briefcase)
|
||||
- [Security Considerations](#security-considerations)
|
||||
- [Dependency Updates](#dependency-updates)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
- [Contact](#contact)
|
||||
@@ -41,8 +50,8 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
|
||||
- **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords.
|
||||
- **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally.
|
||||
- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network.
|
||||
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas.
|
||||
- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. See [Nostr Setup](docs/nostr_setup.md) for relay configuration and event details.
|
||||
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field stores the UNIX timestamp of the latest delta event.
|
||||
- **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering.
|
||||
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
|
||||
- **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds.
|
||||
@@ -52,21 +61,67 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
|
||||
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
|
||||
- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer.
|
||||
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
||||
- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
||||
- **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||
- **Quick Unlock:** Optionally skip the password prompt after verifying once.
|
||||
- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
|
||||
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
||||
- **Typed Search Results:** Results now display each entry's type for quicker identification.
|
||||
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
||||
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
||||
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
|
||||
- **Vault Statistics:** View counts for entries and other profile metrics.
|
||||
- **Change Master Password:** Rotate your encryption password at any time.
|
||||
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
||||
- **Offline Mode:** Disable all Nostr communication for local-only operation.
|
||||
|
||||
|
||||
A small on-screen notification area now shows queued messages for 10 seconds
|
||||
before fading.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
SeedPass follows a layered design. The **`seedpass.core`** package exposes the
|
||||
`PasswordManager` along with service classes (e.g. `VaultService` and
|
||||
`EntryService`) that implement the main API used across interfaces. Both the
|
||||
command line tool in **`seedpass.cli`** and the FastAPI server in
|
||||
**`seedpass.api`** delegate operations to this core. The BeeWare desktop
|
||||
interface (`seedpass_gui.app`) and an optional browser extension reuse these
|
||||
services, with the extension communicating through the API layer.
|
||||
|
||||
Nostr synchronisation lives in the **`nostr`** modules. The core services call
|
||||
into these modules to publish or retrieve encrypted snapshots and deltas from
|
||||
configured relays.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
core["seedpass.core"]
|
||||
nostr["Nostr client"]
|
||||
relays["Nostr relays"]
|
||||
|
||||
cli --> core
|
||||
api --> core
|
||||
core --> nostr
|
||||
nostr --> relays
|
||||
```
|
||||
|
||||
See `docs/ARCHITECTURE.md` and [Nostr Setup](docs/nostr_setup.md) for details.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
|
||||
*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload.
|
||||
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
|
||||
*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
### Quick Installer
|
||||
|
||||
Use the automated installer to download SeedPass and its dependencies in one step.
|
||||
The scripts can also install the BeeWare backend for your platform when requested (use `-IncludeGui` on Windows).
|
||||
If the GTK `gi` bindings are missing, the installer attempts to install the
|
||||
necessary system packages using `apt`, `yum`, `pacman`, or Homebrew.
|
||||
|
||||
**Linux and macOS:**
|
||||
```bash
|
||||
@@ -76,106 +131,163 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/
|
||||
```bash
|
||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
|
||||
```
|
||||
Make sure the command ends right after `-b beta` with **no trailing parenthesis**.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent))
|
||||
```
|
||||
Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly.
|
||||
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to
|
||||
install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python`
|
||||
isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from
|
||||
<https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools,
|
||||
the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
||||
*Install with the optional GUI:*
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -IncludeGui
|
||||
```
|
||||
Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly.
|
||||
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from <https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
||||
|
||||
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
|
||||
*Install the beta branch:*
|
||||
|
||||
#### Installer Dependency Checks
|
||||
|
||||
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
|
||||
|
||||
#### Windows Nostr Sync Troubleshooting
|
||||
|
||||
When backing up or restoring from Nostr on Windows, a few issues are common:
|
||||
|
||||
* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed.
|
||||
* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory.
|
||||
* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment:
|
||||
|
||||
```bash
|
||||
pip install websockets
|
||||
```
|
||||
|
||||
Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed.
|
||||
### Uninstall
|
||||
|
||||
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
|
||||
|
||||
**Linux and macOS:**
|
||||
```bash
|
||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.sh)"
|
||||
```
|
||||
If you see a warning that an old executable couldn't be removed, delete the file manually.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -Branch beta
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.ps1'); & ([scriptblock]::create($scriptContent))
|
||||
```
|
||||
|
||||
### Manual Setup
|
||||
|
||||
Follow these steps to set up SeedPass on your local machine.
|
||||
|
||||
### 1. Clone the Repository
|
||||
1. **Clone the Repository**
|
||||
|
||||
First, clone the SeedPass repository from GitHub:
|
||||
```bash
|
||||
git clone https://github.com/PR0M3TH3AN/SeedPass.git
|
||||
cd SeedPass
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PR0M3TH3AN/SeedPass.git
|
||||
```
|
||||
2. **Create a Virtual Environment**
|
||||
|
||||
Navigate to the project directory:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
```
|
||||
|
||||
```bash
|
||||
cd SeedPass
|
||||
```
|
||||
3. **Activate the Virtual Environment**
|
||||
|
||||
### 2. Create a Virtual Environment
|
||||
- **Linux/macOS:**
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
- **Windows:**
|
||||
```bash
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
It's recommended to use a virtual environment to manage your project's dependencies. Create a virtual environment named `venv`:
|
||||
4. **Install Dependencies**
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
```
|
||||
|
||||
### 3. Activate the Virtual Environment
|
||||
|
||||
Activate the virtual environment using the appropriate command for your operating system.
|
||||
|
||||
- **On Linux and macOS:**
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
- **On Windows:**
|
||||
|
||||
```bash
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
Once activated, your terminal prompt should be prefixed with `(venv)` indicating that the virtual environment is active.
|
||||
|
||||
### 4. Install Dependencies
|
||||
|
||||
Install the required Python packages and build dependencies using `pip`.
|
||||
When upgrading pip, use `python -m pip` inside the virtual environment so that pip can update itself cleanly:
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -r src/requirements.txt
|
||||
```
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --require-hashes -r requirements.lock
|
||||
python -m pip install -e .
|
||||
```
|
||||
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
|
||||
After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on Windows to confirm the command resolves to your virtual environment's `seedpass` executable.
|
||||
|
||||
#### Linux Clipboard Support
|
||||
|
||||
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`.
|
||||
SeedPass will attempt to install **xclip** automatically if neither tool is
|
||||
available. If the automatic installation fails, you can install it manually:
|
||||
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. SeedPass no longer installs these tools automatically. To enable clipboard features such as secret mode, install **xclip** manually:
|
||||
|
||||
```bash
|
||||
sudo apt-get install xclip
|
||||
sudo apt install xclip
|
||||
```
|
||||
|
||||
After installing `xclip`, restart SeedPass to enable clipboard support.
|
||||
|
||||
### Optional GUI
|
||||
|
||||
SeedPass ships with a GTK-based desktop interface that is still in development
|
||||
and not currently functional. Install the packages for your platform before
|
||||
adding the Python GUI dependencies.
|
||||
|
||||
- **Debian/Ubuntu**
|
||||
```bash
|
||||
sudo apt install libgirepository1.0-dev libcairo2-dev libpango1.0-dev libwebkit2gtk-4.0-dev
|
||||
```
|
||||
- **Fedora**
|
||||
```bash
|
||||
sudo dnf install gobject-introspection-devel cairo-devel pango-devel webkit2gtk4.0-devel
|
||||
```
|
||||
- **Arch Linux**
|
||||
```bash
|
||||
sudo pacman -S gobject-introspection cairo pango webkit2gtk
|
||||
```
|
||||
- **macOS (Homebrew)**
|
||||
```bash
|
||||
brew install pygobject3 gtk+3 adwaita-icon-theme librsvg webkitgtk
|
||||
```
|
||||
|
||||
With the system requirements in place, install the Python GUI extras:
|
||||
|
||||
```bash
|
||||
pip install .[gui]
|
||||
```
|
||||
|
||||
CLI-only users can skip these steps and install just the core package for a
|
||||
lightweight setup:
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
After installing dependencies and activating your virtual environment, launch
|
||||
SeedPass and create a backup:
|
||||
After installing dependencies and activating your virtual environment, install the package in editable mode so the `seedpass` command is available:
|
||||
|
||||
```bash
|
||||
# Start the application
|
||||
python src/main.py
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
|
||||
You can then launch SeedPass and create a backup:
|
||||
|
||||
```bash
|
||||
# Start the application (interactive TUI)
|
||||
seedpass
|
||||
|
||||
# Export your index
|
||||
seedpass export --file "~/seedpass_backup.json"
|
||||
seedpass vault export --file "~/seedpass_backup.json"
|
||||
|
||||
# Later you can restore it
|
||||
seedpass import --file "~/seedpass_backup.json"
|
||||
seedpass vault import --file "~/seedpass_backup.json"
|
||||
|
||||
# Quickly find or retrieve entries
|
||||
seedpass search "github"
|
||||
seedpass search --tags "work,personal"
|
||||
seedpass get "github"
|
||||
# Search results show the entry type, e.g. "1: Password - GitHub"
|
||||
# Retrieve a TOTP entry
|
||||
seedpass entry get "email"
|
||||
# The code is printed and copied to your clipboard
|
||||
@@ -183,13 +295,68 @@ seedpass entry get "email"
|
||||
# Sort or filter the list view
|
||||
seedpass list --sort label
|
||||
seedpass list --filter totp
|
||||
# Generate a password with the safe character set defined by `SAFE_SPECIAL_CHARS`
|
||||
seedpass util generate-password --length 20 --special-mode safe --exclude-ambiguous
|
||||
|
||||
# Use the **Settings** menu to configure an extra backup directory
|
||||
# on an external drive.
|
||||
```
|
||||
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
|
||||
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Getting Started with the GUI
|
||||
|
||||
SeedPass also ships with a simple BeeWare desktop interface. Launch it from
|
||||
your virtual environment using any of the following commands:
|
||||
|
||||
```bash
|
||||
seedpass gui
|
||||
python -m seedpass_gui
|
||||
seedpass-gui
|
||||
```
|
||||
|
||||
GUI dependencies are optional. Install them alongside SeedPass with:
|
||||
|
||||
```bash
|
||||
pip install "seedpass[gui]"
|
||||
|
||||
# or when working from a local checkout
|
||||
pip install -e .[gui]
|
||||
```
|
||||
|
||||
After installing the optional GUI extras, add the BeeWare backend for your
|
||||
platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
pip install toga-gtk
|
||||
|
||||
# If you see build errors about "cairo" on Linux, install the cairo
|
||||
# development headers using your package manager, e.g.:
|
||||
sudo apt-get install libcairo2 libcairo2-dev
|
||||
|
||||
# Windows
|
||||
pip install toga-winforms
|
||||
|
||||
# macOS
|
||||
pip install toga-cocoa
|
||||
```
|
||||
|
||||
The GUI works with the same vault and configuration files as the CLI.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
### Vault JSON Layout
|
||||
|
||||
@@ -209,28 +376,74 @@ The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_vers
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> Opening a vault created by older versions automatically converts the legacy
|
||||
> `seedpass_passwords_db.json.enc` (Fernet) to AES-GCM as
|
||||
> `seedpass_entries_db.json.enc`. The original file is kept with a `.fernet`
|
||||
> extension.
|
||||
> The same migration occurs for a legacy `parent_seed.enc` encrypted with
|
||||
> Fernet: it is transparently decrypted, re-encrypted with AES-GCM and the old
|
||||
> file saved as `parent_seed.enc.fernet`.
|
||||
|
||||
## Usage
|
||||
|
||||
After successfully installing the dependencies, you can run SeedPass using the following command:
|
||||
After successfully installing the dependencies, install the package with:
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
Once installed, launch the interactive TUI with:
|
||||
|
||||
```bash
|
||||
seedpass
|
||||
```
|
||||
|
||||
You can also run directly from the repository with:
|
||||
|
||||
```bash
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
You can also use the new Typer-based CLI:
|
||||
You can explore other CLI commands using:
|
||||
|
||||
```bash
|
||||
seedpass --help
|
||||
```
|
||||
|
||||
If this command displays `usage: main.py` instead of the Typer help output, an old `seedpass` executable is still on your `PATH`. Remove it with `pip uninstall seedpass` or delete the stale launcher and rerun:
|
||||
|
||||
```bash
|
||||
python -m pip install -e .
|
||||
```
|
||||
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
|
||||
You can confirm which executable will run with:
|
||||
|
||||
```bash
|
||||
which seedpass # or 'where seedpass' on Windows
|
||||
```
|
||||
|
||||
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. **Start the Application:**
|
||||
|
||||
```bash
|
||||
python src/main.py
|
||||
```
|
||||
```bash
|
||||
seedpass
|
||||
```
|
||||
*(or `python src/main.py` when running directly from the repository)*
|
||||
|
||||
To restore a previously backed up index at launch, provide the backup path
|
||||
and fingerprint:
|
||||
|
||||
```bash
|
||||
seedpass --restore-backup /path/to/backup.json.enc --fingerprint <fp>
|
||||
```
|
||||
|
||||
Without the flag, the startup prompt offers a **Restore from backup** option
|
||||
before the vault is initialized.
|
||||
|
||||
2. **Follow the Prompts:**
|
||||
|
||||
@@ -240,18 +453,18 @@ For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). Th
|
||||
|
||||
Example menu:
|
||||
|
||||
```bash
|
||||
Select an option:
|
||||
1. Add Entry
|
||||
2. Retrieve Entry
|
||||
3. Search Entries
|
||||
4. List Entries
|
||||
5. Modify an Existing Entry
|
||||
6. 2FA Codes
|
||||
7. Settings
|
||||
```bash
|
||||
Select an option:
|
||||
1. Add Entry
|
||||
2. Retrieve Entry
|
||||
3. Search Entries
|
||||
4. List Entries
|
||||
5. Modify an Existing Entry
|
||||
6. 2FA Codes
|
||||
7. Settings
|
||||
|
||||
Enter your choice (1-7) or press Enter to exit:
|
||||
```
|
||||
Enter your choice (1-7) or press Enter to exit:
|
||||
```
|
||||
|
||||
When choosing **Add Entry**, you can now select from:
|
||||
|
||||
@@ -264,6 +477,15 @@ When choosing **Add Entry**, you can now select from:
|
||||
- **Key/Value**
|
||||
- **Managed Account**
|
||||
|
||||
### Adding a Password Entry
|
||||
|
||||
After selecting **Password**, SeedPass asks you to pick a mode:
|
||||
|
||||
1. **Quick** – prompts only for a label, username, URL, desired length, and whether to include special characters. Default values are used for notes, tags, and policy settings.
|
||||
2. **Advanced** – walks through the full set of prompts for notes, tags, custom fields, and detailed password policy options.
|
||||
|
||||
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
|
||||
|
||||
### Adding a 2FA Entry
|
||||
|
||||
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
||||
@@ -285,11 +507,20 @@ When choosing **Add Entry**, you can now select from:
|
||||
|
||||
### Using Secret Mode
|
||||
|
||||
When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
|
||||
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
|
||||
2. Choose how many seconds to keep passwords on the clipboard.
|
||||
3. Retrieve an entry and SeedPass will confirm the password was copied.
|
||||
3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
|
||||
|
||||
### Viewing Entry Details
|
||||
|
||||
Selecting an item from **List Entries** or **Search Entries** first displays the
|
||||
entry's metadata such as the label, username, tags and notes. Passwords, seed
|
||||
phrases and other sensitive fields remain hidden until you choose to reveal
|
||||
them. When you opt to show the secret, the details view presents the same action
|
||||
menu as **Retrieve Entry** so you can edit, archive or display QR codes for the
|
||||
entry.
|
||||
|
||||
### Additional Entry Types
|
||||
|
||||
@@ -297,45 +528,45 @@ SeedPass supports storing more than just passwords and 2FA secrets. You can also
|
||||
- **SSH Key** – deterministically derive an Ed25519 key pair for servers or git hosting platforms.
|
||||
- **Seed Phrase** – store only the BIP-85 index and word count. The mnemonic is regenerated on demand.
|
||||
- **PGP Key** – derive an OpenPGP key pair from your master seed.
|
||||
- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients.
|
||||
When you retrieve one of these entries, SeedPass can display QR codes for the
|
||||
keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan
|
||||
it, while the `nsec` QR is shown only after a security warning.
|
||||
- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients. When you retrieve one of these entries, SeedPass can display QR codes for the keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan it, while the `nsec` QR is shown only after a security warning.
|
||||
- **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data.
|
||||
- **Managed Account** – derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
|
||||
|
||||
The table below summarizes the extra fields stored for each entry type. Every
|
||||
entry includes a `label`, while only password entries track a `url`.
|
||||
|
||||
| Entry Type | Extra Fields |
|
||||
|---------------|---------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
|
||||
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
|
||||
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
||||
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
||||
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Key/Value | `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
||||
The table below summarizes the extra fields stored for each entry type. Every entry includes a `label`, while only password entries track a `url`.
|
||||
|
||||
| Entry Type | Extra Fields |
|
||||
|-----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
|
||||
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
|
||||
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
||||
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
||||
| Nostr Key Pair | `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
||||
|
||||
### Managing Multiple Seeds
|
||||
|
||||
SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords.
|
||||
|
||||
- **Add a New Seed Profile:**
|
||||
- From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
|
||||
- Choose to enter an existing seed or generate a new one.
|
||||
- If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
|
||||
1. From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
|
||||
2. Choose to paste in a full seed, enter one word at a time, or generate a new seed.
|
||||
3. If you enter the seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. SeedPass then shows the completed phrase for confirmation so you can fix any mistakes before it is stored.
|
||||
4. If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
|
||||
|
||||
- **Switch Between Seed Profiles:**
|
||||
- From the **Profiles** menu, select "Switch Seed Profile".
|
||||
- You'll see a list of available seed profiles.
|
||||
- Enter the number corresponding to the seed profile you wish to switch to.
|
||||
- Enter the master password associated with that seed profile.
|
||||
1. From the **Profiles** menu, select "Switch Seed Profile".
|
||||
2. You'll see a list of available seed profiles.
|
||||
3. Enter the number corresponding to the seed profile you wish to switch to.
|
||||
4. Enter the master password associated with that seed profile.
|
||||
|
||||
- **List All Seed Profiles:**
|
||||
- In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
|
||||
In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
|
||||
- **Set Seed Profile Name:**
|
||||
In the **Profiles** menu, choose "Set Seed Profile Name" to assign an optional
|
||||
label to the currently selected profile. The name is stored locally and shown
|
||||
alongside the fingerprint in menus.
|
||||
|
||||
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
|
||||
|
||||
@@ -364,40 +595,65 @@ You can manage your relays and sync with Nostr from the **Settings** menu:
|
||||
|
||||
Back in the Settings menu you can:
|
||||
|
||||
* Select `3` to change your master password.
|
||||
* Choose `4` to verify the script checksum.
|
||||
* Select `5` to generate a new script checksum.
|
||||
* Choose `6` to back up the parent seed.
|
||||
* Select `7` to export the database to an encrypted file.
|
||||
* Choose `8` to import a database from a backup file.
|
||||
* Select `9` to export all 2FA codes.
|
||||
* Choose `10` to set an additional backup location. A backup is created
|
||||
immediately after the directory is configured.
|
||||
* Select `11` to change the inactivity timeout.
|
||||
* Choose `12` to lock the vault and require re-entry of your password.
|
||||
* Select `13` to view seed profile stats. The summary lists counts for
|
||||
passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows
|
||||
whether both the encrypted database and the script itself pass checksum
|
||||
validation.
|
||||
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
|
||||
* Select `15` to return to the main menu.
|
||||
- Select `3` to change your master password.
|
||||
- Choose `4` to verify the script checksum.
|
||||
- Select `5` to generate a new script checksum.
|
||||
- Choose `6` to back up the parent seed.
|
||||
- Select `7` to export the database to an encrypted file.
|
||||
- Choose `8` to import a database from a backup file.
|
||||
- Select `9` to export all 2FA codes.
|
||||
- Choose `10` to set an additional backup location. A backup is created immediately after the directory is configured.
|
||||
- Select `11` to set the PBKDF2 iteration count used for encryption.
|
||||
- Choose `12` to change the inactivity timeout.
|
||||
- Select `13` to lock the vault and require re-entry of your password.
|
||||
- Select `14` to view seed profile stats. The summary lists counts for passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows whether both the encrypted database and the script itself pass checksum validation.
|
||||
- Choose `15` to toggle Secret Mode and set the clipboard clear delay.
|
||||
- Select `16` to toggle Offline Mode and disable Nostr synchronization.
|
||||
- Choose `17` to toggle Quick Unlock for skipping the password prompt after the first unlock.
|
||||
Press **Enter** at any time to return to the main menu.
|
||||
You can adjust these settings directly from the command line:
|
||||
|
||||
```bash
|
||||
seedpass config set kdf_iterations 200000
|
||||
seedpass config set backup_interval 3600
|
||||
seedpass config set quick_unlock true
|
||||
seedpass config set nostr_max_retries 2
|
||||
seedpass config set nostr_retry_delay 1
|
||||
```
|
||||
|
||||
The default configuration uses **50,000** PBKDF2 iterations. Increase this value for stronger password hashing or lower it for faster startup (not recommended). Offline Mode skips all Nostr communication, keeping your data local until you re-enable syncing. Quick Unlock stores a hashed copy of your password in the encrypted config so that after the initial unlock, subsequent operations won't prompt for the password until you exit the program. Avoid enabling Quick Unlock on shared machines.
|
||||
|
||||
### Recovery
|
||||
|
||||
If you previously backed up your vault to Nostr you can restore it during the
|
||||
initial setup. You must provide both your 12‑word master seed and the master
|
||||
password that encrypted the vault; without the correct password the retrieved
|
||||
data cannot be decrypted.
|
||||
|
||||
Alternatively, a local backup file can be loaded at startup. Launch the
|
||||
application with `--restore-backup <file> --fingerprint <fp>` or choose the
|
||||
**Restore from backup** option presented before the vault initializes.
|
||||
|
||||
1. Start SeedPass and choose option **4** when prompted to set up a seed.
|
||||
2. Paste your BIP‑85 seed phrase when asked.
|
||||
3. Enter the master password associated with that seed.
|
||||
4. SeedPass initializes the profile and attempts to download the encrypted
|
||||
vault from the configured relays.
|
||||
5. A success message confirms the vault was restored. If no data is found a
|
||||
failure message is shown and a new empty vault is created.
|
||||
|
||||
## Running Tests
|
||||
|
||||
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install -r src/requirements.txt` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
||||
|
||||
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install --require-hashes -r requirements.lock` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
||||
|
||||
```bash
|
||||
pip install -r src/requirements.txt
|
||||
pip install --require-hashes -r requirements.lock
|
||||
pytest -vv
|
||||
```
|
||||
|
||||
### Exploring Nostr Index Size Limits
|
||||
|
||||
`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events.
|
||||
Each chunk is limited to 50 KB, so the test gradually grows the vault to observe
|
||||
when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment
|
||||
variable to control the delay between publishes when experimenting with large vaults.
|
||||
`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events. Each chunk is limited to 50 KB, so the test gradually grows the vault to observe when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment variable to control the delay between publishes when experimenting with large vaults.
|
||||
|
||||
```bash
|
||||
pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000
|
||||
@@ -411,23 +667,26 @@ Use the helper script below to populate a profile with sample entries for testin
|
||||
python scripts/generate_test_profile.py --profile demo_profile --count 100
|
||||
```
|
||||
|
||||
The script now determines the fingerprint from the generated seed and stores the
|
||||
vault under `~/.seedpass/<fingerprint>`. It also prints the fingerprint after
|
||||
creation and publishes the encrypted index to Nostr. Use that same seed phrase
|
||||
to load SeedPass. The app checks Nostr on startup and pulls any newer snapshot
|
||||
so your vault stays in sync across machines.
|
||||
The script determines the fingerprint from the generated seed and stores the
|
||||
vault under `~/.seedpass/tests/<fingerprint>`. SeedPass only looks for profiles
|
||||
in `~/.seedpass/`, so move or copy the fingerprint directory out of the `tests`
|
||||
subfolder (or adjust `APP_DIR` in `constants.py`) if you want to load it with
|
||||
the main application. The fingerprint is printed after creation and the
|
||||
encrypted index is published to Nostr. Use that same seed phrase to load
|
||||
SeedPass. The app checks Nostr on startup and pulls any newer snapshot so your
|
||||
vault stays in sync across machines. If no snapshot exists or the download
|
||||
cannot be decrypted (for example when using a brand-new seed), SeedPass
|
||||
automatically initializes an empty index instead of exiting.
|
||||
|
||||
### Automatically Updating the Script Checksum
|
||||
|
||||
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
|
||||
To keep this value in sync with the source code, install the pre‑push git hook:
|
||||
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`. To keep this value in sync with the source code, install the pre-push git hook:
|
||||
|
||||
```bash
|
||||
pre-commit install -t pre-push
|
||||
```
|
||||
|
||||
After running this command, every `git push` will execute `scripts/update_checksum.py`,
|
||||
updating the checksum file automatically.
|
||||
After running this command, every `git push` will execute `scripts/update_checksum.py`, updating the checksum file automatically.
|
||||
|
||||
If the checksum file is missing, generate it manually:
|
||||
|
||||
@@ -435,6 +694,10 @@ If the checksum file is missing, generate it manually:
|
||||
python scripts/update_checksum.py
|
||||
```
|
||||
|
||||
If SeedPass prints a "script checksum mismatch" warning on startup, regenerate
|
||||
the checksum with `seedpass util update-checksum` or select "Generate Script
|
||||
Checksum" from the Settings menu.
|
||||
|
||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||
|
||||
```bash
|
||||
@@ -444,6 +707,72 @@ python -m mutmut results
|
||||
```
|
||||
|
||||
Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead.
|
||||
## Development Workflow
|
||||
|
||||
1. Install all development dependencies:
|
||||
```bash
|
||||
pip install --require-hashes -r requirements.lock
|
||||
```
|
||||
|
||||
2. When `src/runtime_requirements.txt` changes, rerun:
|
||||
```bash
|
||||
scripts/vendor_dependencies.sh
|
||||
```
|
||||
Commit the updated `src/vendor/` directory. The application automatically adds this folder to `sys.path` so the bundled packages are found.
|
||||
|
||||
3. Before committing, format and test the code:
|
||||
```bash
|
||||
black .
|
||||
pytest
|
||||
```
|
||||
|
||||
|
||||
## Building a standalone executable
|
||||
|
||||
1. Run the vendoring script to bundle runtime dependencies:
|
||||
|
||||
```bash
|
||||
scripts/vendor_dependencies.sh
|
||||
```
|
||||
|
||||
2. Build the binary with PyInstaller:
|
||||
|
||||
```bash
|
||||
pyinstaller SeedPass.spec
|
||||
```
|
||||
|
||||
You can also produce packaged installers for the GUI with BeeWare's Briefcase:
|
||||
|
||||
```bash
|
||||
briefcase build
|
||||
```
|
||||
|
||||
Pre-built installers are published for each `seedpass-gui` tag. Visit the
|
||||
project's **Actions** or **Releases** page on GitHub to download the latest
|
||||
package for your platform.
|
||||
|
||||
The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
|
||||
|
||||
## Packaging with Briefcase
|
||||
|
||||
For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md).
|
||||
|
||||
Install Briefcase and create a platform-specific scaffold:
|
||||
|
||||
```bash
|
||||
python -m pip install briefcase
|
||||
briefcase create
|
||||
```
|
||||
|
||||
Build and run the packaged GUI:
|
||||
|
||||
```bash
|
||||
briefcase build
|
||||
briefcase run
|
||||
```
|
||||
|
||||
You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`.
|
||||
|
||||
|
||||
## Security Considerations
|
||||
|
||||
@@ -452,38 +781,79 @@ Mutation testing is disabled in the GitHub workflow due to reliability issues an
|
||||
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
|
||||
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
|
||||
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
|
||||
- **Revealing the Parent Seed:** The `vault reveal-parent-seed` command and `/api/v1/parent-seed` endpoint print your seed in plain text. Run them only in a secure environment.
|
||||
- **Backing Up the Parent Seed:** Use the CLI `vault reveal-parent-seed` command or the `/api/v1/vault/backup-parent-seed` endpoint with explicit confirmation to create an encrypted backup. The API does not return the seed directly.
|
||||
- **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
|
||||
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
|
||||
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
|
||||
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
|
||||
- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password.
|
||||
- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
|
||||
- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Adjust this with `seedpass config set kdf_iterations`.
|
||||
- **KDF Iteration Caution:** Lowering `kdf_iterations` makes password cracking easier, while a high `backup_interval` leaves fewer recent backups.
|
||||
- **Offline Mode:** When enabled, SeedPass skips all Nostr operations so your vault stays local until syncing is turned back on.
|
||||
- **Quick Unlock:** Stores a hashed copy of your password in the encrypted config so you only need to enter it once per session. Avoid this on shared computers.
|
||||
- **Prompt Rate Limiting:** Seed and password prompts enforce a configurable attempt limit with exponential backoff to slow brute-force attacks. Adjust or disable the limit for testing via the `--max-prompt-attempts` CLI option or the `SEEDPASS_MAX_PROMPT_ATTEMPTS` environment variable.
|
||||
|
||||
### Secure Deployment
|
||||
|
||||
Always deploy SeedPass behind HTTPS. Place a TLS‑terminating reverse proxy such as Nginx in front of the FastAPI server or configure Uvicorn with certificate files. Example Nginx snippet:
|
||||
|
||||
```
|
||||
server {
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For local testing, Uvicorn can run with TLS directly:
|
||||
|
||||
```
|
||||
uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
||||
```
|
||||
|
||||
## Dependency Updates
|
||||
|
||||
Automated dependency updates are handled by [Dependabot](https://docs.github.com/en/code-security/dependabot).
|
||||
Every week, Dependabot checks Python packages and GitHub Actions used by this repository and opens pull requests when updates are available.
|
||||
|
||||
To review and merge these updates:
|
||||
|
||||
1. Review the changelog and release notes in the Dependabot pull request.
|
||||
2. Run the test suite locally:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install --require-hashes -r requirements.lock
|
||||
pytest
|
||||
```
|
||||
3. Merge the pull request once all checks pass.
|
||||
|
||||
A scheduled **Dependency Audit** workflow also runs [`pip-audit`](https://github.com/pypa/pip-audit) weekly to detect vulnerable packages. Address any reported issues promptly to keep dependencies secure.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:
|
||||
|
||||
1. **Fork the Repository:** Click the "Fork" button on the top right of the repository page.
|
||||
|
||||
2. **Create a Branch:** Create a new branch for your feature or bugfix.
|
||||
|
||||
1. **Create a Branch:** Create a new branch for your feature or bugfix.
|
||||
```bash
|
||||
git checkout -b feature/YourFeatureName
|
||||
```
|
||||
|
||||
3. **Commit Your Changes:** Make your changes and commit them with clear messages.
|
||||
|
||||
1. **Commit Your Changes:** Make your changes and commit them with clear messages.
|
||||
```bash
|
||||
git commit -m "Add feature X"
|
||||
```
|
||||
|
||||
4. **Push to GitHub:** Push your changes to your forked repository.
|
||||
|
||||
1. **Push to GitHub:** Push your changes to your forked repository.
|
||||
```bash
|
||||
git push origin feature/YourFeatureName
|
||||
```
|
||||
|
||||
5. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes.
|
||||
1. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes.
|
||||
|
||||
## License
|
||||
|
||||
@@ -496,5 +866,3 @@ For any questions, suggestions, or support, please open an issue on the [GitHub
|
||||
---
|
||||
|
||||
*Stay secure and keep your passwords safe with SeedPass!*
|
||||
|
||||
---
|
||||
|
38
SeedPass.spec
Normal file
38
SeedPass.spec
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['src/main.py'],
|
||||
pathex=['src', 'src/vendor'],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='SeedPass',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
93
dev-plan.md
93
dev-plan.md
@@ -1,93 +0,0 @@
|
||||
### SeedPass Road-to-1.0 — Detailed Development Plan
|
||||
|
||||
*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)*
|
||||
|
||||
| Phase | Goal | Key Deliverables | Target Window |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||
| **0 – Vision Lock-in** | Be explicit about where you’re going so every later trade-off is easy. | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle. <br>• Public roadmap Kanban board. | **Week 0** |
|
||||
| **1 – Package-ready Codebase** | Turn loose `src/` tree into a pip-installable library + console script. | • `pyproject.toml` with PEP-621 metadata, `setuptools-scm` dynamic version. <br>• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`). <br>• Entry-point: `seedpass = "seedpass.main:cli"`. <br>• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`. <br>• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** |
|
||||
| **2 – Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.10–3.12). <br>• 90 % line coverage gate. <br>• Static checks in pre-commit (black, ruff, mypy). | **Weeks 1-3** |
|
||||
| **3 – CI / Release Automation** | One Git tag → everything ships. | • GitHub Actions matrix (Ubuntu, macOS, Windows). <br>• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release. <br>• Secrets for PyPI / code-signing left empty until 1.0. | **Weeks 2-4** |
|
||||
| **4 – OS-Native Packages** | Users can “apt install / brew install / flatpak install / download .exe”. | **Linux** • `stdeb` → `.deb`, `reprepro` mini-APT repo. <br>**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo. <br>**Windows** • PyInstaller `--onefile` → NSIS installer. <br>**macOS** • Briefcase → notarised `.pkg` or `.dmg` (signing cert later). | **Weeks 4-8** |
|
||||
| **5 – Experimental GUI Track** | Ship a GUI **without** slowing CLI velocity. | • Decide stack (recommend **Textual** first; upgrade later to Toga or PySide). <br>• Create `seedpass.gui` package calling existing APIs; flag with `--gui`. <br>• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch. <br>• Separate workflow that builds GUI artefacts, but does **not** block CLI releases. | **Weeks 6-12** (parallel) |
|
||||
| **6 – Plugin / Extensibility Layer** | Keep core slim while allowing future features. | • Define `entry_points={"seedpass.plugins": …}`. <br>• Document simple example plugin (e.g., custom password rule). <br>• Load plugins lazily to avoid startup cost. | **Weeks 10-14** |
|
||||
| **7 – Security & Hardening** | Turn security assumptions into guarantees before 1.0 | • SAST scan (Bandit, Semgrep). <br>• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow. <br>• Repro-build check for PyInstaller artefacts. <br>• Signed releases (Sigstore, minisign). | **Weeks 12-16** |
|
||||
| **8 – 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`). <br>• In-app `--check-update` hitting GitHub API. <br>• Blog post & template release notes. | **Weeks 16-18** |
|
||||
|
||||
---
|
||||
|
||||
### Ongoing Practices to Keep Development Nimble
|
||||
|
||||
| Practice | What to do |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push – nothing else. |
|
||||
| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. |
|
||||
| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. |
|
||||
| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. |
|
||||
| **Weekly beta release** | Even during heavy GUI work, cut “beta” tags weekly; real users shake out regressions early. |
|
||||
|
||||
---
|
||||
|
||||
### First 2-Week Sprint (Concrete To-Dos)
|
||||
|
||||
1. **Bootstrap packaging**
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip build setuptools_scm
|
||||
poetry init # if you prefer Poetry, else stick with setuptools
|
||||
```
|
||||
|
||||
Add `pyproject.toml`, move code to `seedpass/`.
|
||||
|
||||
2. **Console entry-point**
|
||||
In `seedpass/__main__.py` add `from .main import cli; cli()`.
|
||||
|
||||
3. **Editable dev install**
|
||||
`pip install -e .[dev]` → run `seedpass --help`.
|
||||
|
||||
4. **Set up pre-commit**
|
||||
`pre-commit install` with ruff + black + mypy hooks.
|
||||
|
||||
5. **GitHub Action skeleton** (`.github/workflows/ci.yml`)
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix: os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ['3.12', '3.11']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with: {python-version: ${{ matrix.python-version }}}
|
||||
- run: pip install --upgrade pip
|
||||
- run: pip install -e .[dev]
|
||||
- run: pytest -n auto
|
||||
```
|
||||
|
||||
6. **Smoke PyInstaller locally**
|
||||
`pyinstaller --onefile seedpass/main.py` – fix missing data/hooks; check binary runs.
|
||||
|
||||
When that’s green, cut tag `v0.1.0-beta` and let CI build artefacts automatically.
|
||||
|
||||
---
|
||||
|
||||
### Choosing the GUI Path (decision by Week 6)
|
||||
|
||||
| If you value… | Choose |
|
||||
| ---------------------------------- | ---------------------------- |
|
||||
| Terminal-first UX, live coding | **Textual (Rich-TUI)** |
|
||||
| Native look, single code base | **Toga / Briefcase** |
|
||||
| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** |
|
||||
|
||||
Prototype one screen (vault list + “Add” dialog) and benchmark bundle size + startup time with PyInstaller before committing.
|
||||
|
||||
---
|
||||
|
||||
## Recap
|
||||
|
||||
* **Packaging & CI first** – lets every future feature ride an established release train.
|
||||
* **GUI lives in its own layer** – CLI stays stable; dev cycles remain quick.
|
||||
* **Security & signing** land after functionality is stable, before v1.0 marketing push.
|
||||
|
||||
Follow the phase table, keep weekly betas flowing, and you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility.
|
2
docs/.gitattributes
vendored
Normal file
2
docs/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
17
docs/.github/workflows/ci.yml
vendored
Normal file
17
docs/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm install
|
||||
- run: npm test
|
2
docs/.gitignore
vendored
Normal file
2
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
_site/
|
||||
node_modules/
|
41
docs/ARCHITECTURE.md
Normal file
41
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# SeedPass Architecture
|
||||
|
||||
SeedPass follows a layered design that keeps the security-critical logic isolated in a reusable core package. Interfaces like the command line tool, REST API and graphical client act as thin adapters around this core.
|
||||
|
||||
## Core Components
|
||||
|
||||
- **`seedpass.core`** – houses all encryption, key derivation and vault management code.
|
||||
- **`VaultService`** and **`EntryService`** – thread-safe wrappers exposing the main API.
|
||||
- **`PasswordManager`** – orchestrates vault operations, migrations and Nostr sync.
|
||||
|
||||
## Adapters
|
||||
|
||||
- **CLI/TUI** – implemented in [`seedpass.cli`](src/seedpass/cli.py). The [Advanced CLI](docs/docs/content/01-getting-started/01-advanced_cli.md) guide details all commands.
|
||||
- **FastAPI server** – defined in [`seedpass.api`](src/seedpass/api.py). See the [API Reference](docs/docs/content/01-getting-started/02-api_reference.md) for endpoints.
|
||||
- **BeeWare GUI** – located in [`seedpass_gui`](src/seedpass_gui/app.py) and explained in the [GUI Adapter](docs/docs/content/01-getting-started/06-gui_adapter.md) page.
|
||||
|
||||
## Planned Extensions
|
||||
|
||||
SeedPass is built to support additional adapters. Planned or experimental options include:
|
||||
|
||||
- A browser extension communicating with the API
|
||||
- Automation scripts using the CLI
|
||||
- Additional vault import/export helpers described in [JSON Entries](docs/docs/content/01-getting-started/03-json_entries.md)
|
||||
|
||||
## Overview Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI / TUI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser extension"]
|
||||
|
||||
cli --> core
|
||||
api --> core
|
||||
gui --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
All adapters depend on the same core, allowing features to evolve without duplicating logic across interfaces.
|
@@ -1,25 +1,55 @@
|
||||
# SeedPass Documentation
|
||||
# Archivox
|
||||
|
||||
This directory contains supplementary guides for using SeedPass.
|
||||
Archivox is a lightweight static site generator aimed at producing documentation sites similar to "Read the Docs". Write your content in Markdown, run the generator, and deploy the static files anywhere.
|
||||
|
||||
## Quick Example: Get a TOTP Code
|
||||
[](https://github.com/PR0M3TH3AN/Archivox/actions/workflows/ci.yml)
|
||||
|
||||
Run `seedpass entry get <query>` to retrieve a time-based one-time password (TOTP).
|
||||
The `<query>` can be a label, title, or index. A progress bar shows the remaining
|
||||
seconds in the current period.
|
||||
## Features
|
||||
- Markdown based pages with automatic navigation
|
||||
- Responsive layout with sidebar and search powered by Lunr.js
|
||||
- Simple configuration through `config.yaml`
|
||||
- Extensible via plugins and custom templates
|
||||
|
||||
## Getting Started
|
||||
Install the dependencies and start the development server:
|
||||
|
||||
```bash
|
||||
$ seedpass entry get "email"
|
||||
[##########----------] 15s
|
||||
Code: 123456
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To show all stored TOTP codes with their countdown timers, run:
|
||||
The site will be available at `http://localhost:8080`. Edit files inside the `content/` directory to update pages.
|
||||
|
||||
To create a new project from the starter template you can run:
|
||||
|
||||
```bash
|
||||
$ seedpass entry totp-codes
|
||||
npx create-archivox my-docs --install
|
||||
```
|
||||
|
||||
## CLI and API Reference
|
||||
## Building
|
||||
When you are ready to publish your documentation run:
|
||||
|
||||
See [advanced_cli.md](advanced_cli.md) for a list of command examples. Detailed information about the REST API is available in [api_reference.md](api_reference.md). When starting the API, set `SEEDPASS_CORS_ORIGINS` if you need to allow requests from specific web origins.
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The generated site is placed in the `_site/` folder.
|
||||
|
||||
## Customization
|
||||
- **`config.yaml`** – change the site title, theme options and other settings.
|
||||
- **`plugins/`** – add JavaScript files exporting hook functions such as `onPageRendered` to extend the build process.
|
||||
- **`templates/`** – modify or replace the Nunjucks templates for full control over the HTML.
|
||||
|
||||
## Hosting
|
||||
Upload the contents of `_site/` to any static host. For Netlify you can use the provided `netlify.toml`:
|
||||
|
||||
```toml
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "_site"
|
||||
```
|
||||
|
||||
## Documentation
|
||||
See the files under the `docs/` directory for a full guide to Archivox including an integration tutorial for existing projects.
|
||||
|
||||
Archivox is released under the MIT License.
|
||||
|
34
docs/__tests__/buildNav.test.js
Normal file
34
docs/__tests__/buildNav.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { buildNav } = require('../src/generator');
|
||||
|
||||
test('generates navigation tree', () => {
|
||||
const pages = [
|
||||
{ file: 'guide/install.md', data: { title: 'Install', order: 1 } },
|
||||
{ file: 'guide/usage.md', data: { title: 'Usage', order: 2 } },
|
||||
{ file: 'guide/nested/info.md', data: { title: 'Info', order: 1 } }
|
||||
];
|
||||
const tree = buildNav(pages);
|
||||
const guide = tree.find(n => n.name === 'guide');
|
||||
expect(guide).toBeDefined();
|
||||
expect(guide.children.length).toBe(3);
|
||||
const install = guide.children.find(c => c.name === 'install.md');
|
||||
expect(install.path).toBe('/guide/install.html');
|
||||
});
|
||||
|
||||
test('adds display names and section flags', () => {
|
||||
const pages = [
|
||||
{ file: '02-api.md', data: { title: 'API', order: 2 } },
|
||||
{ file: '01-guide/index.md', data: { title: 'Guide', order: 1 } },
|
||||
{ file: '01-guide/setup.md', data: { title: 'Setup', order: 2 } },
|
||||
{ file: 'index.md', data: { title: 'Home', order: 10 } }
|
||||
];
|
||||
const nav = buildNav(pages);
|
||||
expect(nav[0].name).toBe('index.md');
|
||||
const guide = nav.find(n => n.name === '01-guide');
|
||||
expect(guide.displayName).toBe('Guide');
|
||||
expect(guide.isSection).toBe(true);
|
||||
const api = nav.find(n => n.name === '02-api.md');
|
||||
expect(api.displayName).toBe('API');
|
||||
// alphabetical within same order
|
||||
expect(nav[1].name).toBe('01-guide');
|
||||
expect(nav[2].name).toBe('02-api.md');
|
||||
});
|
13
docs/__tests__/loadConfig.test.js
Normal file
13
docs/__tests__/loadConfig.test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const loadConfig = require('../src/config/loadConfig');
|
||||
|
||||
test('loads configuration and merges defaults', () => {
|
||||
const dir = fs.mkdtempSync(path.join(__dirname, 'cfg-'));
|
||||
const file = path.join(dir, 'config.yaml');
|
||||
fs.writeFileSync(file, 'site:\n title: Test Site\n');
|
||||
const cfg = loadConfig(file);
|
||||
expect(cfg.site.title).toBe('Test Site');
|
||||
expect(cfg.navigation.search).toBe(true);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
23
docs/__tests__/pluginHooks.test.js
Normal file
23
docs/__tests__/pluginHooks.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const loadPlugins = require('../src/config/loadPlugins');
|
||||
|
||||
test('plugin hook modifies data', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'plugins-'));
|
||||
const pluginFile = path.join(dir, 'test.plugin.js');
|
||||
fs.writeFileSync(
|
||||
pluginFile,
|
||||
"module.exports = { onParseMarkdown: ({ content }) => ({ content: content + '!!' }) };\n"
|
||||
);
|
||||
|
||||
const plugins = loadPlugins({ pluginsDir: dir, plugins: ['test.plugin'] });
|
||||
let data = { content: 'hello' };
|
||||
for (const plugin of plugins) {
|
||||
if (typeof plugin.onParseMarkdown === 'function') {
|
||||
const res = await plugin.onParseMarkdown(data);
|
||||
if (res !== undefined) data = res;
|
||||
}
|
||||
}
|
||||
expect(data.content).toBe('hello!!');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
77
docs/__tests__/renderMarkdown.test.js
Normal file
77
docs/__tests__/renderMarkdown.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
jest.mock('@11ty/eleventy', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
return class Eleventy {
|
||||
constructor(input, output) {
|
||||
this.input = input;
|
||||
this.output = output;
|
||||
}
|
||||
setConfig() {}
|
||||
async write() {
|
||||
const walk = d => {
|
||||
const entries = fs.readdirSync(d, { withFileTypes: true });
|
||||
let files = [];
|
||||
for (const e of entries) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) files = files.concat(walk(p));
|
||||
else if (p.endsWith('.md')) files.push(p);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
for (const file of walk(this.input)) {
|
||||
const rel = path.relative(this.input, file).replace(/\.md$/, '.html');
|
||||
const dest = path.join(this.output, rel);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, '<header></header><aside class="sidebar"></aside>');
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { generate } = require('../src/generator');
|
||||
|
||||
function getPaths(tree) {
|
||||
const paths = [];
|
||||
for (const node of tree) {
|
||||
if (node.path) paths.push(node.path);
|
||||
if (node.children) paths.push(...getPaths(node.children));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
test('markdown files render with layout and appear in nav/search', async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-test-'));
|
||||
const contentDir = path.join(tmp, 'content');
|
||||
const outputDir = path.join(tmp, '_site');
|
||||
fs.mkdirSync(path.join(contentDir, 'guide'), { recursive: true });
|
||||
fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\nWelcome');
|
||||
fs.writeFileSync(path.join(contentDir, 'guide', 'install.md'), '# Install\nSteps');
|
||||
const configPath = path.join(tmp, 'config.yaml');
|
||||
fs.writeFileSync(configPath, 'site:\n title: Test\n');
|
||||
|
||||
await generate({ contentDir, outputDir, configPath });
|
||||
|
||||
const indexHtml = fs.readFileSync(path.join(outputDir, 'index.html'), 'utf8');
|
||||
const installHtml = fs.readFileSync(path.join(outputDir, 'guide', 'install.html'), 'utf8');
|
||||
expect(indexHtml).toContain('<header');
|
||||
expect(indexHtml).toContain('<aside class="sidebar"');
|
||||
expect(installHtml).toContain('<header');
|
||||
expect(installHtml).toContain('<aside class="sidebar"');
|
||||
|
||||
const nav = JSON.parse(fs.readFileSync(path.join(outputDir, 'navigation.json'), 'utf8'));
|
||||
const navPaths = getPaths(nav);
|
||||
expect(navPaths).toContain('/index.html');
|
||||
expect(navPaths).toContain('/guide/install.html');
|
||||
|
||||
const search = JSON.parse(fs.readFileSync(path.join(outputDir, 'search-index.json'), 'utf8'));
|
||||
const docs = search.docs.map(d => d.id);
|
||||
expect(docs).toContain('index.html');
|
||||
expect(docs).toContain('guide/install.html');
|
||||
const installDoc = search.docs.find(d => d.id === 'guide/install.html');
|
||||
expect(installDoc.body).toContain('Steps');
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
128
docs/__tests__/responsive.test.js
Normal file
128
docs/__tests__/responsive.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
jest.mock('@11ty/eleventy', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
return class Eleventy {
|
||||
constructor(input, output) {
|
||||
this.input = input;
|
||||
this.output = output;
|
||||
}
|
||||
setConfig() {}
|
||||
async write() {
|
||||
const walk = d => {
|
||||
const entries = fs.readdirSync(d, { withFileTypes: true });
|
||||
let files = [];
|
||||
for (const e of entries) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) files = files.concat(walk(p));
|
||||
else if (p.endsWith('.md')) files.push(p);
|
||||
}
|
||||
return files;
|
||||
};
|
||||
for (const file of walk(this.input)) {
|
||||
const rel = path.relative(this.input, file).replace(/\.md$/, '.html');
|
||||
const dest = path.join(this.output, rel);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
dest,
|
||||
`<!DOCTYPE html><html><head><link rel="stylesheet" href="/assets/theme.css"></head><body><header><button id="sidebar-toggle" class="sidebar-toggle">☰</button></header><div class="container"><aside class="sidebar"></aside><main></main></div><script src="/assets/theme.js"></script></body></html>`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const os = require('os');
|
||||
const puppeteer = require('puppeteer');
|
||||
const { generate } = require('../src/generator');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
let server;
|
||||
let browser;
|
||||
let port;
|
||||
let tmp;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'df-responsive-'));
|
||||
const contentDir = path.join(tmp, 'content');
|
||||
const outputDir = path.join(tmp, '_site');
|
||||
fs.mkdirSync(contentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(contentDir, 'index.md'), '# Home\n');
|
||||
await generate({ contentDir, outputDir });
|
||||
fs.cpSync(path.join(__dirname, '../assets'), path.join(outputDir, 'assets'), { recursive: true });
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
let filePath = path.join(outputDir, req.url === '/' ? 'index.html' : req.url);
|
||||
if (req.url.startsWith('/assets')) {
|
||||
filePath = path.join(outputDir, req.url);
|
||||
}
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(filePath).slice(1);
|
||||
const type = { html: 'text/html', js: 'text/javascript', css: 'text/css' }[ext] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': type });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
await new Promise(resolve => {
|
||||
server.listen(0, () => {
|
||||
port = server.address().port;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) server.close();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('sidebar opens on small screens', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 500, height: 800 });
|
||||
await page.goto(`http://localhost:${port}/`);
|
||||
await page.waitForSelector('#sidebar-toggle');
|
||||
await page.click('#sidebar-toggle');
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open'));
|
||||
const sidebarLeft = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).left);
|
||||
expect(bodyClass).toBe(true);
|
||||
expect(sidebarLeft).toBe('0px');
|
||||
});
|
||||
|
||||
test('clicking outside closes sidebar on small screens', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 500, height: 800 });
|
||||
await page.goto(`http://localhost:${port}/`);
|
||||
await page.waitForSelector('#sidebar-toggle');
|
||||
await page.click('#sidebar-toggle');
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
await page.click('main');
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const bodyClass = await page.evaluate(() => document.body.classList.contains('sidebar-open'));
|
||||
expect(bodyClass).toBe(false);
|
||||
});
|
||||
|
||||
test('sidebar toggles on large screens', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1024, height: 800 });
|
||||
await page.goto(`http://localhost:${port}/`);
|
||||
await page.waitForSelector('#sidebar-toggle');
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
let sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width);
|
||||
expect(sidebarWidth).toBe('240px');
|
||||
await page.click('#sidebar-toggle');
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
sidebarWidth = await page.evaluate(() => getComputedStyle(document.querySelector('.sidebar')).width);
|
||||
expect(sidebarWidth).toBe('0px');
|
||||
});
|
@@ -1,137 +0,0 @@
|
||||
# SeedPass REST API Reference
|
||||
|
||||
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
|
||||
|
||||
## Starting the API
|
||||
|
||||
Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:
|
||||
|
||||
```bash
|
||||
$ seedpass api start
|
||||
API token: abcdef1234567890
|
||||
```
|
||||
|
||||
Keep this token secret. Every request must include it in the `Authorization` header using the `Bearer` scheme.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/v1/entry?query=<text>` – Search entries matching a query.
|
||||
- `GET /api/v1/entry/{id}` – Retrieve a single entry by its index.
|
||||
- `POST /api/v1/entry` – Create a new entry of any supported type.
|
||||
- `PUT /api/v1/entry/{id}` – Modify an existing entry.
|
||||
- `PUT /api/v1/config/{key}` – Update a configuration value.
|
||||
- `POST /api/v1/secret-mode` – Enable or disable Secret Mode and set the clipboard delay.
|
||||
- `POST /api/v1/entry/{id}/archive` – Archive an entry.
|
||||
- `POST /api/v1/entry/{id}/unarchive` – Unarchive an entry.
|
||||
- `GET /api/v1/config/{key}` – Return the value for a configuration key.
|
||||
- `GET /api/v1/fingerprint` – List available seed fingerprints.
|
||||
- `POST /api/v1/fingerprint` – Add a new seed fingerprint.
|
||||
- `DELETE /api/v1/fingerprint/{fp}` – Remove a fingerprint.
|
||||
- `POST /api/v1/fingerprint/select` – Switch the active fingerprint.
|
||||
- `GET /api/v1/totp/export` – Export all TOTP entries as JSON.
|
||||
- `GET /api/v1/totp` – Return current TOTP codes and remaining time.
|
||||
- `GET /api/v1/stats` – Return statistics about the active seed profile.
|
||||
- `GET /api/v1/parent-seed` – Reveal the parent seed or save it with `?file=`.
|
||||
- `GET /api/v1/nostr/pubkey` – Fetch the Nostr public key for the active seed.
|
||||
- `POST /api/v1/checksum/verify` – Verify the checksum of the running script.
|
||||
- `POST /api/v1/checksum/update` – Update the stored script checksum.
|
||||
- `POST /api/v1/change-password` – Change the master password for the active profile.
|
||||
- `POST /api/v1/vault/import` – Import a vault backup from a file or path.
|
||||
- `POST /api/v1/vault/lock` – Lock the vault and clear sensitive data from memory.
|
||||
- `POST /api/v1/shutdown` – Stop the server gracefully.
|
||||
|
||||
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.
|
||||
|
||||
## Example Requests
|
||||
|
||||
Send requests with the token in the header:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"http://127.0.0.1:8000/api/v1/entry?query=email"
|
||||
```
|
||||
|
||||
### Creating an Entry
|
||||
|
||||
`POST /api/v1/entry` accepts a JSON body with at least a `label` field. Set
|
||||
`type` (or `kind`) to choose the entry variant (`password`, `totp`, `ssh`, `pgp`,
|
||||
`nostr`, `seed`, `key_value`, or `managed_account`). Additional fields vary by
|
||||
type:
|
||||
|
||||
- **password** – `length`, optional `username`, `url` and `notes`
|
||||
- **totp** – `secret` or `index`, optional `period`, `digits`, `notes`, `archived`
|
||||
- **ssh/nostr/seed/managed_account** – `index`, optional `notes`, `archived`
|
||||
- **pgp** – `index`, `key_type`, `user_id`, optional `notes`, `archived`
|
||||
- **key_value** – `value`, optional `notes`
|
||||
|
||||
Example creating a TOTP entry:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/entry \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type": "totp", "label": "Email", "secret": "JBSW..."}'
|
||||
```
|
||||
|
||||
### Updating an Entry
|
||||
|
||||
Use `PUT /api/v1/entry/{id}` to change fields such as `label`, `username`,
|
||||
`url`, `notes`, `period`, `digits` or `value` depending on the entry type.
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/entry/1 \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "alice"}'
|
||||
```
|
||||
|
||||
### Updating Configuration
|
||||
|
||||
Send a JSON body containing a `value` field to `PUT /api/v1/config/{key}`:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": 300}'
|
||||
```
|
||||
|
||||
### Toggling Secret Mode
|
||||
|
||||
Send both `enabled` and `delay` values to `/api/v1/secret-mode`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/secret-mode \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enabled": true, "delay": 20}'
|
||||
```
|
||||
|
||||
### Switching Fingerprints
|
||||
|
||||
Change the active seed profile via `POST /api/v1/fingerprint/select`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fingerprint": "abc123"}'
|
||||
```
|
||||
|
||||
### Enabling CORS
|
||||
|
||||
Cross‑origin requests are disabled by default. Set `SEEDPASS_CORS_ORIGINS` to a comma‑separated list of allowed origins before starting the API:
|
||||
|
||||
```bash
|
||||
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
|
||||
```
|
||||
|
||||
Browsers can then call the API from the specified origins, for example using JavaScript:
|
||||
|
||||
```javascript
|
||||
fetch('http://127.0.0.1:8000/api/v1/entry?query=email', {
|
||||
headers: { Authorization: 'Bearer <token>' }
|
||||
});
|
||||
```
|
||||
|
||||
Without CORS enabled, only same‑origin or command‑line tools like `curl` can access the API.
|
3475
docs/assets/lunr.js
Normal file
3475
docs/assets/lunr.js
Normal file
File diff suppressed because it is too large
Load Diff
160
docs/assets/theme.css
Normal file
160
docs/assets/theme.css
Normal file
@@ -0,0 +1,160 @@
|
||||
:root {
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #333333;
|
||||
--sidebar-bg: #f3f3f3;
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
--bg-color: #222222;
|
||||
--text-color: #eeeeee;
|
||||
--sidebar-bg: #333333;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--sidebar-bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
.search-input {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.search-results {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 100%;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid #ccc;
|
||||
width: 250px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
.search-results a {
|
||||
display: block;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
.search-results a:hover {
|
||||
background: var(--sidebar-bg);
|
||||
}
|
||||
.search-results .no-results {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.logo { text-decoration: none; color: var(--text-color); font-weight: bold; }
|
||||
.sidebar-toggle,
|
||||
.theme-toggle { background: none; border: none; font-size: 1.2rem; margin-right: 1rem; cursor: pointer; }
|
||||
.container { display: flex; flex: 1; }
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--sidebar-bg);
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sidebar ul { list-style: none; padding: 0; margin: 0; }
|
||||
.sidebar li { margin: 0.25rem 0; }
|
||||
.sidebar a { text-decoration: none; color: var(--text-color); display: block; padding: 0.25rem 0; }
|
||||
.sidebar nav { font-size: 0.9rem; }
|
||||
.nav-link:hover { text-decoration: underline; }
|
||||
.nav-link.active { font-weight: bold; }
|
||||
.nav-section summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-section summary::-webkit-details-marker { display: none; }
|
||||
.nav-section summary::before {
|
||||
content: '▸';
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.nav-section[open] > summary::before { transform: rotate(90deg); }
|
||||
.nav-level { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; }
|
||||
.sidebar ul ul { padding-left: 1rem; margin-left: 0.5rem; border-left: 2px solid #ccc; }
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
}
|
||||
.breadcrumbs a { color: var(--text-color); text-decoration: none; }
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background: var(--sidebar-bg);
|
||||
position: relative;
|
||||
}
|
||||
.footer-links {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.footer-links a {
|
||||
margin: 0 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.footer-permanent-links {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.footer-permanent-links a {
|
||||
margin-left: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
body.sidebar-open .sidebar-overlay {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: -100%;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
transition: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
body.sidebar-open .sidebar { left: 0; }
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.sidebar {
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
body:not(.sidebar-open) .sidebar {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
107
docs/assets/theme.js
Normal file
107
docs/assets/theme.js
Normal file
@@ -0,0 +1,107 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sidebarToggle = document.getElementById('sidebar-toggle');
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||
const root = document.documentElement;
|
||||
|
||||
function setTheme(theme) {
|
||||
root.dataset.theme = theme;
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) setTheme(stored);
|
||||
|
||||
if (window.innerWidth > 768) {
|
||||
document.body.classList.add('sidebar-open');
|
||||
}
|
||||
|
||||
sidebarToggle?.addEventListener('click', () => {
|
||||
document.body.classList.toggle('sidebar-open');
|
||||
});
|
||||
|
||||
sidebarOverlay?.addEventListener('click', () => {
|
||||
document.body.classList.remove('sidebar-open');
|
||||
});
|
||||
|
||||
themeToggle?.addEventListener('click', () => {
|
||||
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
});
|
||||
|
||||
// search
|
||||
let lunrIndex;
|
||||
let docs = [];
|
||||
async function loadIndex() {
|
||||
if (lunrIndex) return;
|
||||
try {
|
||||
const res = await fetch('/search-index.json');
|
||||
const data = await res.json();
|
||||
lunrIndex = lunr.Index.load(data.index);
|
||||
docs = data.docs;
|
||||
} catch (e) {
|
||||
console.error('Search index failed to load', e);
|
||||
}
|
||||
}
|
||||
|
||||
function highlight(text, q) {
|
||||
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + ')', 'gi');
|
||||
return text.replace(re, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', async e => {
|
||||
const q = e.target.value.trim();
|
||||
await loadIndex();
|
||||
if (!lunrIndex || !q) {
|
||||
searchResults.style.display = 'none';
|
||||
searchResults.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const matches = lunrIndex.search(q);
|
||||
searchResults.innerHTML = '';
|
||||
if (!matches.length) {
|
||||
searchResults.innerHTML = '<div class="no-results">No matches found</div>';
|
||||
searchResults.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
matches.forEach(m => {
|
||||
const doc = docs.find(d => d.id === m.ref);
|
||||
if (!doc) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = doc.url;
|
||||
const snippet = doc.body ? doc.body.slice(0, 160) + (doc.body.length > 160 ? '...' : '') : '';
|
||||
a.innerHTML = '<strong>' + highlight(doc.title, q) + '</strong><br><small>' + highlight(snippet, q) + '</small>';
|
||||
searchResults.appendChild(a);
|
||||
});
|
||||
searchResults.style.display = 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.style.display = 'none';
|
||||
}
|
||||
if (
|
||||
window.innerWidth <= 768 &&
|
||||
document.body.classList.contains('sidebar-open') &&
|
||||
sidebar &&
|
||||
!sidebar.contains(e.target) &&
|
||||
e.target !== sidebarToggle
|
||||
) {
|
||||
document.body.classList.remove('sidebar-open');
|
||||
}
|
||||
});
|
||||
|
||||
// breadcrumbs
|
||||
const bc = document.getElementById('breadcrumbs');
|
||||
if (bc) {
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
let path = '';
|
||||
bc.innerHTML = '<a href="/">Home</a>';
|
||||
parts.forEach((p) => {
|
||||
path += '/' + p;
|
||||
bc.innerHTML += ' / <a href="' + path + '">' + p.replace(/-/g, ' ') + '</a>';
|
||||
});
|
||||
}
|
||||
});
|
45
docs/bin/create-archivox.js
Executable file
45
docs/bin/create-archivox.js
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function copyDir(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDir(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const install = args.includes('--install');
|
||||
const targetArg = args.find(a => !a.startsWith('-')) || '.';
|
||||
const targetDir = path.resolve(process.cwd(), targetArg);
|
||||
|
||||
const templateDir = path.join(__dirname, '..', 'starter');
|
||||
copyDir(templateDir, targetDir);
|
||||
|
||||
const pkgPath = path.join(targetDir, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
const version = require('../package.json').version;
|
||||
if (pkg.dependencies && pkg.dependencies.archivox)
|
||||
pkg.dependencies.archivox = `^${version}`;
|
||||
pkg.name = path.basename(targetDir);
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
||||
}
|
||||
|
||||
if (install) {
|
||||
execSync('npm install', { cwd: targetDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
console.log(`Archivox starter created at ${targetDir}`);
|
||||
}
|
||||
|
||||
main();
|
15
docs/build-docs.js
Executable file
15
docs/build-docs.js
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
const { generate } = require('./src/generator');
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const contentDir = path.join(__dirname, 'docs', 'content');
|
||||
const configPath = path.join(__dirname, 'docs', 'config.yaml');
|
||||
const outputDir = path.join(__dirname, '_site');
|
||||
await generate({ contentDir, outputDir, configPath });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
12
docs/docs/config.yaml
Normal file
12
docs/docs/config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
site:
|
||||
title: "SeedPass Docs"
|
||||
description: "One seed to rule them all."
|
||||
|
||||
navigation:
|
||||
search: true
|
||||
|
||||
footer:
|
||||
links:
|
||||
- text: "SeedPass"
|
||||
url: "https://seedpass.me/"
|
||||
|
@@ -49,15 +49,15 @@ Manage individual entries within a vault.
|
||||
| List entries | `entry list` | `seedpass entry list --sort label` |
|
||||
| Search for entries | `entry search` | `seedpass entry search "GitHub"` |
|
||||
| Retrieve an entry's secret (password or TOTP code) | `entry get` | `seedpass entry get "GitHub"` |
|
||||
| Add a password entry | `entry add` | `seedpass entry add Example --length 16` |
|
||||
| Add a password entry | `entry add` | `seedpass entry add Example --length 16 --no-special --exclude-ambiguous` |
|
||||
| Add a TOTP entry | `entry add-totp` | `seedpass entry add-totp Email --secret JBSW...` |
|
||||
| Add an SSH key entry | `entry add-ssh` | `seedpass entry add-ssh Server --index 0` |
|
||||
| Add a PGP key entry | `entry add-pgp` | `seedpass entry add-pgp Personal --user-id me@example.com` |
|
||||
| Add a Nostr key entry | `entry add-nostr` | `seedpass entry add-nostr Chat` |
|
||||
| Add a seed phrase entry | `entry add-seed` | `seedpass entry add-seed Backup --words 24` |
|
||||
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --value abc123` |
|
||||
| Add a key/value entry | `entry add-key-value` | `seedpass entry add-key-value "API Token" --key api --value abc123` |
|
||||
| Add a managed account entry | `entry add-managed-account` | `seedpass entry add-managed-account Trading` |
|
||||
| Modify an entry | `entry modify` | `seedpass entry modify 1 --username alice` |
|
||||
| Modify an entry | `entry modify` | `seedpass entry modify 1 --key new --value updated` |
|
||||
| Archive an entry | `entry archive` | `seedpass entry archive 1` |
|
||||
| Unarchive an entry | `entry unarchive` | `seedpass entry unarchive 1` |
|
||||
| Export all TOTP secrets | `entry export-totp` | `seedpass entry export-totp --file totp.json` |
|
||||
@@ -70,10 +70,11 @@ Manage the entire vault for a profile.
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Export the vault | `vault export` | `seedpass vault export --file backup.json` |
|
||||
| Import a vault | `vault import` | `seedpass vault import --file backup.json` |
|
||||
| Import a vault | `vault import` | `seedpass vault import --file backup.json` *(also syncs with Nostr)* |
|
||||
| Change the master password | `vault change-password` | `seedpass vault change-password` |
|
||||
| Lock the vault | `vault lock` | `seedpass vault lock` |
|
||||
| Show profile statistics | `vault stats` | `seedpass vault stats` |
|
||||
| Reveal or back up the parent seed | `vault reveal-parent-seed` | `seedpass vault reveal-parent-seed --file backup.enc` |
|
||||
|
||||
### Nostr Commands
|
||||
|
||||
@@ -90,8 +91,9 @@ Manage profile‑specific settings.
|
||||
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Get a setting value | `config get` | `seedpass config get inactivity_timeout` |
|
||||
| Set a setting value | `config set` | `seedpass config set inactivity_timeout 300` |
|
||||
| Get a setting value | `config get` | `seedpass config get kdf_iterations` |
|
||||
| Set a setting value | `config set` | `seedpass config set backup_interval 3600` |
|
||||
| Toggle offline mode | `config toggle-offline` | `seedpass config toggle-offline` |
|
||||
|
||||
### Fingerprint Commands
|
||||
|
||||
@@ -110,10 +112,14 @@ Miscellaneous helper commands.
|
||||
|
||||
| Action | Command | Examples |
|
||||
| :--- | :--- | :--- |
|
||||
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24` |
|
||||
| Generate a password | `util generate-password` | `seedpass util generate-password --length 24 --special-mode safe --exclude-ambiguous` |
|
||||
| Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` |
|
||||
| Update script checksum | `util update-checksum` | `seedpass util update-checksum` |
|
||||
|
||||
If you see a startup warning about a script checksum mismatch,
|
||||
run `seedpass util update-checksum` or choose "Generate Script Checksum"
|
||||
from the Settings menu to update the stored value.
|
||||
|
||||
### API Commands
|
||||
|
||||
Run or stop the local HTTP API.
|
||||
@@ -130,17 +136,17 @@ Run or stop the local HTTP API.
|
||||
### `entry` Commands
|
||||
|
||||
- **`seedpass entry list`** – List entries in the vault, optionally sorted or filtered.
|
||||
- **`seedpass entry search <query>`** – Search across labels, usernames, URLs and notes.
|
||||
- **`seedpass entry search <query>`** – Search across labels, usernames, URLs and notes. Results show the entry type before each label.
|
||||
- **`seedpass entry get <query>`** – Retrieve the password or TOTP code for one matching entry, depending on the entry's type.
|
||||
- **`seedpass entry add <label>`** – Create a new password entry. Use `--length` to set the password length and optional `--username`/`--url` values.
|
||||
- **`seedpass entry add <label>`** – Create a new password entry. Use `--length` and flags like `--no-special`, `--special-mode safe`, or `--exclude-ambiguous` to override the global policy.
|
||||
- **`seedpass entry add-totp <label>`** – Create a TOTP entry. Use `--secret` to import an existing secret or `--index` to derive from the seed.
|
||||
- **`seedpass entry add-ssh <label>`** – Create an SSH key entry derived from the seed.
|
||||
- **`seedpass entry add-pgp <label>`** – Create a PGP key entry. Provide `--user-id` and `--key-type` as needed.
|
||||
- **`seedpass entry add-nostr <label>`** – Create a Nostr key entry for decentralised chat.
|
||||
- **`seedpass entry add-seed <label>`** – Store a derived seed phrase. Use `--words` to set the word count.
|
||||
- **`seedpass entry add-key-value <label>`** – Store arbitrary data with `--value`.
|
||||
- **`seedpass entry add-key-value <label>`** – Store arbitrary data with `--key` and `--value`.
|
||||
- **`seedpass entry add-managed-account <label>`** – Store a BIP‑85 derived account seed.
|
||||
- **`seedpass entry modify <id>`** – Update an entry's label, username, URL or notes.
|
||||
- **`seedpass entry modify <id>`** – Update an entry's fields. For key/value entries you can change the label, key and value.
|
||||
- **`seedpass entry archive <id>`** – Mark an entry as archived so it is hidden from normal lists.
|
||||
- **`seedpass entry unarchive <id>`** – Restore an archived entry.
|
||||
- **`seedpass entry export-totp --file <path>`** – Export all stored TOTP secrets to a JSON file.
|
||||
@@ -154,13 +160,22 @@ $ seedpass entry get "email"
|
||||
Code: 123456
|
||||
```
|
||||
|
||||
### Viewing Entry Details
|
||||
|
||||
Picking an entry from `entry list` or `entry search` displays its metadata first
|
||||
so you can review the label, username and notes. Sensitive fields are hidden
|
||||
until you confirm you want to reveal them. After showing the secret, the details
|
||||
view offers the same actions as `entry get`—edit the entry, archive it or show
|
||||
QR codes for supported types.
|
||||
|
||||
### `vault` Commands
|
||||
|
||||
- **`seedpass vault export`** – Export the entire vault to an encrypted JSON file.
|
||||
- **`seedpass vault import`** – Import a vault from an encrypted JSON file.
|
||||
- **`seedpass vault import`** – Import a vault from an encrypted JSON file and automatically sync via Nostr.
|
||||
- **`seedpass vault change-password`** – Change the master password used for encryption.
|
||||
- **`seedpass vault lock`** – Clear sensitive data from memory and require reauthentication.
|
||||
- **`seedpass vault stats`** – Display statistics about the active seed profile.
|
||||
- **`seedpass vault reveal-parent-seed`** – Print the parent seed or write an encrypted backup with `--file`.
|
||||
|
||||
### `nostr` Commands
|
||||
|
||||
@@ -169,9 +184,10 @@ Code: 123456
|
||||
|
||||
### `config` Commands
|
||||
|
||||
- **`seedpass config get <key>`** – Retrieve a configuration value such as `inactivity_timeout`, `secret_mode`, or `auto_sync`.
|
||||
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set inactivity_timeout 300`.
|
||||
- **`seedpass config get <key>`** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, `quick_unlock`, `nostr_max_retries`, `nostr_retry_delay`, or password policy fields like `min_uppercase`.
|
||||
- **`seedpass config set <key> <value>`** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, `min_special`, `include_special_chars`, `allowed_special_chars`, `special_mode`, `exclude_ambiguous`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` to adjust settings.
|
||||
- **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay.
|
||||
- **`seedpass config toggle-offline`** – Enable or disable offline mode to skip Nostr operations.
|
||||
|
||||
### `fingerprint` Commands
|
||||
|
||||
@@ -206,5 +222,6 @@ Shut down the server with `seedpass api stop`.
|
||||
|
||||
- Use the `--help` flag for details on any command.
|
||||
- Set a strong master password and regularly export encrypted backups.
|
||||
- Adjust configuration values like `inactivity_timeout` or `secret_mode` through the `config` commands.
|
||||
- Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `nostr_max_retries`, `nostr_retry_delay`, or `quick_unlock` through the `config` commands.
|
||||
- Customize the global password policy with commands like `config set min_uppercase 3` or `config set special_mode safe`. When adding a password interactively you can override these values, choose a safe special-character set, and exclude ambiguous characters.
|
||||
- `entry get` is script‑friendly and can be piped into other commands.
|
299
docs/docs/content/01-getting-started/02-api_reference.md
Normal file
299
docs/docs/content/01-getting-started/02-api_reference.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# SeedPass REST API Reference
|
||||
|
||||
This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
|
||||
|
||||
**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
|
||||
|
||||
|
||||
## Starting the API
|
||||
|
||||
Run `seedpass api start` from your terminal. The command prints a short‑lived JWT token used for authentication:
|
||||
|
||||
```bash
|
||||
$ seedpass api start
|
||||
API token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
|
||||
```
|
||||
|
||||
Keep this token secret and avoid logging it. Tokens expire after a few minutes and every request must include one in the `Authorization` header using the `Bearer` scheme.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /api/v1/entry?query=<text>` – Search entries matching a query.
|
||||
- `GET /api/v1/entry/{id}` – Retrieve a single entry by its index. Requires an `X-SeedPass-Password` header.
|
||||
- `POST /api/v1/entry` – Create a new entry of any supported type.
|
||||
- `PUT /api/v1/entry/{id}` – Modify an existing entry.
|
||||
- `PUT /api/v1/config/{key}` – Update a configuration value.
|
||||
- `POST /api/v1/secret-mode` – Enable or disable Secret Mode and set the clipboard delay.
|
||||
- `POST /api/v1/entry/{id}/archive` – Archive an entry.
|
||||
- `POST /api/v1/entry/{id}/unarchive` – Unarchive an entry.
|
||||
- `GET /api/v1/config/{key}` – Return the value for a configuration key.
|
||||
- `GET /api/v1/fingerprint` – List available seed fingerprints.
|
||||
- `POST /api/v1/fingerprint` – Add a new seed fingerprint.
|
||||
- `DELETE /api/v1/fingerprint/{fp}` – Remove a fingerprint.
|
||||
- `POST /api/v1/fingerprint/select` – Switch the active fingerprint.
|
||||
- `GET /api/v1/totp/export` – Export all TOTP entries as JSON. Requires an `X-SeedPass-Password` header.
|
||||
- `GET /api/v1/totp` – Return current TOTP codes and remaining time. Requires an `X-SeedPass-Password` header.
|
||||
- `GET /api/v1/stats` – Return statistics about the active seed profile.
|
||||
- `GET /api/v1/notifications` – Retrieve and clear queued notifications. Messages appear in the persistent notification box but remain queued until fetched.
|
||||
- `GET /api/v1/nostr/pubkey` – Fetch the Nostr public key for the active seed.
|
||||
- `POST /api/v1/checksum/verify` – Verify the checksum of the running script.
|
||||
- `POST /api/v1/checksum/update` – Update the stored script checksum.
|
||||
- `POST /api/v1/change-password` – Change the master password for the active profile.
|
||||
- `POST /api/v1/vault/import` – Import a vault backup from a file or path.
|
||||
- `POST /api/v1/vault/export` – Export the vault and download the encrypted file. Requires an additional `X-SeedPass-Password` header.
|
||||
- `POST /api/v1/vault/backup-parent-seed` – Save an encrypted backup of the parent seed. Requires a `confirm` flag in the request body and an `X-SeedPass-Password` header.
|
||||
- `POST /api/v1/vault/lock` – Lock the vault and clear sensitive data from memory.
|
||||
- `GET /api/v1/relays` – List configured Nostr relays.
|
||||
- `POST /api/v1/relays` – Add a relay URL.
|
||||
- `DELETE /api/v1/relays/{idx}` – Remove the relay at the given index (1‑based).
|
||||
- `POST /api/v1/relays/reset` – Reset the relay list to defaults.
|
||||
- `POST /api/v1/shutdown` – Stop the server gracefully.
|
||||
|
||||
|
||||
## Secure Deployment
|
||||
|
||||
Always run the API behind HTTPS. Use a reverse proxy such as Nginx or Caddy to terminate TLS and forward requests to SeedPass. Example Nginx configuration:
|
||||
|
||||
```
|
||||
server {
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For local testing, Uvicorn can serve TLS directly:
|
||||
|
||||
```
|
||||
uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
||||
```
|
||||
|
||||
## Example Requests
|
||||
|
||||
Send requests with the token in the header:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
"https://127.0.0.1:8000/api/v1/entry?query=email"
|
||||
```
|
||||
|
||||
### Creating an Entry
|
||||
|
||||
`POST /api/v1/entry` accepts a JSON body with at least a `label` field. Set
|
||||
`type` (or `kind`) to choose the entry variant (`password`, `totp`, `ssh`, `pgp`,
|
||||
`nostr`, `seed`, `key_value`, or `managed_account`). Additional fields vary by
|
||||
type:
|
||||
|
||||
- **password** – `length`, optional `username`, `url` and `notes`
|
||||
- **totp** – `secret` or `index`, optional `period`, `digits`, `notes`, `archived`
|
||||
- **ssh/nostr/seed/managed_account** – `index`, optional `notes`, `archived`
|
||||
- **pgp** – `index`, `key_type`, `user_id`, optional `notes`, `archived`
|
||||
- **key_value** – `value`, optional `notes`
|
||||
|
||||
Example creating a TOTP entry:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/entry \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type": "totp", "label": "Email", "secret": "JBSW..."}'
|
||||
```
|
||||
|
||||
### Updating an Entry
|
||||
|
||||
Use `PUT /api/v1/entry/{id}` to change fields such as `label`, `username`,
|
||||
`url`, `notes`, `period`, `digits` or `value` depending on the entry type.
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/entry/1 \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "alice"}'
|
||||
```
|
||||
|
||||
### Updating Configuration
|
||||
|
||||
Send a JSON body containing a `value` field to `PUT /api/v1/config/{key}`:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/config/inactivity_timeout \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": 300}'
|
||||
```
|
||||
|
||||
To raise the PBKDF2 work factor or change how often backups are written:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/config/kdf_iterations \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": 200000}'
|
||||
|
||||
curl -X PUT http://127.0.0.1:8000/api/v1/config/backup_interval \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": 3600}'
|
||||
```
|
||||
|
||||
Using fewer iterations or a long interval reduces security, so adjust these values carefully.
|
||||
|
||||
### Toggling Secret Mode
|
||||
|
||||
Send both `enabled` and `delay` values to `/api/v1/secret-mode`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/secret-mode \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enabled": true, "delay": 20}'
|
||||
```
|
||||
|
||||
### Switching Fingerprints
|
||||
|
||||
Change the active seed profile via `POST /api/v1/fingerprint/select`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fingerprint": "abc123"}'
|
||||
```
|
||||
|
||||
### Exporting the Vault
|
||||
|
||||
Download an encrypted vault backup via `POST /api/v1/vault/export`:
|
||||
|
||||
```bash
|
||||
curl -X POST https://127.0.0.1:8000/api/v1/vault/export \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "X-SeedPass-Password: <master-password>" \
|
||||
-o backup.json
|
||||
```
|
||||
|
||||
### Importing a Vault
|
||||
|
||||
Restore a backup with `POST /api/v1/vault/import`. Use `-F` to upload a file:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/vault/import \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F file=@backup.json
|
||||
```
|
||||
|
||||
### Locking the Vault
|
||||
|
||||
Clear sensitive data from memory using `/api/v1/vault/lock`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/vault/lock \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Backing Up the Parent Seed
|
||||
|
||||
Trigger an encrypted seed backup with `/api/v1/vault/backup-parent-seed`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/vault/backup-parent-seed \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "X-SeedPass-Password: <master password>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path": "seed_backup.enc", "confirm": true}'
|
||||
```
|
||||
|
||||
### Retrieving Vault Statistics
|
||||
|
||||
Get profile stats such as entry counts with `GET /api/v1/stats`:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://127.0.0.1:8000/api/v1/stats
|
||||
```
|
||||
|
||||
### Checking Notifications
|
||||
|
||||
Get queued messages with `GET /api/v1/notifications`:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <token>" \
|
||||
http://127.0.0.1:8000/api/v1/notifications
|
||||
```
|
||||
|
||||
The TUI displays these alerts in a persistent notification box for 10 seconds,
|
||||
but the endpoint returns all queued messages even if they have already
|
||||
disappeared from the screen.
|
||||
|
||||
### Changing the Master Password
|
||||
|
||||
Update the vault password via `POST /api/v1/change-password`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/change-password \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Verifying the Script Checksum
|
||||
|
||||
Check that the running script matches the stored checksum:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/checksum/verify \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Updating the Script Checksum
|
||||
|
||||
Regenerate the stored checksum using `/api/v1/checksum/update`:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/checksum/update \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Managing Relays
|
||||
|
||||
List, add, or remove Nostr relays:
|
||||
|
||||
```bash
|
||||
# list
|
||||
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8000/api/v1/relays
|
||||
|
||||
# add
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/relays \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "wss://relay.example.com"}'
|
||||
|
||||
# remove first relay
|
||||
curl -X DELETE http://127.0.0.1:8000/api/v1/relays/1 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# reset to defaults
|
||||
curl -X POST http://127.0.0.1:8000/api/v1/relays/reset \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Enabling CORS
|
||||
|
||||
Cross‑origin requests are disabled by default. Set `SEEDPASS_CORS_ORIGINS` to a comma‑separated list of allowed origins before starting the API:
|
||||
|
||||
```bash
|
||||
SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start
|
||||
```
|
||||
|
||||
Browsers can then call the API from the specified origins, for example using JavaScript:
|
||||
|
||||
```javascript
|
||||
fetch('http://127.0.0.1:8000/api/v1/entry?query=email', {
|
||||
headers: { Authorization: 'Bearer <token>' }
|
||||
});
|
||||
```
|
||||
|
||||
Without CORS enabled, only same‑origin or command‑line tools like `curl` can access the API.
|
@@ -95,10 +95,22 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
|
||||
- **custom_fields** (`array`, optional): Additional user-defined fields.
|
||||
- **origin** (`string`, optional): Source identifier for imported data.
|
||||
- **value** (`string`, optional): For `key_value` entries, stores the secret value.
|
||||
- **key** (`string`, optional): Name of the key for `key_value` entries.
|
||||
- **index** (`integer`, optional): BIP-85 derivation index for entries that derive material from a seed.
|
||||
- **word_count** (`integer`, managed_account only): Number of words in the child seed. Managed accounts always use `12`.
|
||||
- **fingerprint** (`string`, managed_account only): Identifier of the child profile, used for its directory name.
|
||||
- **tags** (`array`, optional): Category labels to aid in organization and search.
|
||||
|
||||
#### Password Policy Fields
|
||||
|
||||
- **min_uppercase** (`integer`, default `2`): Minimum required uppercase letters.
|
||||
- **min_lowercase** (`integer`, default `2`): Minimum required lowercase letters.
|
||||
- **min_digits** (`integer`, default `2`): Minimum required digits.
|
||||
- **min_special** (`integer`, default `2`): Minimum required special characters.
|
||||
- **include_special_chars** (`boolean`, default `true`): Enable or disable any punctuation in generated passwords.
|
||||
- **allowed_special_chars** (`string`, optional): Restrict punctuation to this exact set.
|
||||
- **special_mode** (`string`, default `"standard"`): Choose `"safe"` for the `SAFE_SPECIAL_CHARS` set (`!@#$%^*-_+=?`), otherwise the full `string.punctuation` is used.
|
||||
- **exclude_ambiguous** (`boolean`, default `false`): Omit confusing characters like `O0Il1`.
|
||||
Example:
|
||||
|
||||
```json
|
||||
@@ -160,6 +172,17 @@ Each entry is stored within `seedpass_entries_db.json.enc` under the `entries` d
|
||||
}
|
||||
```
|
||||
|
||||
#### Password Entry with Policy Overrides
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "Custom Policy",
|
||||
"length": 16,
|
||||
"include_special_chars": false,
|
||||
"exclude_ambiguous": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Managed User
|
||||
|
||||
```json
|
49
docs/docs/content/01-getting-started/04-migrations.md
Normal file
49
docs/docs/content/01-getting-started/04-migrations.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Index Migrations
|
||||
|
||||
SeedPass stores its password index in an encrypted JSON file. Each index contains
|
||||
a `schema_version` field so the application knows how to upgrade older files.
|
||||
|
||||
> **Note:** Recent releases derive passwords and other artifacts using a new deterministic algorithm that works consistently across Python versions. Artifacts produced with older versions will not match outputs from this release and must be regenerated.
|
||||
|
||||
## How migrations work
|
||||
|
||||
When the vault loads the index, `Vault.load_index()` checks the version and
|
||||
applies migrations defined in `password_manager/migrations.py`. The
|
||||
`apply_migrations()` function iterates through registered migrations until the
|
||||
file reaches `LATEST_VERSION`.
|
||||
|
||||
If an old file lacks `schema_version`, it is treated as version 0 and upgraded
|
||||
to the latest format. Attempting to load an index from a future version will
|
||||
raise an error.
|
||||
|
||||
## Upgrading an index
|
||||
|
||||
1. The JSON is decrypted and parsed.
|
||||
2. `apply_migrations()` applies any necessary steps, such as injecting the
|
||||
`schema_version` field on first upgrade.
|
||||
3. After migration, the updated index is saved back to disk.
|
||||
|
||||
This process happens automatically; users only need to open their vault to
|
||||
upgrade older indices.
|
||||
|
||||
### Legacy Fernet migration
|
||||
|
||||
Older versions stored the vault index in a file named
|
||||
`seedpass_passwords_db.json.enc` encrypted with Fernet. When opening such a
|
||||
vault, SeedPass now automatically decrypts the legacy file, re‑encrypts it using
|
||||
AES‑GCM, and saves it under the new name `seedpass_entries_db.json.enc`.
|
||||
The original Fernet file is preserved as
|
||||
`seedpass_entries_db.json.enc.fernet` and the legacy checksum file, if present,
|
||||
is renamed to `seedpass_entries_db_checksum.txt.fernet`.
|
||||
|
||||
No additional command is required – simply open your existing vault and the
|
||||
conversion happens transparently.
|
||||
|
||||
### Parent seed backup migration
|
||||
|
||||
If your vault contains a `parent_seed.enc` file that was encrypted with Fernet,
|
||||
SeedPass performs a similar upgrade. Upon loading the vault, the application
|
||||
decrypts the old file, re‑encrypts it with AES‑GCM, and writes the result back to
|
||||
`parent_seed.enc`. The legacy Fernet file is preserved as
|
||||
`parent_seed.enc.fernet` so you can revert if needed. No manual steps are
|
||||
required – simply unlock your vault and the conversion runs automatically.
|
29
docs/docs/content/01-getting-started/05-briefcase.md
Normal file
29
docs/docs/content/01-getting-started/05-briefcase.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Packaging the GUI with Briefcase
|
||||
|
||||
This project uses [BeeWare's Briefcase](https://beeware.org) to generate
|
||||
platform‑native installers. Once your development environment is set up,
|
||||
package the GUI by running the following commands from the repository root:
|
||||
|
||||
```bash
|
||||
# Create the application scaffold for your platform
|
||||
briefcase create
|
||||
|
||||
# Compile dependencies and produce a distributable bundle
|
||||
briefcase build
|
||||
|
||||
# Run the packaged application
|
||||
briefcase run
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
|
||||
- **`briefcase create`** — generates the project scaffold for your
|
||||
operating system. Run this once per platform.
|
||||
- **`briefcase build`** — compiles dependencies and produces the
|
||||
distributable bundle.
|
||||
- **`briefcase run`** — launches the packaged application so you can test
|
||||
it locally.
|
||||
|
||||
After the initial creation step you can repeatedly run `briefcase build`
|
||||
followed by `briefcase run` to test your packaged application on Windows,
|
||||
macOS or Linux.
|
165
docs/docs/content/01-getting-started/06-gui_adapter.md
Normal file
165
docs/docs/content/01-getting-started/06-gui_adapter.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# BeeWare GUI Adapter
|
||||
|
||||
SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
|
||||
|
||||
|
||||
## Getting Started with the GUI
|
||||
|
||||
After installing the project dependencies, launch the desktop interface with one
|
||||
of the following commands:
|
||||
|
||||
```bash
|
||||
seedpass gui
|
||||
python -m seedpass_gui
|
||||
seedpass-gui
|
||||
```
|
||||
|
||||
GUI dependencies are optional. Install them alongside SeedPass with:
|
||||
|
||||
```bash
|
||||
pip install "seedpass[gui]"
|
||||
|
||||
# or when working from a local checkout
|
||||
pip install -e .[gui]
|
||||
```
|
||||
|
||||
After installing the optional GUI extras, add the BeeWare backend for your
|
||||
platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
pip install toga-gtk
|
||||
|
||||
# If installation fails with cairo errors, install libcairo2-dev or the
|
||||
# cairo development package using your distro's package manager.
|
||||
|
||||
# Windows
|
||||
pip install toga-winforms
|
||||
|
||||
# macOS
|
||||
pip install toga-cocoa
|
||||
```
|
||||
|
||||
The GUI shares the same encrypted vault and configuration as the command line tool.
|
||||
|
||||
To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core["seedpass.core"]
|
||||
cli["CLI"]
|
||||
api["FastAPI server"]
|
||||
gui["BeeWare GUI"]
|
||||
ext["Browser Extension"]
|
||||
|
||||
cli --> core
|
||||
gui --> core
|
||||
api --> core
|
||||
ext --> api
|
||||
```
|
||||
|
||||
## VaultService and EntryService
|
||||
|
||||
`VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access.
|
||||
|
||||
```python
|
||||
class VaultService:
|
||||
"""Thread-safe wrapper around vault operations."""
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
```
|
||||
|
||||
```python
|
||||
class EntryService:
|
||||
"""Thread-safe wrapper around entry operations."""
|
||||
def __init__(self, manager: PasswordManager) -> None:
|
||||
self._manager = manager
|
||||
self._lock = Lock()
|
||||
```
|
||||
|
||||
## BeeWare Windows
|
||||
|
||||
The GUI defines two main windows in `src/seedpass_gui/app.py`. `LockScreenWindow` prompts for the master password and then opens `MainWindow` to display the vault entries.
|
||||
|
||||
```python
|
||||
class LockScreenWindow(toga.Window):
|
||||
"""Window prompting for the master password."""
|
||||
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
|
||||
super().__init__("Unlock Vault")
|
||||
self.app = app
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
class MainWindow(toga.Window):
|
||||
"""Main application window showing vault entries."""
|
||||
def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None:
|
||||
super().__init__("SeedPass")
|
||||
self.app = app
|
||||
self.vault = vault
|
||||
self.entries = entries
|
||||
...
|
||||
```
|
||||
|
||||
Each window receives the service instances and calls methods such as `vault.unlock()` or `entries.add_entry()` when buttons are pressed. This keeps the UI thin while reusing the core logic.
|
||||
|
||||
## Asynchronous Synchronization
|
||||
|
||||
`PasswordManager` performs network synchronization with Nostr using `asyncio`. Methods like `start_background_vault_sync()` create a coroutine that calls `sync_vault_async()` in a background thread or task without blocking the UI.
|
||||
|
||||
```python
|
||||
async def sync_vault_async(self, alt_summary: str | None = None) -> dict[str, list[str] | str] | None:
|
||||
"""Publish the current vault contents to Nostr and return event IDs."""
|
||||
...
|
||||
```
|
||||
|
||||
```python
|
||||
def start_background_vault_sync(self, alt_summary: str | None = None) -> None:
|
||||
if getattr(self, "offline_mode", False):
|
||||
return
|
||||
def _worker() -> None:
|
||||
asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
else:
|
||||
asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary))
|
||||
```
|
||||
|
||||
This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop.
|
||||
|
||||
## Relay Manager and Status Bar
|
||||
|
||||
The *Relays* button opens a dialog for adding or removing Nostr relay URLs. The
|
||||
status bar at the bottom of the main window shows when the last synchronization
|
||||
completed. It updates automatically when `sync_started` and `sync_finished`
|
||||
events are published on the internal pubsub bus.
|
||||
|
||||
When a ``vault_locked`` event is emitted, the GUI automatically returns to the
|
||||
lock screen so the session can be reopened with the master password.
|
||||
|
||||
|
||||
## Event Handling
|
||||
|
||||
The GUI subscribes to a few core events so the interface reacts automatically when the vault changes state. When `MainWindow` is created it registers callbacks for `sync_started`, `sync_finished` and `vault_locked` on the global pubsub `bus`:
|
||||
|
||||
```python
|
||||
bus.subscribe("sync_started", self.sync_started)
|
||||
bus.subscribe("sync_finished", self.sync_finished)
|
||||
bus.subscribe("vault_locked", self.vault_locked)
|
||||
```
|
||||
|
||||
Each handler updates the status bar or returns to the lock screen. The `cleanup` method removes these hooks when the window closes:
|
||||
|
||||
```python
|
||||
def cleanup(self, *args: object, **kwargs: object) -> None:
|
||||
bus.unsubscribe("sync_started", self.sync_started)
|
||||
bus.unsubscribe("sync_finished", self.sync_finished)
|
||||
bus.unsubscribe("vault_locked", self.vault_locked)
|
||||
```
|
||||
|
||||
The [TOTP window](../../02-api_reference.md#totp) demonstrates how such events keep the UI fresh: it shows live two-factor codes that reflect the latest vault data after synchronization.
|
610
docs/docs/content/index.md
Normal file
610
docs/docs/content/index.md
Normal file
@@ -0,0 +1,610 @@
|
||||
# SeedPass
|
||||
|
||||
**SeedPass** is a secure password generator and manager built on **Bitcoin's BIP-85 standard**. It uses deterministic key derivation to generate **passwords that are never stored**, but can be easily regenerated when needed. By integrating with the **Nostr network**, SeedPass compresses your encrypted vault and splits it into 50 KB chunks. Each chunk is published as a parameterised replaceable event (`kind 30071`), with a manifest (`kind 30070`) describing the snapshot and deltas (`kind 30072`) capturing changes between snapshots. This allows secure password recovery across devices without exposing your data.
|
||||
|
||||
[Tip Jar](https://nostrtipjar.netlify.app/?n=npub16y70nhp56rwzljmr8jhrrzalsx5x495l4whlf8n8zsxww204k8eqrvamnp)
|
||||
|
||||
---
|
||||
|
||||
**⚠️ Disclaimer**
|
||||
|
||||
This software was not developed by an experienced security expert and should be used with caution. There may be bugs and missing features. Each vault chunk is limited to 50 KB and SeedPass periodically publishes a new snapshot to keep accumulated deltas small. The security of the program's memory management and logs has not been evaluated and may leak sensitive information. Loss or exposure of the parent seed places all derived passwords, accounts, and other artifacts at risk.
|
||||
|
||||
**🚨 Breaking Change**
|
||||
|
||||
Recent releases derive passwords and other artifacts using a fully deterministic algorithm that behaves consistently across Python versions. This improvement means artifacts generated with earlier versions of SeedPass will not match those produced now. Regenerate any previously derived data or retain the old version if you need to reproduce older passwords or keys.
|
||||
|
||||
---
|
||||
### Supported OS
|
||||
|
||||
✔ Windows 10/11 • macOS 12+ • Any modern Linux
|
||||
SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
core(seedpass.core)
|
||||
cli(CLI/TUI)
|
||||
gui(BeeWare GUI)
|
||||
ext(Browser extension)
|
||||
cli --> core
|
||||
gui --> core
|
||||
ext --> core
|
||||
```
|
||||
|
||||
SeedPass uses a modular design with a single core library that handles all
|
||||
security-critical logic. The current CLI/TUI adapter communicates with
|
||||
`seedpass.core`, and future interfaces like a BeeWare GUI and a browser
|
||||
extension can hook into the same layer. This architecture keeps the codebase
|
||||
maintainable while enabling a consistent experience on multiple platforms.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [1. Clone the Repository](#1-clone-the-repository)
|
||||
- [2. Create a Virtual Environment](#2-create-a-virtual-environment)
|
||||
- [3. Activate the Virtual Environment](#3-activate-the-virtual-environment)
|
||||
- [4. Install Dependencies](#4-install-dependencies)
|
||||
- [Usage](#usage)
|
||||
- [Running the Application](#running-the-application)
|
||||
- [Managing Multiple Seeds](#managing-multiple-seeds)
|
||||
- [Additional Entry Types](#additional-entry-types)
|
||||
- [Recovery](#recovery)
|
||||
- [Security Considerations](#security-considerations)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
- [Contact](#contact)
|
||||
|
||||
## Features
|
||||
|
||||
- **Deterministic Password Generation:** Utilize BIP-85 for generating deterministic and secure passwords.
|
||||
- **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally.
|
||||
- **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network.
|
||||
- **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. The manifest's `delta_since` field stores the UNIX timestamp of the latest delta event.
|
||||
- **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering.
|
||||
- **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly.
|
||||
- **Nested Managed Account Seeds:** SeedPass can derive nested managed account seeds.
|
||||
- **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings.
|
||||
- **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar.
|
||||
- **2FA Secret Issuance & Import:** Derive new TOTP secrets from your seed or import existing `otpauth://` URIs.
|
||||
- **Export 2FA Codes:** Save all stored TOTP entries to an encrypted JSON file for use with other apps.
|
||||
- **Display TOTP Codes:** Show all active 2FA codes with a countdown timer.
|
||||
- **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
|
||||
- **Auto‑Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
|
||||
- **Quick Unlock:** Optionally skip the password prompt after verifying once. Startup delay is unaffected.
|
||||
- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
|
||||
- **Tagging Support:** Organize entries with optional tags and find them quickly via search.
|
||||
- **Typed Search Results:** Searches display each entry's type for easier scanning.
|
||||
- **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
|
||||
- **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
|
||||
- **Manual Vault Locking:** Instantly clear keys from memory when needed.
|
||||
- **Vault Statistics:** View counts for entries and other profile metrics.
|
||||
- **Change Master Password:** Rotate your encryption password at any time.
|
||||
- **Checksum Verification Utilities:** Verify or regenerate the script checksum.
|
||||
- **Relay Management:** List, add, remove or reset configured Nostr relays.
|
||||
- **Offline Mode:** Disable network sync to work entirely locally.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.
|
||||
*Windows only:* Install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and select the **C++ build tools** workload.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
### Quick Installer
|
||||
|
||||
Use the automated installer to download SeedPass and its dependencies in one step.
|
||||
If GTK packages are missing, the installer will try to install them using your
|
||||
system's package manager (`apt`, `yum`, `pacman`, or Homebrew).
|
||||
|
||||
**Linux and macOS:**
|
||||
```bash
|
||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)"
|
||||
```
|
||||
*Install the beta branch:*
|
||||
```bash
|
||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent))
|
||||
```
|
||||
Before running the script, install **Python 3.11** or **3.12** from [python.org](https://www.python.org/downloads/windows/) and tick **"Add Python to PATH"**. You should also install the [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the **C++ build tools** workload so dependencies compile correctly.
|
||||
The Windows installer will attempt to install Git automatically if it is not already available. It also tries to
|
||||
install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python`
|
||||
isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from
|
||||
<https://www.python.org/downloads/windows/>. When Python 3.13 or newer is detected without the Microsoft C++ build tools,
|
||||
the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
|
||||
|
||||
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
|
||||
|
||||
#### Installer Dependency Checks
|
||||
|
||||
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
|
||||
|
||||
### Uninstall
|
||||
|
||||
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:
|
||||
|
||||
**Linux and macOS:**
|
||||
```bash
|
||||
bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.sh)"
|
||||
```
|
||||
If the script warns that it couldn't remove an executable, delete that file manually.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/uninstall.ps1'); & ([scriptblock]::create($scriptContent))
|
||||
```
|
||||
|
||||
*Install the beta branch:*
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -Branch beta
|
||||
```
|
||||
|
||||
### Manual Setup
|
||||
Follow these steps to set up SeedPass on your local machine.
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
First, clone the SeedPass repository from GitHub:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PR0M3TH3AN/SeedPass.git
|
||||
```
|
||||
|
||||
Navigate to the project directory:
|
||||
|
||||
```bash
|
||||
cd SeedPass
|
||||
```
|
||||
|
||||
### 2. Create a Virtual Environment
|
||||
|
||||
It's recommended to use a virtual environment to manage your project's dependencies. Create a virtual environment named `venv`:
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
```
|
||||
|
||||
### 3. Activate the Virtual Environment
|
||||
|
||||
Activate the virtual environment using the appropriate command for your operating system.
|
||||
|
||||
- **On Linux and macOS:**
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
- **On Windows:**
|
||||
|
||||
```bash
|
||||
venv\Scripts\activate
|
||||
```
|
||||
|
||||
Once activated, your terminal prompt should be prefixed with `(venv)` indicating that the virtual environment is active.
|
||||
|
||||
### 4. Install Dependencies
|
||||
|
||||
Install the required Python packages and build dependencies using `pip`.
|
||||
When upgrading pip, use `python -m pip` inside the virtual environment so that pip can update itself cleanly:
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --require-hashes -r requirements.lock
|
||||
python -m pip install -e .
|
||||
```
|
||||
|
||||
#### Linux Clipboard Support
|
||||
|
||||
On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`.
|
||||
SeedPass does not install these tools automatically. To use clipboard features
|
||||
such as secret mode, install **xclip** manually:
|
||||
|
||||
```bash
|
||||
sudo apt install xclip
|
||||
```
|
||||
|
||||
After installing `xclip`, restart SeedPass to enable clipboard support.
|
||||
|
||||
## Quick Start
|
||||
|
||||
After installing dependencies, activate your virtual environment and install
|
||||
the package so the `seedpass` command is available, then launch SeedPass and
|
||||
create a backup:
|
||||
|
||||
```bash
|
||||
# Start the application
|
||||
seedpass
|
||||
|
||||
# Export your index
|
||||
seedpass vault export --file "~/seedpass_backup.json"
|
||||
|
||||
# Later you can restore it
|
||||
seedpass vault import --file "~/seedpass_backup.json"
|
||||
# Import also performs a Nostr sync to pull any changes
|
||||
|
||||
# Quickly find or retrieve entries
|
||||
seedpass search "github"
|
||||
seedpass search --tags "work,personal"
|
||||
seedpass get "github"
|
||||
# Search results show the entry type, e.g. "1: Password - GitHub"
|
||||
# Retrieve a TOTP entry
|
||||
seedpass entry get "email"
|
||||
# The code is printed and copied to your clipboard
|
||||
|
||||
# Sort or filter the list view
|
||||
seedpass list --sort label
|
||||
seedpass list --filter totp
|
||||
|
||||
# Use the **Settings** menu to configure an extra backup directory
|
||||
# on an external drive.
|
||||
```
|
||||
|
||||
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
|
||||
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Vault JSON Layout
|
||||
|
||||
The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 2,
|
||||
"entries": {
|
||||
"0": {
|
||||
"label": "example.com",
|
||||
"length": 8,
|
||||
"type": "password",
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
After successfully installing the dependencies, launch the interactive TUI with:
|
||||
|
||||
```bash
|
||||
seedpass
|
||||
```
|
||||
|
||||
You can also run directly from the repository using:
|
||||
|
||||
```bash
|
||||
python src/main.py
|
||||
```
|
||||
|
||||
You can explore other CLI commands using:
|
||||
```bash
|
||||
seedpass --help
|
||||
```
|
||||
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
|
||||
|
||||
### Running the Application
|
||||
|
||||
1. **Start the Application:**
|
||||
|
||||
```bash
|
||||
seedpass
|
||||
```
|
||||
*(or `python src/main.py` if running directly from the repository)*
|
||||
|
||||
2. **Follow the Prompts:**
|
||||
|
||||
- **Seed Profile Selection:** If you have existing seed profiles, you'll be prompted to select one or add a new one.
|
||||
- **Enter Your Password:** This password is crucial as it is used to encrypt and decrypt your parent seed and seed index data.
|
||||
- **Select an Option:** Navigate through the menu by entering the number corresponding to your desired action.
|
||||
|
||||
Example menu:
|
||||
|
||||
```bash
|
||||
Select an option:
|
||||
1. Add Entry
|
||||
2. Retrieve Entry
|
||||
3. Search Entries
|
||||
4. List Entries
|
||||
5. Modify an Existing Entry
|
||||
6. 2FA Codes
|
||||
7. Settings
|
||||
|
||||
Enter your choice (1-7) or press Enter to exit:
|
||||
```
|
||||
|
||||
When choosing **Add Entry**, you can now select from:
|
||||
|
||||
- **Password**
|
||||
- **2FA (TOTP)**
|
||||
- **SSH Key**
|
||||
- **Seed Phrase**
|
||||
- **Nostr Key Pair**
|
||||
- **PGP Key**
|
||||
- **Key/Value**
|
||||
- **Managed Account**
|
||||
|
||||
### Adding a Password Entry
|
||||
|
||||
After selecting **Password**, SeedPass asks you to choose a mode:
|
||||
|
||||
1. **Quick** – enter only a label, username, URL, desired length, and whether to include special characters. All other fields use defaults.
|
||||
2. **Advanced** – continue through prompts for notes, tags, custom fields, and detailed password policy settings.
|
||||
|
||||
Both modes generate the password, display it (or copy it to the clipboard in Secret Mode), and save the entry to your encrypted vault.
|
||||
|
||||
### Adding a 2FA Entry
|
||||
|
||||
1. From the main menu choose **Add Entry** and select **2FA (TOTP)**.
|
||||
2. Pick **Make 2FA** to derive a new secret from your seed or **Import 2FA** to paste an existing `otpauth://` URI or secret.
|
||||
3. Provide a label for the account (for example, `GitHub`).
|
||||
4. SeedPass automatically chooses the next available derivation index when deriving.
|
||||
5. Optionally specify the TOTP period and digit count.
|
||||
6. SeedPass displays the URI and secret, along with a QR code you can scan to import it into your authenticator app.
|
||||
|
||||
### Modifying a 2FA Entry
|
||||
|
||||
1. From the main menu choose **Modify an Existing Entry** and enter the index of the 2FA code you want to edit.
|
||||
2. SeedPass will show the current label, period, digit count, and archived status.
|
||||
3. Enter new values or press **Enter** to keep the existing settings.
|
||||
4. When retrieving a 2FA entry you can press **E** to edit the label, period or digit count, or **A** to archive/unarchive it.
|
||||
5. The updated entry is saved back to your encrypted vault.
|
||||
6. Archived entries are hidden from lists but can be viewed or restored from the **List Archived** menu.
|
||||
7. When editing an archived entry you'll be prompted to restore it after saving your changes.
|
||||
|
||||
### Using Secret Mode
|
||||
|
||||
When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
|
||||
|
||||
1. From the main menu open **Settings** and select **Toggle Secret Mode**.
|
||||
2. Choose how many seconds to keep passwords on the clipboard.
|
||||
3. Retrieve an entry and SeedPass will confirm the password was copied.
|
||||
|
||||
### Additional Entry Types
|
||||
|
||||
SeedPass supports storing more than just passwords and 2FA secrets. You can also create entries for:
|
||||
- **SSH Key** – deterministically derive an Ed25519 key pair for servers or git hosting platforms.
|
||||
- **Seed Phrase** – store only the BIP-85 index and word count. The mnemonic is regenerated on demand.
|
||||
- **PGP Key** – derive an OpenPGP key pair from your master seed.
|
||||
- **Nostr Key Pair** – store the index used to derive an `npub`/`nsec` pair for Nostr clients.
|
||||
When you retrieve one of these entries, SeedPass can display QR codes for the
|
||||
keys. The `npub` is wrapped in the `nostr:` URI scheme so any client can scan
|
||||
it, while the `nsec` QR is shown only after a security warning.
|
||||
- **Key/Value** – store a simple key and value for miscellaneous secrets or configuration data.
|
||||
- **Managed Account** – derive a child seed under the current profile. Loading a managed account switches to a nested profile and the header shows `<parent_fp> > Managed Account > <child_fp>`. Press Enter on the main menu to return to the parent profile.
|
||||
|
||||
The table below summarizes the extra fields stored for each entry type. Every
|
||||
entry includes a `label`, while only password entries track a `url`.
|
||||
|
||||
| Entry Type | Extra Fields |
|
||||
|---------------|---------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Password | `username`, `url`, `length`, `archived`, optional `notes`, optional `custom_fields` (may include hidden fields), optional `tags` |
|
||||
| 2FA (TOTP) | `index` or `secret`, `period`, `digits`, `archived`, optional `notes`, optional `tags` |
|
||||
| SSH Key | `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Seed Phrase | `index`, `word_count` *(mnemonic regenerated; never stored)*, `archived`, optional `notes`, optional `tags` |
|
||||
| PGP Key | `index`, `key_type`, `archived`, optional `user_id`, optional `notes`, optional `tags` |
|
||||
| Nostr Key Pair| `index`, `archived`, optional `notes`, optional `tags` |
|
||||
| Key/Value | `key`, `value`, `archived`, optional `notes`, optional `custom_fields`, optional `tags` |
|
||||
| Managed Account | `index`, `word_count`, `fingerprint`, `archived`, optional `notes`, optional `tags` |
|
||||
|
||||
|
||||
### Managing Multiple Seeds
|
||||
|
||||
SeedPass allows you to manage multiple seed profiles (previously referred to as "fingerprints"). Each seed profile has its own parent seed and associated data, enabling you to compartmentalize your passwords.
|
||||
|
||||
- **Add a New Seed Profile:**
|
||||
- From the main menu, select **Settings** then **Profiles** and choose "Add a New Seed Profile".
|
||||
- Choose to paste in a full seed, enter one word at a time, or generate a new seed.
|
||||
- When entering a seed word by word, each word is hidden with `*` and the screen refreshes after every entry for clarity. You'll review the completed phrase after the last word and can correct mistakes before it is saved.
|
||||
- If generating a new seed, you'll be provided with a 12-word BIP-85 seed phrase. **Ensure you write this down and store it securely.**
|
||||
|
||||
- **Switch Between Seed Profiles:**
|
||||
- From the **Profiles** menu, select "Switch Seed Profile".
|
||||
- You'll see a list of available seed profiles.
|
||||
- Enter the number corresponding to the seed profile you wish to switch to.
|
||||
- Enter the master password associated with that seed profile.
|
||||
|
||||
- **List All Seed Profiles:**
|
||||
- In the **Profiles** menu, choose "List All Seed Profiles" to view all existing profiles.
|
||||
- **Set Seed Profile Name:**
|
||||
- In the **Profiles** menu, choose "Set Seed Profile Name" to assign a label to the current profile. The name is stored locally and shown next to the fingerprint.
|
||||
|
||||
**Note:** The term "seed profile" is used to represent different sets of seeds you can manage within SeedPass. This provides an intuitive way to handle multiple identities or sets of passwords.
|
||||
|
||||
|
||||
### Recovery
|
||||
|
||||
If you previously backed up your vault to Nostr you can restore it during the
|
||||
initial setup. You must provide both your 12 -word master seed and the master
|
||||
password that encrypted the vault; without the correct password the retrieved
|
||||
data cannot be decrypted.
|
||||
|
||||
1. Start SeedPass and choose option **4** when prompted to set up a seed.
|
||||
2. Paste your BIP‑85 seed phrase when asked.
|
||||
3. Enter the master password associated with that seed.
|
||||
4. SeedPass initializes the profile and attempts to download the encrypted
|
||||
vault from the configured relays.
|
||||
5. A success message confirms the vault was restored. If no data is found a
|
||||
failure message is shown and a new empty vault is created.
|
||||
|
||||
### Configuration File and Settings
|
||||
|
||||
SeedPass keeps per-profile settings in an encrypted file named `seedpass_config.json.enc` inside each profile directory under `~/.seedpass/`. This file stores your chosen Nostr relays and the optional settings PIN. New profiles start with the following default relays:
|
||||
|
||||
```
|
||||
wss://relay.snort.social
|
||||
wss://nostr.oxtr.dev
|
||||
wss://relay.primal.net
|
||||
```
|
||||
|
||||
You can manage your relays and sync with Nostr from the **Settings** menu:
|
||||
|
||||
1. From the main menu choose `6` (**Settings**).
|
||||
2. Select `2` (**Nostr**) to open the Nostr submenu.
|
||||
3. Choose `1` to back up your encrypted index to Nostr.
|
||||
4. Select `2` to restore the index from Nostr.
|
||||
5. Choose `3` to view your current relays.
|
||||
6. Select `4` to add a new relay URL.
|
||||
7. Choose `5` to remove a relay by number.
|
||||
8. Select `6` to reset to the default relay list.
|
||||
9. Choose `7` to display your Nostr public key.
|
||||
10. Select `8` to return to the Settings menu.
|
||||
|
||||
Back in the Settings menu you can:
|
||||
|
||||
* Select `3` to change your master password.
|
||||
* Choose `4` to verify the script checksum.
|
||||
* Select `5` to generate a new script checksum.
|
||||
* Choose `6` to back up the parent seed.
|
||||
* Select `7` to export the database to an encrypted file.
|
||||
* Choose `8` to import a database from a backup file. This also performs a Nostr sync automatically.
|
||||
* Select `9` to export all 2FA codes.
|
||||
* Choose `10` to set an additional backup location. A backup is created
|
||||
immediately after the directory is configured.
|
||||
* Select `11` to change the inactivity timeout.
|
||||
* Choose `12` to lock the vault and require re-entry of your password.
|
||||
* Select `13` to view seed profile stats. The summary lists counts for
|
||||
passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. It also shows
|
||||
whether both the encrypted database and the script itself pass checksum
|
||||
validation.
|
||||
* Choose `14` to toggle Secret Mode and set the clipboard clear delay.
|
||||
* Select `15` to toggle Offline Mode and work locally without contacting Nostr.
|
||||
* Choose `16` to toggle Quick Unlock so subsequent actions skip the password prompt. Startup delay is unchanged.
|
||||
* Select `17` to return to the main menu.
|
||||
|
||||
## Running Tests
|
||||
|
||||
SeedPass includes a small suite of unit tests located under `src/tests`. **Before running `pytest`, be sure to install the test requirements.** Activate your virtual environment and run `pip install --require-hashes -r requirements.lock` to ensure all testing dependencies are available. Then run the tests with **pytest**. Use `-vv` to see INFO-level log messages from each passing test:
|
||||
|
||||
|
||||
```bash
|
||||
pip install --require-hashes -r requirements.lock
|
||||
pytest -vv
|
||||
```
|
||||
|
||||
`test_fuzz_key_derivation.py` uses Hypothesis to generate random passwords,
|
||||
seeds and configuration data. It performs round-trip encryption tests with the
|
||||
`EncryptionManager` to catch edge cases automatically. These fuzz tests run in
|
||||
CI alongside the rest of the suite.
|
||||
|
||||
### Exploring Nostr Index Size Limits
|
||||
|
||||
`test_nostr_index_size.py` demonstrates how SeedPass rotates snapshots after too many delta events.
|
||||
Each chunk is limited to 50 KB, so the test gradually grows the vault to observe
|
||||
when a new snapshot is triggered. Use the `NOSTR_TEST_DELAY` environment
|
||||
variable to control the delay between publishes when experimenting with large vaults.
|
||||
|
||||
```bash
|
||||
pytest -vv -s -n 0 src/tests/test_nostr_index_size.py --desktop --max-entries=1000
|
||||
```
|
||||
|
||||
### Generating a Test Profile
|
||||
|
||||
Use the helper script below to populate a profile with sample entries for testing:
|
||||
|
||||
```bash
|
||||
python scripts/generate_test_profile.py --profile demo_profile --count 100
|
||||
```
|
||||
|
||||
The script determines the fingerprint from the generated seed and stores the
|
||||
vault under `~/.seedpass/tests/<fingerprint>`. SeedPass only discovers profiles
|
||||
inside `~/.seedpass/`, so copy the fingerprint directory out of the `tests`
|
||||
subfolder (or adjust `APP_DIR` in `constants.py`) if you want to use the
|
||||
generated seed with the main application. The fingerprint is printed after
|
||||
creation and the encrypted index is published to Nostr. Use that same seed
|
||||
phrase to load SeedPass. The app checks Nostr on startup and pulls any newer
|
||||
snapshot so your vault stays in sync across machines. Synchronization also runs
|
||||
in the background after unlocking or when switching profiles.
|
||||
|
||||
### Automatically Updating the Script Checksum
|
||||
|
||||
SeedPass stores a SHA-256 checksum for the main program in `~/.seedpass/seedpass_script_checksum.txt`.
|
||||
To keep this value in sync with the source code, install the pre‑push git hook:
|
||||
|
||||
```bash
|
||||
pre-commit install -t pre-push
|
||||
```
|
||||
|
||||
After running this command, every `git push` will execute `scripts/update_checksum.py`,
|
||||
updating the checksum file automatically.
|
||||
|
||||
If the checksum file is missing, generate it manually:
|
||||
|
||||
```bash
|
||||
python scripts/update_checksum.py
|
||||
```
|
||||
|
||||
If SeedPass reports a "script checksum mismatch" warning on startup,
|
||||
regenerate the checksum with `seedpass util update-checksum` or select
|
||||
"Generate Script Checksum" from the Settings menu.
|
||||
|
||||
To run mutation tests locally, generate coverage data first and then execute `mutmut`:
|
||||
|
||||
```bash
|
||||
pytest --cov=src src/tests
|
||||
python -m mutmut run --paths-to-mutate src --tests-dir src/tests --runner "python -m pytest -q" --use-coverage --no-progress
|
||||
python -m mutmut results
|
||||
```
|
||||
|
||||
Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password.
|
||||
|
||||
- **Backup Your Data:** Regularly back up your encrypted data and checksum files to prevent data loss.
|
||||
- **Backup the Settings PIN:** Your settings PIN is stored in the encrypted configuration file. Keep a copy of this file or remember the PIN, as losing it will require deleting the file and reconfiguring your relays.
|
||||
- **Protect Your Passwords:** Do not share your master password or seed phrases with anyone and ensure they are strong and unique.
|
||||
- **Backing Up the Parent Seed:** Use the CLI `vault reveal-parent-seed` command or the `/api/v1/vault/backup-parent-seed` endpoint with explicit confirmation to create an encrypted backup. The API does not return the seed directly.
|
||||
- **No PBKDF2 Salt Needed:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
|
||||
- **Checksum Verification:** Always verify the script's checksum to ensure its integrity and protect against unauthorized modifications.
|
||||
- **Potential Bugs and Limitations:** Be aware that the software may contain bugs and lacks certain features. Snapshot chunks are capped at 50 KB and the client rotates snapshots after enough delta events accumulate. The security of memory management and logs has not been thoroughly evaluated and may pose risks of leaking sensitive information.
|
||||
- **Multiple Seeds Management:** While managing multiple seeds adds flexibility, it also increases the responsibility to secure each seed and its associated password.
|
||||
- **No PBKDF2 Salt Required:** SeedPass deliberately omits an explicit PBKDF2 salt. Every password is derived from a unique 512-bit BIP-85 child seed, which already provides stronger per-password uniqueness than a conventional 128-bit salt.
|
||||
- **Default KDF Iterations:** New profiles start with 50,000 PBKDF2 iterations. Use `seedpass config set kdf_iterations` to change this.
|
||||
- **Offline Mode:** Disable Nostr sync to keep all operations local until you re-enable networking.
|
||||
- **Quick Unlock:** Store a hashed copy of your password so future actions skip the prompt. Startup delay no longer changes. Use with caution on shared systems.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:
|
||||
|
||||
1. **Fork the Repository:** Click the "Fork" button on the top right of the repository page.
|
||||
|
||||
2. **Create a Branch:** Create a new branch for your feature or bugfix.
|
||||
|
||||
```bash
|
||||
git checkout -b feature/YourFeatureName
|
||||
```
|
||||
|
||||
3. **Commit Your Changes:** Make your changes and commit them with clear messages.
|
||||
|
||||
```bash
|
||||
git commit -m "Add feature X"
|
||||
```
|
||||
|
||||
4. **Push to GitHub:** Push your changes to your forked repository.
|
||||
|
||||
```bash
|
||||
git push origin feature/YourFeatureName
|
||||
```
|
||||
|
||||
5. **Create a Pull Request:** Navigate to the original repository and create a pull request describing your changes.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE). See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Contact
|
||||
|
||||
For any questions, suggestions, or support, please open an issue on the [GitHub repository](https://github.com/PR0M3TH3AN/SeedPass/issues) or contact the maintainer directly on [Nostr](https://primal.net/p/npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx).
|
||||
|
||||
---
|
||||
|
||||
*Stay secure and keep your passwords safe with SeedPass!*
|
||||
|
||||
---
|
11
docs/docs/package.json
Normal file
11
docs/docs/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "eleventy --serve",
|
||||
"build": "node node_modules/archivox/src/generator/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archivox": "^1.0.0"
|
||||
}
|
||||
}
|
@@ -1,25 +0,0 @@
|
||||
# Index Migrations
|
||||
|
||||
SeedPass stores its password index in an encrypted JSON file. Each index contains
|
||||
a `schema_version` field so the application knows how to upgrade older files.
|
||||
|
||||
## How migrations work
|
||||
|
||||
When the vault loads the index, `Vault.load_index()` checks the version and
|
||||
applies migrations defined in `password_manager/migrations.py`. The
|
||||
`apply_migrations()` function iterates through registered migrations until the
|
||||
file reaches `LATEST_VERSION`.
|
||||
|
||||
If an old file lacks `schema_version`, it is treated as version 0 and upgraded
|
||||
to the latest format. Attempting to load an index from a future version will
|
||||
raise an error.
|
||||
|
||||
## Upgrading an index
|
||||
|
||||
1. The JSON is decrypted and parsed.
|
||||
2. `apply_migrations()` applies any necessary steps, such as injecting the
|
||||
`schema_version` field on first upgrade.
|
||||
3. After migration, the updated index is saved back to disk.
|
||||
|
||||
This process happens automatically; users only need to open their vault to
|
||||
upgrade older indices.
|
3
docs/netlify.toml
Normal file
3
docs/netlify.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build]
|
||||
command = "node build-docs.js"
|
||||
publish = "_site"
|
33
docs/nostr_setup.md
Normal file
33
docs/nostr_setup.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Nostr Setup
|
||||
|
||||
This guide explains how SeedPass uses the Nostr protocol for encrypted vault backups and how to configure relays.
|
||||
|
||||
## Relay Configuration
|
||||
|
||||
SeedPass communicates with the Nostr network through a list of relays. You can manage these relays from the CLI:
|
||||
|
||||
```bash
|
||||
seedpass nostr list-relays # show configured relays
|
||||
seedpass nostr add-relay <url> # add a relay URL
|
||||
seedpass nostr remove-relay <n> # remove relay by index
|
||||
```
|
||||
|
||||
At least one relay is required for publishing and retrieving backups. Choose relays you trust to remain online and avoid those that charge high fees or aggressively rate‑limit connections.
|
||||
|
||||
## Manifest and Delta Events
|
||||
|
||||
Backups are published as parameterised replaceable events:
|
||||
|
||||
- **Kind 30070 – Manifest:** describes the snapshot and lists chunk IDs. The optional `delta_since` field stores the UNIX timestamp of the latest delta event.
|
||||
- **Kind 30071 – Snapshot Chunk:** each 50 KB fragment of the compressed, encrypted vault.
|
||||
- **Kind 30072 – Delta:** captures changes since the last snapshot.
|
||||
|
||||
When restoring, SeedPass downloads the most recent manifest and applies any newer delta events.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No events found:** ensure the relays are reachable and that the correct fingerprint is selected.
|
||||
- **Connection failures:** some relays only support WebSocket over TLS; verify you are using `wss://` URLs where required.
|
||||
- **Stale data:** if deltas accumulate without a fresh snapshot, run `seedpass nostr sync` to publish an updated snapshot.
|
||||
|
||||
Increasing log verbosity with `--verbose` can also help diagnose relay or network issues.
|
6357
docs/package-lock.json
generated
Normal file
6357
docs/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
docs/package.json
Normal file
25
docs/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "archivox",
|
||||
"version": "1.0.0",
|
||||
"description": "Archivox static site generator",
|
||||
"scripts": {
|
||||
"dev": "eleventy --serve",
|
||||
"build": "node src/generator/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@11ty/eleventy": "^2.0.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^11.1.1",
|
||||
"lunr": "^2.3.9",
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.6.1",
|
||||
"puppeteer": "^24.12.1"
|
||||
},
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"create-archivox": "./bin/create-archivox.js"
|
||||
}
|
||||
}
|
38
docs/packaging.md
Normal file
38
docs/packaging.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Packaging SeedPass
|
||||
|
||||
This guide describes how to build platform-native packages for SeedPass using [BeeWare Briefcase](https://briefcase.readthedocs.io/).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Python 3.12 with development headers (`python3-dev` on Debian/Ubuntu).
|
||||
* Briefcase installed in your virtual environment:
|
||||
|
||||
```bash
|
||||
pip install briefcase
|
||||
```
|
||||
|
||||
## Linux
|
||||
|
||||
The helper script in `packaging/build-linux.sh` performs `briefcase create`, `build`, and `package` for the current project.
|
||||
|
||||
```bash
|
||||
./packaging/build-linux.sh
|
||||
```
|
||||
|
||||
Briefcase outputs its build artifacts in `build/seedpass-gui/ubuntu/noble/`. These files can be bundled in container formats such as Flatpak or Snap. Example manifests are included:
|
||||
|
||||
* `packaging/flatpak/seedpass.yml` targets the `org.gnome.Platform` runtime and copies the Briefcase build into the Flatpak bundle.
|
||||
* `packaging/snapcraft.yaml` stages the Briefcase build and lists GTK libraries in `stage-packages` so the Snap includes its GUI dependencies.
|
||||
|
||||
## macOS and Windows
|
||||
|
||||
Scripts are provided to document the commands expected on each platform. They must be run on their respective operating systems:
|
||||
|
||||
* `packaging/build-macos.sh`
|
||||
* `packaging/build-windows.ps1`
|
||||
|
||||
Each script runs Briefcase's `create`, `build`, and `package` steps with `--no-input`.
|
||||
|
||||
## Reproducible Releases
|
||||
|
||||
The `packaging/` directory contains the scripts and manifests needed to regenerate desktop packages. Invoke the appropriate script on the target OS, then use the supplied Flatpak or Snap manifest to bundle additional dependencies for Linux.
|
7
docs/plugins/analytics.js
Normal file
7
docs/plugins/analytics.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
onPageRendered: async ({ html, file }) => {
|
||||
// Example: inject analytics script into each page
|
||||
const snippet = '\n<script>console.log("Page viewed: ' + file + '")</script>';
|
||||
return { html: html.replace('</body>', `${snippet}</body>`) };
|
||||
}
|
||||
};
|
17
docs/secret-scanning.md
Normal file
17
docs/secret-scanning.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Secret Scanning
|
||||
|
||||
SeedPass uses [Gitleaks](https://github.com/gitleaks/gitleaks) to scan the repository for accidentally committed secrets. The scan runs automatically for pull requests and on a nightly schedule. Any findings will cause the build to fail.
|
||||
|
||||
## Suppressing False Positives
|
||||
|
||||
If a file or string triggers the scanner but does not contain a real secret, add it to the allowlist in `.gitleaks.toml`.
|
||||
|
||||
```toml
|
||||
[allowlist]
|
||||
# Ignore specific files
|
||||
paths = ["path/to/file.txt"]
|
||||
# Ignore strings that match a regular expression
|
||||
regexes = ["""dummy_api_key"""]
|
||||
```
|
||||
|
||||
Commit the updated `.gitleaks.toml` to stop future alerts for the allowed items.
|
30
docs/security.md
Normal file
30
docs/security.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Security Testing and Calibration
|
||||
|
||||
This project includes fuzz tests and a calibration routine to tune Argon2 parameters for your hardware.
|
||||
|
||||
## Running Fuzz Tests
|
||||
|
||||
The fuzz tests exercise encryption and decryption with random data using [Hypothesis](https://hypothesis.readthedocs.io/).
|
||||
Activate the project's virtual environment and run:
|
||||
|
||||
```bash
|
||||
pytest src/tests/test_encryption_fuzz.py
|
||||
```
|
||||
|
||||
Running the entire test suite will also execute these fuzz tests.
|
||||
|
||||
## Calibrating Argon2 Time Cost
|
||||
|
||||
Argon2 performance varies by device. To calibrate the `time_cost` parameter, run the helper function:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from utils.key_derivation import calibrate_argon2_time_cost
|
||||
|
||||
# assuming ``cfg`` is a ConfigManager for your profile
|
||||
calibrate_argon2_time_cost(cfg)
|
||||
PY
|
||||
```
|
||||
|
||||
The selected `time_cost` is stored in the profile's configuration and used for subsequent key derivations.
|
70
docs/src/config/loadConfig.js
Normal file
70
docs/src/config/loadConfig.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
function deepMerge(target, source) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (
|
||||
source[key] &&
|
||||
typeof source[key] === 'object' &&
|
||||
!Array.isArray(source[key])
|
||||
) {
|
||||
target[key] = deepMerge(target[key] || {}, source[key]);
|
||||
} else if (source[key] !== undefined) {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function loadConfig(configPath = path.join(process.cwd(), 'config.yaml')) {
|
||||
let raw = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
raw = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse ${configPath}: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
site: {
|
||||
title: 'Archivox',
|
||||
description: '',
|
||||
logo: '',
|
||||
favicon: ''
|
||||
},
|
||||
navigation: {
|
||||
search: true
|
||||
},
|
||||
footer: {},
|
||||
theme: {
|
||||
name: 'minimal',
|
||||
darkMode: false
|
||||
},
|
||||
features: {},
|
||||
pluginsDir: 'plugins',
|
||||
plugins: []
|
||||
};
|
||||
|
||||
const config = deepMerge(defaults, raw);
|
||||
|
||||
const errors = [];
|
||||
if (
|
||||
!config.site ||
|
||||
typeof config.site.title !== 'string' ||
|
||||
!config.site.title.trim()
|
||||
) {
|
||||
errors.push('site.title is required in config.yaml');
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(err => console.error(`Config error: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = loadConfig;
|
24
docs/src/config/loadPlugins.js
Normal file
24
docs/src/config/loadPlugins.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function loadPlugins(config) {
|
||||
const dir = path.resolve(process.cwd(), config.pluginsDir || 'plugins');
|
||||
const names = Array.isArray(config.plugins) ? config.plugins : [];
|
||||
const plugins = [];
|
||||
for (const name of names) {
|
||||
const file = path.join(dir, name.endsWith('.js') ? name : `${name}.js`);
|
||||
if (fs.existsSync(file)) {
|
||||
try {
|
||||
const mod = require(file);
|
||||
plugins.push(mod);
|
||||
} catch (e) {
|
||||
console.error(`Failed to load plugin ${name}:`, e);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Plugin not found: ${file}`);
|
||||
}
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
module.exports = loadPlugins;
|
235
docs/src/generator/index.js
Normal file
235
docs/src/generator/index.js
Normal file
@@ -0,0 +1,235 @@
|
||||
// Generator entry point for Archivox
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const matter = require('gray-matter');
|
||||
const lunr = require('lunr');
|
||||
const marked = require('marked');
|
||||
const { lexer } = marked;
|
||||
const loadConfig = require('../config/loadConfig');
|
||||
const loadPlugins = require('../config/loadPlugins');
|
||||
|
||||
function formatName(name) {
|
||||
return name
|
||||
.replace(/^\d+[-_]?/, '')
|
||||
.replace(/\.md$/, '');
|
||||
}
|
||||
|
||||
async function readDirRecursive(dir) {
|
||||
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const res = path.resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await readDirRecursive(res));
|
||||
} else {
|
||||
files.push(res);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildNav(pages) {
|
||||
const tree = {};
|
||||
for (const page of pages) {
|
||||
const rel = page.file.replace(/\\/g, '/');
|
||||
if (rel === 'index.md') {
|
||||
if (!tree.children) tree.children = [];
|
||||
tree.children.push({
|
||||
name: 'index.md',
|
||||
children: [],
|
||||
page: page.data,
|
||||
path: `/${rel.replace(/\.md$/, '.html')}`,
|
||||
order: page.data.order || 0
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parts = rel.split('/');
|
||||
let node = tree;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
const isIndex = isLast && part === 'index.md';
|
||||
if (isIndex) {
|
||||
node.page = page.data;
|
||||
node.path = `/${rel.replace(/\.md$/, '.html')}`;
|
||||
node.order = page.data.order || 0;
|
||||
break;
|
||||
}
|
||||
if (!node.children) node.children = [];
|
||||
let child = node.children.find(c => c.name === part);
|
||||
if (!child) {
|
||||
child = { name: part, children: [] };
|
||||
node.children.push(child);
|
||||
}
|
||||
node = child;
|
||||
if (isLast) {
|
||||
node.page = page.data;
|
||||
node.path = `/${rel.replace(/\.md$/, '.html')}`;
|
||||
node.order = page.data.order || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finalize(node, isRoot = false) {
|
||||
if (node.page && node.page.title) {
|
||||
node.displayName = node.page.title;
|
||||
} else if (node.name) {
|
||||
node.displayName = formatName(node.name);
|
||||
}
|
||||
if (node.children) {
|
||||
node.children.forEach(c => finalize(c));
|
||||
node.children.sort((a, b) => {
|
||||
const orderDiff = (a.order || 0) - (b.order || 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return (a.displayName || '').localeCompare(b.displayName || '');
|
||||
});
|
||||
node.isSection = node.children.length > 0;
|
||||
} else {
|
||||
node.isSection = false;
|
||||
}
|
||||
if (isRoot && node.children) {
|
||||
const idx = node.children.findIndex(c => c.name === 'index.md');
|
||||
if (idx > 0) {
|
||||
const [first] = node.children.splice(idx, 1);
|
||||
node.children.unshift(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalize(tree, true);
|
||||
return tree.children || [];
|
||||
}
|
||||
|
||||
async function generate({ contentDir = 'content', outputDir = '_site', configPath } = {}) {
|
||||
const config = loadConfig(configPath);
|
||||
const plugins = loadPlugins(config);
|
||||
|
||||
async function runHook(name, data) {
|
||||
for (const plugin of plugins) {
|
||||
if (typeof plugin[name] === 'function') {
|
||||
const res = await plugin[name](data);
|
||||
if (res !== undefined) data = res;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (!fs.existsSync(contentDir)) {
|
||||
console.error(`Content directory not found: ${contentDir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await readDirRecursive(contentDir);
|
||||
const pages = [];
|
||||
const assets = [];
|
||||
const searchDocs = [];
|
||||
|
||||
for (const file of files) {
|
||||
const rel = path.relative(contentDir, file);
|
||||
if (file.endsWith('.md')) {
|
||||
const srcStat = await fs.promises.stat(file);
|
||||
const outPath = path.join(outputDir, rel.replace(/\.md$/, '.html'));
|
||||
if (fs.existsSync(outPath)) {
|
||||
const outStat = await fs.promises.stat(outPath);
|
||||
if (srcStat.mtimeMs <= outStat.mtimeMs) {
|
||||
continue; // skip unchanged
|
||||
}
|
||||
}
|
||||
let raw = await fs.promises.readFile(file, 'utf8');
|
||||
const mdObj = await runHook('onParseMarkdown', { file: rel, content: raw });
|
||||
if (mdObj && mdObj.content) raw = mdObj.content;
|
||||
const parsed = matter(raw);
|
||||
const tokens = lexer(parsed.content || '');
|
||||
const firstHeading = tokens.find(t => t.type === 'heading');
|
||||
const title = parsed.data.title || (firstHeading ? firstHeading.text : path.basename(rel, '.md'));
|
||||
const headings = tokens.filter(t => t.type === 'heading').map(t => t.text).join(' ');
|
||||
const htmlBody = require('marked').parse(parsed.content || '');
|
||||
const bodyText = htmlBody.replace(/<[^>]+>/g, ' ');
|
||||
pages.push({ file: rel, data: { ...parsed.data, title } });
|
||||
searchDocs.push({ id: rel.replace(/\.md$/, '.html'), url: '/' + rel.replace(/\.md$/, '.html'), title, headings, body: bodyText });
|
||||
} else {
|
||||
assets.push(rel);
|
||||
}
|
||||
}
|
||||
|
||||
const nav = buildNav(pages);
|
||||
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(outputDir, 'navigation.json'), JSON.stringify(nav, null, 2));
|
||||
await fs.promises.writeFile(path.join(outputDir, 'config.json'), JSON.stringify(config, null, 2));
|
||||
|
||||
const searchIndex = lunr(function() {
|
||||
this.ref('id');
|
||||
this.field('title');
|
||||
this.field('headings');
|
||||
this.field('body');
|
||||
searchDocs.forEach(d => this.add(d));
|
||||
});
|
||||
await fs.promises.writeFile(
|
||||
path.join(outputDir, 'search-index.json'),
|
||||
JSON.stringify({ index: searchIndex.toJSON(), docs: searchDocs }, null, 2)
|
||||
);
|
||||
|
||||
const nunjucks = require('nunjucks');
|
||||
const env = new nunjucks.Environment(
|
||||
new nunjucks.FileSystemLoader('templates')
|
||||
);
|
||||
env.addGlobal('navigation', nav);
|
||||
env.addGlobal('config', config);
|
||||
|
||||
for (const page of pages) {
|
||||
const outPath = path.join(outputDir, page.file.replace(/\.md$/, '.html'));
|
||||
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
|
||||
const srcPath = path.join(contentDir, page.file);
|
||||
const raw = await fs.promises.readFile(srcPath, 'utf8');
|
||||
const { content, data } = matter(raw);
|
||||
const body = require('marked').parse(content);
|
||||
|
||||
const pageContext = {
|
||||
title: data.title || page.data.title,
|
||||
content: body,
|
||||
page: { url: '/' + page.file.replace(/\.md$/, '.html') }
|
||||
};
|
||||
|
||||
let html = env.render('layout.njk', pageContext);
|
||||
const result = await runHook('onPageRendered', { file: page.file, html });
|
||||
if (result && result.html) html = result.html;
|
||||
await fs.promises.writeFile(outPath, html);
|
||||
}
|
||||
|
||||
|
||||
for (const asset of assets) {
|
||||
const srcPath = path.join(contentDir, asset);
|
||||
const destPath = path.join(outputDir, asset);
|
||||
await fs.promises.mkdir(path.dirname(destPath), { recursive: true });
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
if (/(png|jpg|jpeg)/i.test(path.extname(asset))) {
|
||||
await sharp(srcPath).toFile(destPath);
|
||||
continue;
|
||||
}
|
||||
} catch (e) {
|
||||
// sharp not installed, fallback
|
||||
}
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
|
||||
// Copy the main assets directory (theme, js, etc.)
|
||||
// Always resolve assets relative to the Archivox package so it works
|
||||
// regardless of the current working directory or config location.
|
||||
const mainAssetsSrc = path.resolve(__dirname, '../../assets');
|
||||
const mainAssetsDest = path.join(outputDir, 'assets');
|
||||
|
||||
if (fs.existsSync(mainAssetsSrc)) {
|
||||
console.log(`Copying main assets from ${mainAssetsSrc} to ${mainAssetsDest}`);
|
||||
// Use fs.promises.cp for modern Node.js, it's like `cp -R`
|
||||
await fs.promises.cp(mainAssetsSrc, mainAssetsDest, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generate, buildNav };
|
||||
|
||||
if (require.main === module) {
|
||||
generate().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
6
docs/starter/config.yaml
Normal file
6
docs/starter/config.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
site:
|
||||
title: "Archivox Docs"
|
||||
description: "Simple static docs."
|
||||
|
||||
navigation:
|
||||
search: true
|
3
docs/starter/content/01-getting-started/01-install.md
Normal file
3
docs/starter/content/01-getting-started/01-install.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Install
|
||||
|
||||
Run `npm install` then `npm run build` to generate your site.
|
3
docs/starter/content/01-getting-started/index.md
Normal file
3
docs/starter/content/01-getting-started/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Getting Started
|
||||
|
||||
This section helps you begin with Archivox.
|
3
docs/starter/content/index.md
Normal file
3
docs/starter/content/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Welcome to Archivox
|
||||
|
||||
This is your new documentation site. Start editing files in the `content/` folder.
|
11
docs/starter/package.json
Normal file
11
docs/starter/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "my-archivox-site",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "eleventy --serve",
|
||||
"build": "node node_modules/archivox/src/generator/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archivox": "*"
|
||||
}
|
||||
}
|
23
docs/templates/layout.njk
vendored
Normal file
23
docs/templates/layout.njk
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="{% if config.theme.darkMode %}dark{% else %}light{% endif %}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ title | default(config.site.title) }}</title>
|
||||
<link rel="stylesheet" href="/assets/theme.css" />
|
||||
</head>
|
||||
<body>
|
||||
{% include "partials/header.njk" %}
|
||||
<div id="sidebar-overlay" class="sidebar-overlay"></div>
|
||||
<div class="container">
|
||||
{% include "partials/sidebar.njk" %}
|
||||
<main id="content">
|
||||
<nav id="breadcrumbs" class="breadcrumbs"></nav>
|
||||
{{ content | safe }}
|
||||
</main>
|
||||
</div>
|
||||
{% include "partials/footer.njk" %}
|
||||
<script src="/assets/lunr.js"></script>
|
||||
<script src="/assets/theme.js"></script>
|
||||
</body>
|
||||
</html>
|
14
docs/templates/partials/footer.njk
vendored
Normal file
14
docs/templates/partials/footer.njk
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<footer class="footer">
|
||||
{% if config.footer.links %}
|
||||
<nav class="footer-links">
|
||||
{% for link in config.footer.links %}
|
||||
<a href="{{ link.url }}">{{ link.text }}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
<p>© {{ config.site.title }}</p>
|
||||
<div class="footer-permanent-links">
|
||||
<a href="https://github.com/PR0M3TH3AN/Archivox">GitHub</a>
|
||||
<a href="https://nostrtipjar.netlify.app/?n=npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx">Tip Jar</a>
|
||||
</div>
|
||||
</footer>
|
7
docs/templates/partials/header.njk
vendored
Normal file
7
docs/templates/partials/header.njk
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
<header class="header">
|
||||
<button id="sidebar-toggle" class="sidebar-toggle" aria-label="Toggle navigation">☰</button>
|
||||
<a href="/" class="logo">{{ config.site.title }}</a>
|
||||
<input id="search-input" class="search-input" type="search" placeholder="Search..." aria-label="Search" />
|
||||
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">🌓</button>
|
||||
<div id="search-results" class="search-results"></div>
|
||||
</header>
|
29
docs/templates/partials/sidebar.njk
vendored
Normal file
29
docs/templates/partials/sidebar.njk
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{% macro renderNav(items, pageUrl) %}
|
||||
<ul>
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% if item.children and item.children.length %}
|
||||
{% set sectionPath = item.path | replace('index.html', '') %}
|
||||
<details class="nav-section" {% if pageUrl.startsWith(sectionPath) %}open{% endif %}>
|
||||
<summary>
|
||||
<a href="{{ item.path }}" class="nav-link{% if item.path === pageUrl %} active{% endif %}">
|
||||
{{ item.displayName or item.page.title }}
|
||||
</a>
|
||||
</summary>
|
||||
{{ renderNav(item.children, pageUrl) }}
|
||||
</details>
|
||||
{% else %}
|
||||
<a href="{{ item.path }}" class="nav-link{% if item.path === pageUrl %} active{% endif %}">
|
||||
{{ item.displayName or item.page.title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<nav>
|
||||
{{ renderNav(navigation, page.url) }}
|
||||
</nav>
|
||||
</aside>
|
@@ -1,31 +0,0 @@
|
||||
from pathlib import Path
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.entry_management import EntryManager
|
||||
from password_manager.backup import BackupManager
|
||||
from constants import initialize_app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Demonstrate basic EntryManager usage."""
|
||||
initialize_app()
|
||||
key = Fernet.generate_key()
|
||||
enc = EncryptionManager(key, Path("."))
|
||||
vault = Vault(enc, Path("."))
|
||||
backup_mgr = BackupManager(Path("."))
|
||||
manager = EntryManager(vault, backup_mgr)
|
||||
|
||||
index = manager.add_entry(
|
||||
"Example Website",
|
||||
16,
|
||||
username="user123",
|
||||
url="https://example.com",
|
||||
)
|
||||
print(manager.retrieve_entry(index))
|
||||
manager.list_all_entries()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -1,15 +0,0 @@
|
||||
from password_manager.manager import PasswordManager
|
||||
from nostr.client import NostrClient
|
||||
from constants import initialize_app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Show how to initialise PasswordManager with Nostr support."""
|
||||
initialize_app()
|
||||
manager = PasswordManager()
|
||||
manager.nostr_client = NostrClient(encryption_manager=manager.encryption_manager)
|
||||
# Sample actions could be called on ``manager`` here.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -40,6 +40,8 @@
|
||||
</li>
|
||||
<li role="none"><a href="#disclaimer" role="menuitem">Disclaimer</a>
|
||||
</li>
|
||||
<li role="none"><a href="https://docs.seedpass.me/" role="menuitem">Docs</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -82,6 +84,26 @@ flowchart TB
|
||||
<h2 class="section-title" id="architecture-heading">Architecture Overview</h2>
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: base
|
||||
themeVariables:
|
||||
primaryColor: '#e94a39'
|
||||
primaryBorderColor: '#e94a39'
|
||||
lineColor: '#e94a39'
|
||||
look: classic
|
||||
---
|
||||
graph TD
|
||||
core(seedpass.core)
|
||||
cli(CLI/TUI)
|
||||
gui(BeeWare GUI)
|
||||
ext(Browser extension)
|
||||
cli --> core
|
||||
gui --> core
|
||||
ext --> core
|
||||
</pre>
|
||||
<pre class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: fixed
|
||||
theme: base
|
||||
@@ -180,6 +202,8 @@ flowchart TD
|
||||
<p>SeedPass allows you to manage multiple seed profiles (fingerprints). You can switch between different seeds to compartmentalize your passwords.</p>
|
||||
<h3 class="subsection-title">Nostr Relay Integration</h3>
|
||||
<p>SeedPass publishes your encrypted vault to Nostr in 50 KB chunks using parameterised replaceable events. A manifest describes each snapshot while deltas record updates. When too many deltas accumulate, a new snapshot is rotated in automatically.</p>
|
||||
<h3 class="subsection-title">Recovery from Nostr</h3>
|
||||
<p>Restoring a vault on a new device requires both your 12 word master seed and the master password that encrypted the vault. Without the correct password the downloaded archive cannot be decrypted.</p>
|
||||
<h3 class="subsection-title">Checksum Verification</h3>
|
||||
<p>Built-in checksum verification ensures your SeedPass installation hasn't been tampered with.</p>
|
||||
<h3 class="subsection-title">Interactive TUI</h3>
|
||||
|
5
packaging/build-linux.sh
Executable file
5
packaging/build-linux.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
briefcase create linux --no-input
|
||||
briefcase build linux --no-input
|
||||
briefcase package linux --no-input
|
5
packaging/build-macos.sh
Executable file
5
packaging/build-macos.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
briefcase create macos --no-input
|
||||
briefcase build macos --no-input
|
||||
briefcase package macos --no-input
|
3
packaging/build-windows.ps1
Normal file
3
packaging/build-windows.ps1
Normal file
@@ -0,0 +1,3 @@
|
||||
briefcase create windows --no-input
|
||||
briefcase build windows --no-input
|
||||
briefcase package windows --no-input
|
18
packaging/flatpak/seedpass.yml
Normal file
18
packaging/flatpak/seedpass.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
app-id: io.seedpass.SeedPass
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '46'
|
||||
sdk: org.gnome.Sdk
|
||||
command: seedpass-gui
|
||||
modules:
|
||||
- name: seedpass
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- mkdir -p /app/bin
|
||||
- cp -r ../../build/seedpass-gui/ubuntu/noble/* /app/bin/
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../../
|
||||
finish-args:
|
||||
- --share=network
|
||||
- --socket=fallback-x11
|
||||
- --socket=wayland
|
22
packaging/snapcraft.yaml
Normal file
22
packaging/snapcraft.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: seedpass
|
||||
base: core22
|
||||
version: '0.1.0'
|
||||
summary: Deterministic password manager
|
||||
description: |
|
||||
SeedPass deterministically generates passwords using BIP-39 seeds.
|
||||
grade: devel
|
||||
confinement: strict
|
||||
apps:
|
||||
seedpass-gui:
|
||||
command: bin/seedpass-gui
|
||||
plugs:
|
||||
- network
|
||||
- x11
|
||||
parts:
|
||||
seedpass:
|
||||
plugin: dump
|
||||
source: build/seedpass-gui/ubuntu/noble/app
|
||||
stage-packages:
|
||||
- libgtk-3-0
|
||||
- libglib2.0-0
|
||||
- libgdk-pixbuf2.0-0
|
3397
poetry.lock
generated
Normal file
3397
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
||||
---
|
||||
|
||||
# SeedPass Feature Back‑Log (v2)
|
||||
|
||||
> **Encryption invariant** Everything at rest **and** in export remains cipher‑text that ultimately derives from the **profile master‑password + parent seed**. No unencrypted payload leaves the vault.
|
||||
>
|
||||
> **Surface rule** UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network.
|
||||
|
||||
---
|
||||
|
||||
## Track vocabulary
|
||||
|
||||
| Label | Meaning |
|
||||
| ------------ | ------------------------------------------------------------------------------ |
|
||||
| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI |
|
||||
| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries |
|
||||
| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events |
|
||||
| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap |
|
||||
|
||||
---
|
||||
|
||||
## Phase A • Core‑level enhancements (blockers for GUI)
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]` <br>• Decrypt *only* whitelisted meta‑fields per `kind` (title, username, url, tags) for in‑memory matching. |
|
||||
| 🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")` <br>• Sorting by `title` must decrypt that field on‑the‑fly. |
|
||||
| 🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]` <br>• CRUD CLI commands & GUI dialog. <br>• `NostrClient` reads from state at instantiation. |
|
||||
| ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15 min). <br>• `AuthGuard` clears in‑memory keys & seeds. <br>• CLI `seedpass lock` + GUI menu “Lock vault”. |
|
||||
|
||||
**Exit‑criteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness.
|
||||
|
||||
---
|
||||
|
||||
## Phase B • Data Portability (encrypted only)
|
||||
|
||||
| Prio | Feature | Notes | |
|
||||
| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc` <br>• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()` <br>• Always require active profile unlock. | |
|
||||
| ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]` <br>• Verify fingerprint match before ingest. <br>• Conflict policy pluggable; default `skip\`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase C • Advanced secrets & sync
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ◇ | **TOTP entry kind** | • `kind="totp_secret"` fields: title, issuer, username, secret\_key <br>• `secret_key` encrypted; handler uses `pyotp` to show current code. |
|
||||
| ◇ | **Manual Conflict Resolver** | • When `checksum` mismatch *and* both sides newer than last sync → prompt user (CLI) or modal (GUI). |
|
||||
|
||||
---
|
||||
|
||||
## Phase D • Desktop GUI MVP (Qt 6)
|
||||
|
||||
*Features here ride on the Core API; keep UI totally stateless.*
|
||||
|
||||
| Prio | Feature | Notes |
|
||||
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 🔥 | **Login Window** | • Unlock profile with master pw. <br>• Profile switcher drop‑down. |
|
||||
| 🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings). <br>• `QTableView` bound to `VaultService.list_entries()` <br>• Sort & basic filters built‑in. |
|
||||
| 🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`. <br>• Add / Edit. |
|
||||
| ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. |
|
||||
| ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. |
|
||||
|
||||
*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.*
|
||||
|
||||
---
|
||||
|
||||
## Phase E • Later / Research
|
||||
|
||||
• Hardware‑wallet unlock (SLIP‑39 share)
|
||||
• Background daemon (`seedpassd` + gRPC)
|
||||
• Mobile companion (Flutter FFI)
|
||||
• Federated search across multiple profiles
|
||||
|
||||
---
|
||||
|
||||
**Reminder:** *No plaintext exports, no on‑disk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only.
|
100
pyproject.toml
100
pyproject.toml
@@ -1,11 +1,107 @@
|
||||
[project]
|
||||
[tool.poetry]
|
||||
name = "seedpass"
|
||||
version = "0.1.0"
|
||||
description = "Deterministic password manager with a BeeWare GUI"
|
||||
authors = []
|
||||
|
||||
[project.scripts]
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<3.13"
|
||||
colorama = ">=0.4.6"
|
||||
termcolor = ">=1.1.0"
|
||||
cryptography = ">=40.0.2"
|
||||
bip-utils = ">=2.5.0"
|
||||
bech32 = "1.2.0"
|
||||
coincurve = ">=18.0.0"
|
||||
mnemonic = "*"
|
||||
aiohttp = ">=3.12.15"
|
||||
bcrypt = "*"
|
||||
portalocker = ">=2.8"
|
||||
nostr-sdk = ">=0.43"
|
||||
websocket-client = "1.7.0"
|
||||
websockets = ">=15.0.0"
|
||||
tomli = "*"
|
||||
pgpy = "0.6.0"
|
||||
pyotp = ">=2.8.0"
|
||||
pyperclip = "*"
|
||||
qrcode = ">=8.2"
|
||||
typer = ">=0.12.3"
|
||||
fastapi = ">=0.116.0"
|
||||
uvicorn = ">=0.35.0"
|
||||
httpx = ">=0.28.1"
|
||||
requests = ">=2.32"
|
||||
python-multipart = ">=0.0.20"
|
||||
orjson = "*"
|
||||
argon2-cffi = "*"
|
||||
PyJWT = ">=2.8.0"
|
||||
slowapi = "^0.1.9"
|
||||
toga-core = { version = ">=0.5.2", optional = true }
|
||||
pillow = { version = "*", optional = true }
|
||||
|
||||
[tool.poetry.extras]
|
||||
gui = ["toga-core", "pillow"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.2"
|
||||
coverage = "^7.5"
|
||||
black = "^24.3"
|
||||
pip-audit = "^2.7"
|
||||
pytest-xdist = "^3.5"
|
||||
hypothesis = "^6.98"
|
||||
freezegun = "^1.5"
|
||||
toga-dummy = ">=0.5.2"
|
||||
Pillow = "^10.4"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
seedpass = "seedpass.cli:app"
|
||||
seedpass-gui = "seedpass_gui.app:main"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
mypy_path = "src"
|
||||
|
||||
[tool.briefcase]
|
||||
project_name = "SeedPass"
|
||||
bundle = "io.seedpass"
|
||||
version = "0.1.0"
|
||||
|
||||
[tool.briefcase.app.seedpass-gui]
|
||||
formal-name = "SeedPass"
|
||||
description = "Deterministic password manager with a BeeWare GUI"
|
||||
sources = ["src/seedpass_gui"]
|
||||
requires = [
|
||||
"toga-core>=0.5.2",
|
||||
"colorama>=0.4.6",
|
||||
"termcolor>=1.1.0",
|
||||
"cryptography>=40.0.2",
|
||||
"bip-utils>=2.5.0",
|
||||
"bech32==1.2.0",
|
||||
"coincurve>=18.0.0",
|
||||
"mnemonic",
|
||||
"aiohttp>=3.12.15",
|
||||
"bcrypt",
|
||||
"portalocker>=2.8",
|
||||
"nostr-sdk>=0.43",
|
||||
"websocket-client==1.7.0",
|
||||
"websockets>=15.0.0",
|
||||
"tomli",
|
||||
"pgpy==0.6.0",
|
||||
"pyotp>=2.8.0",
|
||||
"pyperclip",
|
||||
"qrcode>=8.2",
|
||||
"typer>=0.12.3",
|
||||
"fastapi>=0.116.0",
|
||||
"uvicorn>=0.35.0",
|
||||
"httpx>=0.28.1",
|
||||
"requests>=2.32",
|
||||
"python-multipart>=0.0.20",
|
||||
"orjson",
|
||||
"argon2-cffi",
|
||||
]
|
||||
icon = "logo/png/SeedPass-Logo-24.png"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
|
@@ -3,7 +3,7 @@ addopts = -n auto
|
||||
log_cli = true
|
||||
log_cli_level = WARNING
|
||||
log_level = WARNING
|
||||
testpaths = src/tests
|
||||
testpaths = src/tests tests
|
||||
markers =
|
||||
network: tests that require network connectivity
|
||||
stress: long running stress tests
|
||||
|
113
refactor.md
113
refactor.md
@@ -1,113 +0,0 @@
|
||||
# SeedPass v2 Roadmap — CLI → Desktop GUI
|
||||
|
||||
> **Guiding principles**
|
||||
>
|
||||
> 1. **Core-first** – a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic.
|
||||
> 2. **Thin adapters** – CLI, GUI, and future mobile layers merely call the core API.
|
||||
> 3. **Stateless UI** – all persistence lives in core services; UI never touches vault files directly.
|
||||
> 4. **Parity at every step** – CLI must keep working while GUI evolves.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 • Tooling Baseline
|
||||
|
||||
| # | Task | Rationale |
|
||||
| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. |
|
||||
| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. |
|
||||
| 0.3 | ✅ Pre-commit hooks: ruff –fix, black, isort. | Uniform style. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 • Finalize Core Refactor (CLI still primary)
|
||||
|
||||
> *Most of this is already drafted – here’s what must ship before GUI work starts.*
|
||||
|
||||
| # | Component | Must-have work |
|
||||
| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` |
|
||||
| 1.2 | **`StateManager`** | JSON file w/ fcntl lock<br>keys: `last_bip85_idx`, `last_sync_ts` |
|
||||
| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` |
|
||||
| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone |
|
||||
| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files |
|
||||
| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. |
|
||||
| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` – idempotent, uses `add_entry()` under the hood. |
|
||||
| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. |
|
||||
|
||||
> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 • Core API Hardening (prep for GUI)
|
||||
|
||||
| # | Task | Deliverable |
|
||||
| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:<br>`VaultService`, `ProfileService`, `SyncService` – *no* CLI / UI imports. |
|
||||
| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. |
|
||||
| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). |
|
||||
| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). |
|
||||
| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 • Desktop GUI MVP
|
||||
|
||||
| # | Decision | Notes |
|
||||
| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. |
|
||||
| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.<br>(If we outgrow this, swap to a local gRPC server later.) |
|
||||
| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | |
|
||||
| – | `LoginWindow` | master-password prompt → opens default profile |
|
||||
| – | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views |
|
||||
| – | `EntryTableView` | QTableView bound to `VaultService.list_entries()` |
|
||||
| – | `EntryEditorDialog` | Add / Edit forms – field set driven by `kinds.py` |
|
||||
| – | `SyncStatusBar` | pulse animation + last-sync timestamp |
|
||||
| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. |
|
||||
| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. |
|
||||
| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). |
|
||||
|
||||
> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 • Unified Workflows & Coverage
|
||||
|
||||
| # | Task |
|
||||
| --- | --------------------------------------------------------------------------------------- |
|
||||
| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. |
|
||||
| 4.2 | Add synthetic coverage for GUI code paths (QtBot). |
|
||||
| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 • Future-Proofing (post-GUI v1)
|
||||
|
||||
| Idea | Sketch |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. |
|
||||
| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. |
|
||||
| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). |
|
||||
| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. |
|
||||
|
||||
---
|
||||
|
||||
## Deliverables Checklist
|
||||
|
||||
* [ ] Core refactor merged, tests ≥ 85 % coverage
|
||||
* [ ] `seedpass` installs and passes `python -m seedpass.cli --help`
|
||||
* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs
|
||||
* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag
|
||||
* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers
|
||||
|
||||
When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers.
|
||||
|
||||
---
|
||||
|
||||
### 🔑 Key Takeaways
|
||||
|
||||
1. **Keep all state & crypto in the core package.**
|
||||
2. **Expose a clean Python API first – GUI is “just another client.”**
|
||||
3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.**
|
||||
4. **Lock files and StateManager prevent index reuse and vault corruption.**
|
||||
5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.**
|
||||
|
2045
requirements.lock
2045
requirements.lock
File diff suppressed because it is too large
Load Diff
9
scripts/dependency_scan.sh
Executable file
9
scripts/dependency_scan.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run pip-audit against the pinned requirements
|
||||
if ! command -v pip-audit >/dev/null 2>&1; then
|
||||
python -m pip install --quiet pip-audit
|
||||
fi
|
||||
|
||||
pip-audit -r requirements.lock "$@"
|
@@ -1,10 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a SeedPass test profile with realistic entries.
|
||||
|
||||
This script populates a profile directory with a variety of entry types.
|
||||
This script populates a profile directory with a variety of entry types,
|
||||
including key/value pairs and managed accounts.
|
||||
If the profile does not exist, a new BIP-39 seed phrase is generated and
|
||||
stored encrypted. A clear text copy is written to ``seed_phrase.txt`` so
|
||||
it can be reused across devices.
|
||||
|
||||
Profiles are saved under ``~/.seedpass/tests/`` by default. SeedPass
|
||||
only detects a profile automatically when it resides directly under
|
||||
``~/.seedpass/``. Copy the generated fingerprint directory from the
|
||||
``tests`` subfolder to ``~/.seedpass`` (or adjust ``APP_DIR`` in
|
||||
``constants.py``) to use the test seed with the main application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt"
|
||||
|
||||
from constants import APP_DIR, initialize_app
|
||||
from utils.key_derivation import derive_key_from_password, derive_index_key
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from password_manager.vault import Vault
|
||||
from password_manager.config_manager import ConfigManager
|
||||
from password_manager.backup import BackupManager
|
||||
from password_manager.entry_management import EntryManager
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
from seedpass.core.vault import Vault
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
from nostr.client import NostrClient
|
||||
from utils.fingerprint import generate_fingerprint
|
||||
from utils.fingerprint_manager import FingerprintManager
|
||||
@@ -46,7 +53,9 @@ import gzip
|
||||
DEFAULT_PASSWORD = "testpassword"
|
||||
|
||||
|
||||
def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]:
|
||||
def initialize_profile(
|
||||
profile_name: str,
|
||||
) -> tuple[str, EntryManager, Path, str, ConfigManager]:
|
||||
"""Create or load a profile and return the seed phrase, manager, directory and fingerprint."""
|
||||
initialize_app()
|
||||
seed_txt = APP_DIR / f"{profile_name}_seed.txt"
|
||||
@@ -70,7 +79,7 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]
|
||||
profile_dir = APP_DIR / fingerprint
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
seed_key = derive_key_from_password(DEFAULT_PASSWORD)
|
||||
seed_key = derive_key_from_password(DEFAULT_PASSWORD, fingerprint)
|
||||
seed_mgr = EncryptionManager(seed_key, profile_dir)
|
||||
seed_file = profile_dir / "parent_seed.enc"
|
||||
clear_path = profile_dir / "seed_phrase.txt"
|
||||
@@ -96,9 +105,11 @@ def initialize_profile(profile_name: str) -> tuple[str, EntryManager, Path, str]
|
||||
# Store the default password hash so the profile can be opened
|
||||
hashed = bcrypt.hashpw(DEFAULT_PASSWORD.encode(), bcrypt.gensalt()).decode()
|
||||
cfg_mgr.set_password_hash(hashed)
|
||||
# Ensure stored iterations match the PBKDF2 work factor used above
|
||||
cfg_mgr.set_kdf_iterations(100_000)
|
||||
backup_mgr = BackupManager(profile_dir, cfg_mgr)
|
||||
entry_mgr = EntryManager(vault, backup_mgr)
|
||||
return seed_phrase, entry_mgr, profile_dir, fingerprint
|
||||
return seed_phrase, entry_mgr, profile_dir, fingerprint, cfg_mgr
|
||||
|
||||
|
||||
def random_secret(length: int = 16) -> str:
|
||||
@@ -111,7 +122,7 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None:
|
||||
start_index = entry_mgr.get_next_index()
|
||||
for i in range(count):
|
||||
idx = start_index + i
|
||||
kind = idx % 7
|
||||
kind = idx % 9
|
||||
if kind == 0:
|
||||
entry_mgr.add_entry(
|
||||
label=f"site-{idx}.example.com",
|
||||
@@ -133,18 +144,33 @@ def populate(entry_mgr: EntryManager, seed: str, count: int) -> None:
|
||||
)
|
||||
elif kind == 5:
|
||||
entry_mgr.add_nostr_key(f"nostr-{idx}", notes=f"Nostr key {idx}")
|
||||
else:
|
||||
elif kind == 6:
|
||||
entry_mgr.add_pgp_key(
|
||||
f"pgp-{idx}",
|
||||
seed,
|
||||
user_id=f"user{idx}@example.com",
|
||||
notes=f"PGP key {idx}",
|
||||
)
|
||||
elif kind == 7:
|
||||
entry_mgr.add_key_value(
|
||||
f"kv-{idx}",
|
||||
random_secret(20),
|
||||
notes=f"Key/Value {idx}",
|
||||
)
|
||||
else:
|
||||
entry_mgr.add_managed_account(
|
||||
f"acct-{idx}",
|
||||
seed,
|
||||
notes=f"Managed account {idx}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create or extend a SeedPass test profile"
|
||||
description=(
|
||||
"Create or extend a SeedPass test profile (default PBKDF2 iterations:"
|
||||
" 100,000)"
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
@@ -159,7 +185,7 @@ def main() -> None:
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
seed, entry_mgr, dir_path, fingerprint = initialize_profile(args.profile)
|
||||
seed, entry_mgr, dir_path, fingerprint, cfg_mgr = initialize_profile(args.profile)
|
||||
print(f"Using profile directory: {dir_path}")
|
||||
print(f"Parent seed: {seed}")
|
||||
if fingerprint:
|
||||
@@ -173,6 +199,7 @@ def main() -> None:
|
||||
entry_mgr.vault.encryption_manager,
|
||||
fingerprint or dir_path.name,
|
||||
parent_seed=seed,
|
||||
config_manager=cfg_mgr,
|
||||
)
|
||||
asyncio.run(client.publish_snapshot(encrypted))
|
||||
print("[+] Data synchronized to Nostr.")
|
||||
|
@@ -2,10 +2,12 @@
|
||||
# SeedPass Universal Installer for Windows
|
||||
#
|
||||
# Supports installing from a specific branch using the -Branch parameter.
|
||||
# Example: .\install.ps1 -Branch beta
|
||||
# Use -IncludeGui to install the optional BeeWare GUI backend.
|
||||
# Example: .\install.ps1 -Branch beta -IncludeGui
|
||||
|
||||
param(
|
||||
[string]$Branch = "main" # The git branch to install from
|
||||
[string]$Branch = "main", # The git branch to install from
|
||||
[switch]$IncludeGui # Install BeeWare GUI components
|
||||
)
|
||||
|
||||
# --- Configuration ---
|
||||
@@ -249,12 +251,31 @@ if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to upgrade pip"
|
||||
}
|
||||
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install -r "src\requirements.txt"
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install --require-hashes -r "requirements.lock"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Failed to install Python dependencies. If errors mention C++, install Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/"
|
||||
Write-Error "Dependency installation failed."
|
||||
}
|
||||
|
||||
if ($IncludeGui) {
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install -e .[gui]
|
||||
} else {
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install -e .
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to install SeedPass package"
|
||||
}
|
||||
|
||||
if ($IncludeGui) {
|
||||
Write-Info "Installing BeeWare GUI backend..."
|
||||
try {
|
||||
& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms
|
||||
if ($LASTEXITCODE -ne 0) { throw "toga-winforms installation failed" }
|
||||
} catch {
|
||||
Write-Warning "Failed to install GUI backend. Install Microsoft C++ Build Tools from https://visualstudio.microsoft.com/visual-cpp-build-tools/ and rerun the installer."
|
||||
}
|
||||
}
|
||||
|
||||
# 5. Create launcher script
|
||||
Write-Info "Creating launcher script..."
|
||||
if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null }
|
||||
@@ -263,11 +284,29 @@ $LauncherContent = @"
|
||||
@echo off
|
||||
setlocal
|
||||
call "%~dp0..\venv\Scripts\activate.bat"
|
||||
python "%~dp0..\src\main.py" %*
|
||||
"%~dp0..\venv\Scripts\python.exe" -m seedpass.cli %*
|
||||
endlocal
|
||||
"@
|
||||
Set-Content -Path $LauncherPath -Value $LauncherContent -Force
|
||||
|
||||
$existingSeedpass = Get-Command seedpass -ErrorAction SilentlyContinue
|
||||
if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) {
|
||||
Write-Warning "Another 'seedpass' command was found at $($existingSeedpass.Source)."
|
||||
Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation."
|
||||
}
|
||||
|
||||
# Detect additional seedpass executables on PATH that are not our launcher
|
||||
$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source
|
||||
$stale = @()
|
||||
foreach ($cmd in $allSeedpass) {
|
||||
if ($cmd -ne $LauncherPath) { $stale += $cmd }
|
||||
}
|
||||
if ($stale.Count -gt 0) {
|
||||
Write-Warning "Stale 'seedpass' executables detected:"
|
||||
foreach ($cmd in $stale) { Write-Warning " - $cmd" }
|
||||
Write-Warning "Remove or rename these to avoid launching outdated code."
|
||||
}
|
||||
|
||||
# 6. Add launcher directory to User's PATH if needed
|
||||
Write-Info "Checking if '$LauncherDir' is in your PATH..."
|
||||
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
@@ -281,4 +320,5 @@ if (($UserPath -split ';') -notcontains $LauncherDir) {
|
||||
}
|
||||
|
||||
Write-Success "Installation/update complete!"
|
||||
Write-Info "To run the application, please open a NEW terminal window and type: seedpass"
|
||||
Write-Info "To launch the interactive TUI, open a NEW terminal window and run: seedpass"
|
||||
Write-Info "'seedpass' resolves to: $(Get-Command seedpass | Select-Object -ExpandProperty Source)"
|
||||
|
@@ -5,7 +5,9 @@
|
||||
# Supports installing from a specific branch using the -b or --branch flag.
|
||||
# Example: ./install.sh -b beta
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
trap 'echo "[ERROR] Line $LINENO failed"; exit 1' ERR
|
||||
|
||||
# --- Configuration ---
|
||||
REPO_URL="https://github.com/PR0M3TH3AN/SeedPass.git"
|
||||
@@ -15,15 +17,51 @@ VENV_DIR="$INSTALL_DIR/venv"
|
||||
LAUNCHER_DIR="$HOME/.local/bin"
|
||||
LAUNCHER_PATH="$LAUNCHER_DIR/seedpass"
|
||||
BRANCH="main" # Default branch
|
||||
INSTALL_GUI=true
|
||||
|
||||
# --- Helper Functions ---
|
||||
print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
|
||||
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
|
||||
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
|
||||
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; }
|
||||
print_info() { echo -e "\033[1;34m[INFO]\033[0m" "$1"; }
|
||||
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m" "$1"; }
|
||||
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m" "$1"; }
|
||||
print_error() { echo -e "\033[1;31m[ERROR]\033[0m" "$1" >&2; exit 1; }
|
||||
|
||||
# Install build dependencies for Gtk/GObject if available via the system package manager
|
||||
install_dependencies() {
|
||||
print_info "Installing system packages required for Gtk bindings..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
sudo apt-get update && sudo apt-get install -y \\
|
||||
build-essential pkg-config libcairo2 libcairo2-dev \\
|
||||
libgirepository1.0-dev gobject-introspection \\
|
||||
gir1.2-gtk-3.0 libgtk-3-dev python3-dev libffi-dev libssl-dev \\
|
||||
cmake rustc cargo zlib1g-dev libjpeg-dev libpng-dev \\
|
||||
libfreetype6-dev xclip wl-clipboard
|
||||
elif command -v yum &>/dev/null; then
|
||||
sudo yum install -y @'Development Tools' cairo cairo-devel \\
|
||||
gobject-introspection-devel gtk3-devel python3-devel \\
|
||||
libffi-devel openssl-devel cmake rust cargo zlib-devel \\
|
||||
libjpeg-turbo-devel libpng-devel freetype-devel xclip \\
|
||||
wl-clipboard
|
||||
elif command -v dnf &>/dev/null; then
|
||||
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \\
|
||||
cairo cairo-devel gobject-introspection-devel gtk3-devel \\
|
||||
python3-devel libffi-devel openssl-devel cmake rust cargo \\
|
||||
zlib-devel libjpeg-turbo-devel libpng-devel freetype-devel \\
|
||||
xclip wl-clipboard
|
||||
elif command -v pacman &>/dev/null; then
|
||||
sudo pacman -Syu --noconfirm base-devel pkgconf cmake rustup \\
|
||||
gtk3 gobject-introspection cairo libjpeg-turbo zlib \\
|
||||
libpng freetype xclip wl-clipboard && rustup default stable
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install pkg-config cairo gobject-introspection gtk+3 cmake rustup-init && \\
|
||||
rustup-init -y
|
||||
else
|
||||
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
|
||||
fi
|
||||
}
|
||||
usage() {
|
||||
echo "Usage: $0 [-b | --branch <branch_name>] [-h | --help]"
|
||||
echo "Usage: $0 [-b | --branch <branch_name>] [--no-gui] [-h | --help]"
|
||||
echo " -b, --branch Specify the git branch to install (default: main)"
|
||||
echo " --no-gui Skip graphical interface dependencies (default: include GUI)"
|
||||
echo " -h, --help Display this help message"
|
||||
exit 0
|
||||
}
|
||||
@@ -44,6 +82,10 @@ main() {
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
--no-gui)
|
||||
INSTALL_GUI=false
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown parameter passed: $1"; usage
|
||||
;;
|
||||
@@ -84,15 +126,14 @@ main() {
|
||||
fi
|
||||
|
||||
# 3. Install OS-specific dependencies
|
||||
print_info "Checking for build dependencies..."
|
||||
if [ "$OS_NAME" = "Linux" ]; then
|
||||
if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip;
|
||||
elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip;
|
||||
elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip;
|
||||
else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi
|
||||
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||
if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi
|
||||
brew install pkg-config
|
||||
if [ "$INSTALL_GUI" = true ]; then
|
||||
print_info "Checking for Gtk development libraries..."
|
||||
if command -v pkg-config &>/dev/null && pkg-config --exists girepository-2.0; then
|
||||
print_info "Gtk bindings already available."
|
||||
else
|
||||
print_warning "Gtk introspection bindings not found. Installing dependencies..."
|
||||
install_dependencies
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Clone or update the repository
|
||||
@@ -116,24 +157,81 @@ main() {
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# 6. Install/Update Python dependencies
|
||||
print_info "Installing/updating Python dependencies from src/requirements.txt..."
|
||||
print_info "Installing/updating Python dependencies from requirements.lock..."
|
||||
pip install --upgrade pip
|
||||
pip install -r src/requirements.txt
|
||||
pip install --require-hashes -r requirements.lock
|
||||
if [ "$INSTALL_GUI" = true ]; then
|
||||
GUI_READY=true
|
||||
if [ "$OS_NAME" = "Linux" ]; then
|
||||
if ! (command -v pkg-config &>/dev/null && pkg-config --exists girepository-2.0); then
|
||||
print_warning "GTK libraries (girepository-2.0) not found. Install them with: sudo apt install libgirepository1.0-dev"
|
||||
read -r -p "Continue with GUI installation anyway? (y/N) " CONTINUE_GUI
|
||||
if [[ ! "$CONTINUE_GUI" =~ ^[Yy]$ ]]; then
|
||||
GUI_READY=false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ "$GUI_READY" = true ]; then
|
||||
pip install -e .[gui]
|
||||
print_info "Installing platform-specific Toga backend..."
|
||||
if [ "$OS_NAME" = "Linux" ]; then
|
||||
print_info "Installing toga-gtk for Linux..."
|
||||
pip install toga-gtk
|
||||
elif [ "$OS_NAME" = "Darwin" ]; then
|
||||
print_info "Installing toga-cocoa for macOS..."
|
||||
pip install toga-cocoa
|
||||
fi
|
||||
else
|
||||
print_warning "Skipping GUI installation."
|
||||
pip install -e .
|
||||
fi
|
||||
else
|
||||
pip install -e .
|
||||
fi
|
||||
|
||||
if ! "$VENV_DIR/bin/python" -c "import seedpass.cli; print('ok')"; then
|
||||
print_error "SeedPass CLI import check failed."
|
||||
fi
|
||||
|
||||
deactivate
|
||||
|
||||
# 7. Create launcher script
|
||||
print_info "Creating launcher script at '$LAUNCHER_PATH'..."
|
||||
mkdir -p "$LAUNCHER_DIR"
|
||||
cat > "$LAUNCHER_PATH" << EOF2
|
||||
cat > "$LAUNCHER_PATH" << EOF2
|
||||
#!/bin/bash
|
||||
source "$VENV_DIR/bin/activate"
|
||||
exec python3 "$INSTALL_DIR/src/main.py" "\$@"
|
||||
exec "$VENV_DIR/bin/seedpass" "\$@"
|
||||
EOF2
|
||||
chmod +x "$LAUNCHER_PATH"
|
||||
|
||||
existing_cmd=$(command -v seedpass 2>/dev/null || true)
|
||||
if [ -n "$existing_cmd" ] && [ "$existing_cmd" != "$LAUNCHER_PATH" ]; then
|
||||
print_warning "Another 'seedpass' command was found at $existing_cmd."
|
||||
print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation."
|
||||
fi
|
||||
|
||||
# Detect any additional seedpass executables on PATH that are not our launcher
|
||||
IFS=':' read -ra _sp_paths <<< "$PATH"
|
||||
stale_cmds=()
|
||||
for _dir in "${_sp_paths[@]}"; do
|
||||
_candidate="$_dir/seedpass"
|
||||
if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then
|
||||
stale_cmds+=("$_candidate")
|
||||
fi
|
||||
done
|
||||
if [ ${#stale_cmds[@]} -gt 0 ]; then
|
||||
print_warning "Stale 'seedpass' executables detected:"
|
||||
for cmd in "${stale_cmds[@]}"; do
|
||||
print_warning " - $cmd"
|
||||
done
|
||||
print_warning "Remove or rename these to avoid launching outdated code."
|
||||
fi
|
||||
|
||||
# 8. Final instructions
|
||||
print_success "Installation/update complete!"
|
||||
print_info "You can now run the application by typing: seedpass"
|
||||
print_info "You can now launch the interactive TUI by typing: seedpass"
|
||||
print_info "'seedpass' resolves to: $(command -v seedpass)"
|
||||
if [[ ":$PATH:" != *":$LAUNCHER_DIR:"* ]]; then
|
||||
print_warning "Directory '$LAUNCHER_DIR' is not in your PATH."
|
||||
print_warning "Please add 'export PATH=\"$HOME/.local/bin:$PATH\"' to your shell's config file (e.g., ~/.bashrc, ~/.zshrc) and restart your terminal."
|
||||
|
36
scripts/run_ci_tests.sh
Executable file
36
scripts/run_ci_tests.sh
Executable file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
pytest_args=(-vv)
|
||||
if [[ -n "${STRESS_ARGS:-}" ]]; then
|
||||
pytest_args+=(${STRESS_ARGS})
|
||||
fi
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
pytest_args+=(-n 1)
|
||||
fi
|
||||
pytest_args+=(--cov=src --cov-report=xml --cov-report=term-missing --cov-fail-under=20 src/tests)
|
||||
|
||||
timeout_bin="timeout"
|
||||
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
timeout_bin=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$timeout_bin" ]]; then
|
||||
$timeout_bin 15m pytest "${pytest_args[@]}" 2>&1 | tee pytest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
else
|
||||
echo "timeout command not found; running tests without timeout" >&2
|
||||
pytest "${pytest_args[@]}" 2>&1 | tee pytest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
fi
|
||||
|
||||
if [[ $status -eq 124 ]]; then
|
||||
echo "::error::Tests exceeded 15-minute limit"
|
||||
tail -n 20 pytest.log
|
||||
exit 1
|
||||
fi
|
||||
exit $status
|
32
scripts/run_gui_tests.sh
Executable file
32
scripts/run_gui_tests.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
pytest_args=(-vv --desktop -m desktop src/tests)
|
||||
if [[ "${RUNNER_OS:-}" == "Windows" ]]; then
|
||||
pytest_args+=(-n 1)
|
||||
fi
|
||||
|
||||
timeout_bin="timeout"
|
||||
if ! command -v "$timeout_bin" >/dev/null 2>&1; then
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
timeout_bin="gtimeout"
|
||||
else
|
||||
timeout_bin=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$timeout_bin" ]]; then
|
||||
$timeout_bin 10m pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
|
||||
status=${PIPESTATUS[0]}
|
||||
else
|
||||
echo "timeout command not found; running tests without timeout" >&2
|
||||
pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log
|
||||
status=${PIPESTATUS[0]}
|
||||
fi
|
||||
|
||||
if [[ $status -eq 124 ]]; then
|
||||
echo "::error::Desktop tests exceeded 10-minute limit"
|
||||
tail -n 20 pytest_gui.log
|
||||
exit 1
|
||||
fi
|
||||
exit $status
|
41
scripts/uninstall.ps1
Normal file
41
scripts/uninstall.ps1
Normal file
@@ -0,0 +1,41 @@
|
||||
#
|
||||
# SeedPass Uninstaller for Windows
|
||||
#
|
||||
# Removes the SeedPass application files but preserves user data under ~/.seedpass
|
||||
|
||||
$AppRootDir = Join-Path $env:USERPROFILE ".seedpass"
|
||||
$InstallDir = Join-Path $AppRootDir "app"
|
||||
$LauncherDir = Join-Path $InstallDir "bin"
|
||||
$LauncherName = "seedpass.cmd"
|
||||
|
||||
function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan }
|
||||
function Write-Success { param([string]$Message) Write-Host "[SUCCESS] $Message" -ForegroundColor Green }
|
||||
function Write-Warning { param([string]$Message) Write-Host "[WARNING] $Message" -ForegroundColor Yellow }
|
||||
function Write-Error { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red }
|
||||
|
||||
Write-Info "Removing SeedPass installation..."
|
||||
|
||||
if (Test-Path $InstallDir) {
|
||||
Remove-Item -Recurse -Force $InstallDir
|
||||
Write-Info "Deleted '$InstallDir'"
|
||||
} else {
|
||||
Write-Info "Installation directory not found."
|
||||
}
|
||||
|
||||
$LauncherPath = Join-Path $LauncherDir $LauncherName
|
||||
if (Test-Path $LauncherPath) {
|
||||
Remove-Item -Force $LauncherPath
|
||||
Write-Info "Removed launcher '$LauncherPath'"
|
||||
} else {
|
||||
Write-Info "Launcher not found."
|
||||
}
|
||||
|
||||
Write-Info "Attempting to uninstall any global 'seedpass' package with pip..."
|
||||
try {
|
||||
pip uninstall -y seedpass | Out-Null
|
||||
} catch {
|
||||
try { pip3 uninstall -y seedpass | Out-Null } catch {}
|
||||
}
|
||||
|
||||
Write-Success "SeedPass uninstalled. User data under '$AppRootDir' was left intact."
|
||||
|
70
scripts/uninstall.sh
Normal file
70
scripts/uninstall.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# SeedPass Uninstaller for Linux and macOS
|
||||
#
|
||||
# Removes the SeedPass application files but preserves user data under ~/.seedpass
|
||||
|
||||
set -e
|
||||
|
||||
APP_ROOT_DIR="$HOME/.seedpass"
|
||||
INSTALL_DIR="$APP_ROOT_DIR/app"
|
||||
LAUNCHER_PATH="$HOME/.local/bin/seedpass"
|
||||
|
||||
print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; }
|
||||
print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; }
|
||||
print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; }
|
||||
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1"; }
|
||||
|
||||
# Remove any stale 'seedpass' executables that may still be on the PATH.
|
||||
remove_stale_executables() {
|
||||
IFS=':' read -ra DIRS <<< "$PATH"
|
||||
for dir in "${DIRS[@]}"; do
|
||||
candidate="$dir/seedpass"
|
||||
if [ -f "$candidate" ] && [ "$candidate" != "$LAUNCHER_PATH" ]; then
|
||||
print_info "Removing old executable '$candidate'..."
|
||||
if rm -f "$candidate"; then
|
||||
rm_status=0
|
||||
else
|
||||
rm_status=$?
|
||||
fi
|
||||
if [ $rm_status -ne 0 ] && [ -f "$candidate" ]; then
|
||||
print_warning "Failed to remove $candidate – try deleting it manually"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
print_info "Removing installation directory '$INSTALL_DIR'..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
else
|
||||
print_info "Installation directory not found."
|
||||
fi
|
||||
|
||||
|
||||
if [ -f "$LAUNCHER_PATH" ]; then
|
||||
print_info "Removing launcher script '$LAUNCHER_PATH'..."
|
||||
rm -f "$LAUNCHER_PATH"
|
||||
else
|
||||
print_info "Launcher script not found."
|
||||
fi
|
||||
|
||||
remove_stale_executables
|
||||
|
||||
print_info "Attempting to uninstall any global 'seedpass' package with pip..."
|
||||
if command -v python3 &> /dev/null; then
|
||||
python3 -m pip uninstall -y seedpass >/dev/null 2>&1 || true
|
||||
elif command -v pip &> /dev/null; then
|
||||
pip uninstall -y seedpass >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v pipx &> /dev/null; then
|
||||
pipx uninstall -y seedpass >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
print_success "SeedPass uninstalled."
|
||||
print_warning "User data in '$APP_ROOT_DIR' was left intact."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
@@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app
|
||||
def main() -> None:
|
||||
"""Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE."""
|
||||
initialize_app()
|
||||
script_path = SRC_DIR / "password_manager" / "manager.py"
|
||||
script_path = SRC_DIR / "seedpass/core" / "manager.py"
|
||||
if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)):
|
||||
raise SystemExit(f"Failed to update checksum for {script_path}")
|
||||
print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}")
|
||||
|
12
scripts/vendor_dependencies.sh
Executable file
12
scripts/vendor_dependencies.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VENDOR_DIR="src/vendor"
|
||||
|
||||
# Clean vendor directory
|
||||
rm -rf "$VENDOR_DIR"
|
||||
mkdir -p "$VENDOR_DIR"
|
||||
|
||||
pip download --no-binary :all: -r src/runtime_requirements.txt -d "$VENDOR_DIR"
|
||||
|
||||
echo "Vendored dependencies installed in $VENDOR_DIR"
|
@@ -9,8 +9,11 @@ logger = logging.getLogger(__name__)
|
||||
# -----------------------------------
|
||||
# Nostr Relay Connection Settings
|
||||
# -----------------------------------
|
||||
MAX_RETRIES = 3 # Maximum number of retries for relay connections
|
||||
RETRY_DELAY = 5 # Seconds to wait before retrying a failed connection
|
||||
# Retry fewer times with a shorter wait by default. These values
|
||||
# act as defaults that can be overridden via ``ConfigManager``
|
||||
# entries ``nostr_max_retries`` and ``nostr_retry_delay``.
|
||||
MAX_RETRIES = 2 # Default maximum number of retry attempts
|
||||
RETRY_DELAY = 1 # Default seconds to wait before retrying
|
||||
MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup
|
||||
|
||||
# -----------------------------------
|
||||
@@ -47,9 +50,15 @@ DEFAULT_PASSWORD_LENGTH = 16 # Default length for generated passwords
|
||||
MIN_PASSWORD_LENGTH = 8 # Minimum allowed password length
|
||||
MAX_PASSWORD_LENGTH = 128 # Maximum allowed password length
|
||||
|
||||
# Characters considered safe for passwords when limiting punctuation
|
||||
SAFE_SPECIAL_CHARS = "!@#$%^*-_+=?"
|
||||
|
||||
# Timeout in seconds before the vault locks due to inactivity
|
||||
INACTIVITY_TIMEOUT = 15 * 60 # 15 minutes
|
||||
|
||||
# Duration in seconds that a notification remains active
|
||||
NOTIFICATION_DURATION = 10
|
||||
|
||||
# -----------------------------------
|
||||
# Additional Constants (if any)
|
||||
# -----------------------------------
|
||||
|
@@ -1,17 +1,15 @@
|
||||
# bip85/__init__.py
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from .bip85 import BIP85
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.info("BIP85 module imported successfully.")
|
||||
except Exception as e:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.error(f"Failed to import BIP85 module: {e}", exc_info=True)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to import BIP85 module: %s", exc, exc_info=True)
|
||||
raise ImportError(
|
||||
"BIP85 dependencies are missing. Install 'bip_utils', 'cryptography', and 'colorama'."
|
||||
) from exc
|
||||
|
||||
__all__ = ["BIP85"]
|
||||
|
@@ -18,7 +18,8 @@ import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from typing import Union
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicGenerator, Bip39Languages
|
||||
@@ -38,13 +39,19 @@ class Bip85Error(Exception):
|
||||
|
||||
|
||||
class BIP85:
|
||||
def __init__(self, seed_bytes: bytes | str):
|
||||
"""Initialize from BIP39 seed bytes or BIP32 xprv string."""
|
||||
def __init__(self, seed_or_xprv: Union[bytes, str]):
|
||||
"""Initialize from seed bytes or an ``xprv`` string.
|
||||
|
||||
Parameters:
|
||||
seed_or_xprv (Union[bytes, str]): Either raw BIP39 seed bytes
|
||||
or a BIP32 extended private key (``xprv``) string.
|
||||
"""
|
||||
|
||||
try:
|
||||
if isinstance(seed_bytes, (bytes, bytearray)):
|
||||
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
|
||||
if isinstance(seed_or_xprv, (bytes, bytearray)):
|
||||
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_or_xprv)
|
||||
else:
|
||||
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_bytes)
|
||||
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_or_xprv)
|
||||
logging.debug("BIP32 context initialized successfully.")
|
||||
except Exception as e:
|
||||
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
|
||||
@@ -52,26 +59,34 @@ class BIP85:
|
||||
raise Bip85Error(f"Error initializing BIP32 context: {e}")
|
||||
|
||||
def derive_entropy(
|
||||
self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None
|
||||
self,
|
||||
index: int,
|
||||
entropy_bytes: int,
|
||||
app_no: int = 39,
|
||||
word_count: int | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Derives entropy using BIP-85 HMAC-SHA512 method.
|
||||
"""Derive entropy using the BIP-85 HMAC-SHA512 method.
|
||||
|
||||
Parameters:
|
||||
index (int): Index for the child entropy.
|
||||
bytes_len (int): Number of bytes to derive for the entropy.
|
||||
app_no (int): Application number (default 39 for BIP39)
|
||||
entropy_bytes (int): Number of bytes of entropy to derive.
|
||||
app_no (int): Application number (default 39 for BIP39).
|
||||
word_count (int | None): Number of words used in the derivation path
|
||||
for BIP39. If ``None`` and ``app_no`` is ``39``, ``word_count``
|
||||
defaults to ``entropy_bytes``. The final segment of the
|
||||
derivation path becomes ``m/83696968'/39'/0'/word_count'/index'``.
|
||||
|
||||
Returns:
|
||||
bytes: Derived entropy.
|
||||
bytes: Derived entropy of length ``entropy_bytes``.
|
||||
|
||||
Raises:
|
||||
SystemExit: If derivation fails or entropy length is invalid.
|
||||
SystemExit: If derivation fails or the derived entropy length is
|
||||
invalid.
|
||||
"""
|
||||
if app_no == 39:
|
||||
if words_len is None:
|
||||
words_len = bytes_len
|
||||
path = f"m/83696968'/{app_no}'/0'/{words_len}'/{index}'"
|
||||
if word_count is None:
|
||||
word_count = entropy_bytes
|
||||
path = f"m/83696968'/{app_no}'/0'/{word_count}'/{index}'"
|
||||
elif app_no == 32:
|
||||
path = f"m/83696968'/{app_no}'/{index}'"
|
||||
else:
|
||||
@@ -87,17 +102,17 @@ class BIP85:
|
||||
hmac_result = hmac.new(hmac_key, k, hashlib.sha512).digest()
|
||||
logging.debug(f"HMAC-SHA512 result: {hmac_result.hex()}")
|
||||
|
||||
entropy = hmac_result[:bytes_len]
|
||||
entropy = hmac_result[:entropy_bytes]
|
||||
|
||||
if len(entropy) != bytes_len:
|
||||
if len(entropy) != entropy_bytes:
|
||||
logging.error(
|
||||
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
|
||||
f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
|
||||
)
|
||||
print(
|
||||
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
|
||||
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
|
||||
)
|
||||
raise Bip85Error(
|
||||
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes."
|
||||
f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
|
||||
)
|
||||
|
||||
logging.debug(f"Derived entropy: {entropy.hex()}")
|
||||
@@ -108,14 +123,17 @@ class BIP85:
|
||||
raise Bip85Error(f"Error deriving entropy: {e}")
|
||||
|
||||
def derive_mnemonic(self, index: int, words_num: int) -> str:
|
||||
bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num)
|
||||
if not bytes_len:
|
||||
entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words_num)
|
||||
if not entropy_bytes:
|
||||
logging.error(f"Unsupported number of words: {words_num}")
|
||||
print(f"{Fore.RED}Error: Unsupported number of words: {words_num}")
|
||||
raise Bip85Error(f"Unsupported number of words: {words_num}")
|
||||
|
||||
entropy = self.derive_entropy(
|
||||
index=index, bytes_len=bytes_len, app_no=39, words_len=words_num
|
||||
index=index,
|
||||
entropy_bytes=entropy_bytes,
|
||||
app_no=39,
|
||||
word_count=words_num,
|
||||
)
|
||||
try:
|
||||
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(
|
||||
@@ -131,7 +149,7 @@ class BIP85:
|
||||
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
|
||||
"""Derive 32 bytes of entropy for symmetric key usage."""
|
||||
try:
|
||||
key = self.derive_entropy(index=index, bytes_len=32, app_no=app_no)
|
||||
key = self.derive_entropy(index=index, entropy_bytes=32, app_no=app_no)
|
||||
logging.debug(f"Derived symmetric key: {key.hex()}")
|
||||
return key
|
||||
except Exception as e:
|
||||
|
645
src/main.py
645
src/main.py
@@ -1,37 +1,68 @@
|
||||
# main.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Add bundled vendor directory to sys.path so bundled dependencies can be imported
|
||||
vendor_dir = Path(__file__).parent / "vendor"
|
||||
if vendor_dir.exists():
|
||||
sys.path.insert(0, str(vendor_dir))
|
||||
|
||||
import os
|
||||
import logging
|
||||
import signal
|
||||
import getpass
|
||||
import time
|
||||
import argparse
|
||||
import asyncio
|
||||
import gzip
|
||||
import tomli
|
||||
from tomli import TOMLDecodeError
|
||||
from colorama import init as colorama_init
|
||||
from termcolor import colored
|
||||
from utils.color_scheme import color_text
|
||||
import traceback
|
||||
import importlib
|
||||
|
||||
from password_manager.manager import PasswordManager
|
||||
from seedpass.core.manager import PasswordManager, restore_backup_index
|
||||
from nostr.client import NostrClient
|
||||
from password_manager.entry_types import EntryType
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from constants import INACTIVITY_TIMEOUT, initialize_app
|
||||
from utils.password_prompt import PasswordPromptError
|
||||
from utils.password_prompt import (
|
||||
PasswordPromptError,
|
||||
prompt_existing_password,
|
||||
prompt_new_password,
|
||||
)
|
||||
from utils import (
|
||||
timed_input,
|
||||
copy_to_clipboard,
|
||||
clear_screen,
|
||||
pause,
|
||||
clear_and_print_fingerprint,
|
||||
clear_header_with_notification,
|
||||
)
|
||||
from utils.clipboard import ClipboardUnavailableError
|
||||
from utils.atomic_write import atomic_write
|
||||
import queue
|
||||
from local_bip85.bip85 import Bip85Error
|
||||
|
||||
|
||||
colorama_init()
|
||||
|
||||
OPTIONAL_DEPENDENCIES = {
|
||||
"pyperclip": "clipboard support for secret mode",
|
||||
"qrcode": "QR code generation for TOTP setup",
|
||||
"toga": "desktop GUI features",
|
||||
}
|
||||
|
||||
|
||||
def _warn_missing_optional_dependencies() -> None:
|
||||
"""Log warnings for any optional packages that are not installed."""
|
||||
for module, feature in OPTIONAL_DEPENDENCIES.items():
|
||||
try:
|
||||
importlib.import_module(module)
|
||||
except ModuleNotFoundError:
|
||||
logging.warning(
|
||||
"Optional dependency '%s' is not installed; %s will be unavailable.",
|
||||
module,
|
||||
feature,
|
||||
)
|
||||
|
||||
|
||||
def load_global_config() -> dict:
|
||||
"""Load configuration from ~/.seedpass/config.toml if present."""
|
||||
@@ -41,7 +72,7 @@ def load_global_config() -> dict:
|
||||
try:
|
||||
with open(config_path, "rb") as f:
|
||||
return tomli.load(f)
|
||||
except Exception as exc:
|
||||
except (OSError, TOMLDecodeError) as exc:
|
||||
logging.warning(f"Failed to read {config_path}: {exc}")
|
||||
return {}
|
||||
|
||||
@@ -100,6 +131,37 @@ def confirm_action(prompt: str) -> bool:
|
||||
print(colored("Please enter 'Y' or 'N'.", "red"))
|
||||
|
||||
|
||||
def drain_notifications(pm: PasswordManager) -> str | None:
|
||||
"""Return the next queued notification message if available."""
|
||||
queue_obj = getattr(pm, "notifications", None)
|
||||
if queue_obj is None:
|
||||
return None
|
||||
try:
|
||||
note = queue_obj.get_nowait()
|
||||
except queue.Empty:
|
||||
return None
|
||||
category = getattr(note, "level", "info").lower()
|
||||
if category not in ("info", "warning", "error"):
|
||||
category = "info"
|
||||
return color_text(getattr(note, "message", ""), category)
|
||||
|
||||
|
||||
def get_notification_text(pm: PasswordManager) -> str:
|
||||
"""Return the current notification from ``pm`` as a colored string."""
|
||||
note = None
|
||||
if hasattr(pm, "get_current_notification"):
|
||||
try:
|
||||
note = pm.get_current_notification()
|
||||
except Exception:
|
||||
note = None
|
||||
if not note:
|
||||
return ""
|
||||
category = getattr(note, "level", "info").lower()
|
||||
if category not in ("info", "warning", "error"):
|
||||
category = "info"
|
||||
return color_text(getattr(note, "message", ""), category)
|
||||
|
||||
|
||||
def handle_switch_fingerprint(password_manager: PasswordManager):
|
||||
"""
|
||||
Handles switching the active fingerprint.
|
||||
@@ -119,7 +181,8 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
|
||||
|
||||
print(colored("Available Seed Profiles:", "cyan"))
|
||||
for idx, fp in enumerate(fingerprints, start=1):
|
||||
print(colored(f"{idx}. {fp}", "cyan"))
|
||||
label = password_manager.fingerprint_manager.display_name(fp)
|
||||
print(colored(f"{idx}. {label}", "cyan"))
|
||||
|
||||
choice = input("Select a seed profile by number to switch: ").strip()
|
||||
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
|
||||
@@ -127,6 +190,13 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
|
||||
return
|
||||
|
||||
selected_fingerprint = fingerprints[int(choice) - 1]
|
||||
if selected_fingerprint == password_manager.current_fingerprint:
|
||||
print(
|
||||
colored(
|
||||
f"Seed profile {selected_fingerprint} is already active.", "yellow"
|
||||
)
|
||||
)
|
||||
return
|
||||
if password_manager.select_fingerprint(selected_fingerprint):
|
||||
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
|
||||
else:
|
||||
@@ -150,11 +220,7 @@ def handle_add_new_fingerprint(password_manager: PasswordManager):
|
||||
|
||||
|
||||
def handle_remove_fingerprint(password_manager: PasswordManager):
|
||||
"""
|
||||
Handles removing an existing seed profile.
|
||||
|
||||
:param password_manager: An instance of PasswordManager.
|
||||
"""
|
||||
"""Handle removing an existing seed profile."""
|
||||
try:
|
||||
fingerprints = password_manager.fingerprint_manager.list_fingerprints()
|
||||
if not fingerprints:
|
||||
@@ -163,7 +229,8 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
|
||||
|
||||
print(colored("Available Seed Profiles:", "cyan"))
|
||||
for idx, fp in enumerate(fingerprints, start=1):
|
||||
print(colored(f"{idx}. {fp}", "cyan"))
|
||||
label = password_manager.fingerprint_manager.display_name(fp)
|
||||
print(colored(f"{idx}. {label}", "cyan"))
|
||||
|
||||
choice = input("Select a seed profile by number to remove: ").strip()
|
||||
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
|
||||
@@ -172,12 +239,24 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
|
||||
|
||||
selected_fingerprint = fingerprints[int(choice) - 1]
|
||||
confirm = confirm_action(
|
||||
f"Are you sure you want to remove seed profile {selected_fingerprint}? This will delete all associated data. (Y/N): "
|
||||
f"Are you sure you want to remove seed profile {selected_fingerprint}? This will delete all associated data. (Y/N):"
|
||||
)
|
||||
if confirm:
|
||||
|
||||
def _cleanup_and_exit() -> None:
|
||||
password_manager.current_fingerprint = None
|
||||
password_manager.is_dirty = False
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
print(colored("All seed profiles removed. Exiting.", "yellow"))
|
||||
sys.exit(0)
|
||||
|
||||
if password_manager.fingerprint_manager.remove_fingerprint(
|
||||
selected_fingerprint
|
||||
selected_fingerprint, _cleanup_and_exit
|
||||
):
|
||||
password_manager.current_fingerprint = (
|
||||
password_manager.fingerprint_manager.current_fingerprint
|
||||
)
|
||||
password_manager.is_dirty = False
|
||||
print(
|
||||
colored(
|
||||
f"Seed profile {selected_fingerprint} removed successfully.",
|
||||
@@ -207,7 +286,8 @@ def handle_list_fingerprints(password_manager: PasswordManager):
|
||||
|
||||
print(colored("Available Seed Profiles:", "cyan"))
|
||||
for fp in fingerprints:
|
||||
print(colored(f"- {fp}", "cyan"))
|
||||
label = password_manager.fingerprint_manager.display_name(fp)
|
||||
print(colored(f"- {label}", "cyan"))
|
||||
pause()
|
||||
except Exception as e:
|
||||
logging.error(f"Error listing seed profiles: {e}", exc_info=True)
|
||||
@@ -232,12 +312,95 @@ def handle_display_npub(password_manager: PasswordManager):
|
||||
print(colored(f"Error: Failed to display npub: {e}", "red"))
|
||||
|
||||
|
||||
def _display_live_stats(
|
||||
password_manager: PasswordManager, interval: float = 1.0
|
||||
) -> None:
|
||||
"""Continuously refresh stats until the user presses Enter.
|
||||
|
||||
Each refresh also triggers a background sync so the latest stats are
|
||||
displayed if newer data exists on Nostr.
|
||||
"""
|
||||
|
||||
stats_mgr = getattr(password_manager, "stats_manager", None)
|
||||
display_fn = getattr(password_manager, "display_stats", None)
|
||||
sync_fn = getattr(password_manager, "start_background_sync", None)
|
||||
if not callable(display_fn):
|
||||
return
|
||||
|
||||
if callable(sync_fn):
|
||||
try:
|
||||
sync_fn()
|
||||
except Exception as exc: # pragma: no cover - sync best effort
|
||||
logging.debug("Background sync failed during stats display: %s", exc)
|
||||
|
||||
if not sys.stdin or not sys.stdin.isatty():
|
||||
clear_screen()
|
||||
display_fn()
|
||||
note = get_notification_text(password_manager)
|
||||
if note:
|
||||
print(note)
|
||||
print(colored("Press Enter to continue.", "cyan"))
|
||||
pause()
|
||||
if stats_mgr is not None:
|
||||
stats_mgr.reset()
|
||||
return
|
||||
|
||||
# Flush any pending input so an accidental newline doesn't exit immediately
|
||||
try: # pragma: no cover - depends on platform
|
||||
import termios
|
||||
|
||||
termios.tcflush(sys.stdin, termios.TCIFLUSH)
|
||||
except Exception:
|
||||
try: # pragma: no cover - Windows fallback
|
||||
import msvcrt
|
||||
|
||||
while msvcrt.kbhit():
|
||||
msvcrt.getwch()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
# Break out immediately if the user has already pressed Enter
|
||||
try: # pragma: no cover - non-interactive environments
|
||||
import select
|
||||
|
||||
ready, _, _ = select.select([sys.stdin], [], [], 0)
|
||||
if ready:
|
||||
line = sys.stdin.readline().strip()
|
||||
if line == "" or line.lower() == "b":
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if callable(sync_fn):
|
||||
try:
|
||||
sync_fn()
|
||||
except Exception: # pragma: no cover - sync best effort
|
||||
logging.debug("Background sync failed during stats display")
|
||||
clear_screen()
|
||||
display_fn()
|
||||
note = get_notification_text(password_manager)
|
||||
if note:
|
||||
print(note)
|
||||
print(colored("Press Enter to continue.", "cyan"))
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
user_input = timed_input("", interval)
|
||||
if user_input.strip() == "" or user_input.strip().lower() == "b":
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
break
|
||||
if stats_mgr is not None:
|
||||
stats_mgr.reset()
|
||||
|
||||
|
||||
def handle_display_stats(password_manager: PasswordManager) -> None:
|
||||
"""Print seed profile statistics."""
|
||||
"""Print seed profile statistics with live updates."""
|
||||
try:
|
||||
display_fn = getattr(password_manager, "display_stats", None)
|
||||
if callable(display_fn):
|
||||
display_fn()
|
||||
_display_live_stats(password_manager)
|
||||
except Exception as e: # pragma: no cover - display best effort
|
||||
logging.error(f"Failed to display stats: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to display stats: {e}", "red"))
|
||||
@@ -245,31 +408,28 @@ def handle_display_stats(password_manager: PasswordManager) -> None:
|
||||
|
||||
def print_matches(
|
||||
password_manager: PasswordManager,
|
||||
matches: list[tuple[int, str, str | None, str | None, bool]],
|
||||
matches: list[tuple[int, str, str | None, str | None, bool, EntryType]],
|
||||
) -> None:
|
||||
"""Print a list of search matches."""
|
||||
print(colored("\n[+] Matches:\n", "green"))
|
||||
for entry in matches:
|
||||
idx, website, username, url, blacklisted = entry
|
||||
idx, website, username, url, blacklisted, etype = entry
|
||||
data = password_manager.entry_manager.retrieve_entry(idx)
|
||||
etype = (
|
||||
data.get("type", data.get("kind", EntryType.PASSWORD.value))
|
||||
if data
|
||||
else EntryType.PASSWORD.value
|
||||
)
|
||||
print(color_text(f"Index: {idx}", "index"))
|
||||
if etype == EntryType.TOTP.value:
|
||||
print(color_text(f" Label: {data.get('label', website)}", "index"))
|
||||
print(color_text(f" Derivation Index: {data.get('index', idx)}", "index"))
|
||||
elif etype == EntryType.SEED.value:
|
||||
if etype == EntryType.TOTP:
|
||||
label = data.get("label", website) if data else website
|
||||
deriv = data.get("index", idx) if data else idx
|
||||
print(color_text(f" Label: {label}", "index"))
|
||||
print(color_text(f" Derivation Index: {deriv}", "index"))
|
||||
elif etype == EntryType.SEED:
|
||||
print(color_text(" Type: Seed Phrase", "index"))
|
||||
elif etype == EntryType.SSH.value:
|
||||
elif etype == EntryType.SSH:
|
||||
print(color_text(" Type: SSH Key", "index"))
|
||||
elif etype == EntryType.PGP.value:
|
||||
elif etype == EntryType.PGP:
|
||||
print(color_text(" Type: PGP Key", "index"))
|
||||
elif etype == EntryType.NOSTR.value:
|
||||
elif etype == EntryType.NOSTR:
|
||||
print(color_text(" Type: Nostr Key", "index"))
|
||||
elif etype == EntryType.KEY_VALUE.value:
|
||||
elif etype == EntryType.KEY_VALUE:
|
||||
print(color_text(" Type: Key/Value", "index"))
|
||||
else:
|
||||
if website:
|
||||
@@ -289,14 +449,15 @@ def handle_post_to_nostr(
|
||||
Handles the action of posting the encrypted password index to Nostr.
|
||||
"""
|
||||
try:
|
||||
event_id = password_manager.sync_vault(alt_summary=alt_summary)
|
||||
if event_id:
|
||||
print(
|
||||
colored(
|
||||
f"\N{WHITE HEAVY CHECK MARK} Sync complete. Event ID: {event_id}",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
result = password_manager.sync_vault(alt_summary=alt_summary)
|
||||
if result:
|
||||
print(colored("\N{WHITE HEAVY CHECK MARK} Sync complete.", "green"))
|
||||
print("Event IDs:")
|
||||
print(f" manifest: {result['manifest_id']}")
|
||||
for cid in result["chunk_ids"]:
|
||||
print(f" chunk: {cid}")
|
||||
for did in result["delta_ids"]:
|
||||
print(f" delta: {did}")
|
||||
logging.info("Encrypted index posted to Nostr successfully.")
|
||||
else:
|
||||
print(colored("\N{CROSS MARK} Sync failed…", "red"))
|
||||
@@ -309,32 +470,36 @@ def handle_post_to_nostr(
|
||||
|
||||
|
||||
def handle_retrieve_from_nostr(password_manager: PasswordManager):
|
||||
"""
|
||||
Handles the action of retrieving the encrypted password index from Nostr.
|
||||
"""
|
||||
"""Retrieve the encrypted password index from Nostr."""
|
||||
try:
|
||||
result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
|
||||
if result:
|
||||
manifest, chunks = result
|
||||
encrypted = gzip.decompress(b"".join(chunks))
|
||||
if manifest.delta_since:
|
||||
try:
|
||||
version = int(manifest.delta_since)
|
||||
deltas = asyncio.run(
|
||||
password_manager.nostr_client.fetch_deltas_since(version)
|
||||
)
|
||||
if deltas:
|
||||
encrypted = deltas[-1]
|
||||
except ValueError:
|
||||
pass
|
||||
password_manager.encryption_manager.decrypt_and_save_index_from_nostr(
|
||||
encrypted
|
||||
password_manager.sync_index_from_nostr()
|
||||
if password_manager.nostr_client.last_error:
|
||||
msg = (
|
||||
f"No Nostr events found for fingerprint"
|
||||
f" {password_manager.current_fingerprint}."
|
||||
if "Snapshot not found" in password_manager.nostr_client.last_error
|
||||
else password_manager.nostr_client.last_error
|
||||
)
|
||||
print(colored("Encrypted index retrieved and saved successfully.", "green"))
|
||||
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
|
||||
print(colored(msg, "red"))
|
||||
logging.error(msg)
|
||||
else:
|
||||
print(colored("Failed to retrieve data from Nostr.", "red"))
|
||||
logging.error("Failed to retrieve data from Nostr.")
|
||||
try:
|
||||
legacy_pub = (
|
||||
password_manager.nostr_client.key_manager.generate_legacy_nostr_keys().public_key_hex()
|
||||
)
|
||||
if password_manager.nostr_client.keys.public_key_hex() == legacy_pub:
|
||||
note = "Restored index from legacy Nostr backup."
|
||||
print(colored(note, "yellow"))
|
||||
logging.info(note)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
colored(
|
||||
"Encrypted index retrieved and saved successfully.",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
|
||||
@@ -359,10 +524,21 @@ def handle_view_relays(cfg_mgr: "ConfigManager") -> None:
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
|
||||
|
||||
def _safe_close_client_pool(pm: PasswordManager) -> None:
|
||||
"""Close the Nostr client pool if the client exists."""
|
||||
client = getattr(pm, "nostr_client", None)
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.close_client_pool()
|
||||
except Exception as exc:
|
||||
logging.error(f"Error during NostrClient shutdown: {exc}")
|
||||
|
||||
|
||||
def _reload_relays(password_manager: PasswordManager, relays: list) -> None:
|
||||
"""Reload NostrClient with the updated relay list."""
|
||||
try:
|
||||
password_manager.nostr_client.close_client_pool()
|
||||
_safe_close_client_pool(password_manager)
|
||||
except Exception as exc:
|
||||
logging.warning(f"Failed to close client pool: {exc}")
|
||||
try:
|
||||
@@ -493,6 +669,39 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
|
||||
|
||||
def handle_set_kdf_iterations(password_manager: PasswordManager) -> None:
|
||||
"""Change the PBKDF2 iteration count."""
|
||||
cfg_mgr = password_manager.config_manager
|
||||
if cfg_mgr is None:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
try:
|
||||
current = cfg_mgr.get_kdf_iterations()
|
||||
print(colored(f"Current iterations: {current}", "cyan"))
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading iterations: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
return
|
||||
value = input("Enter new iteration count: ").strip()
|
||||
if not value:
|
||||
print(colored("No iteration count entered.", "yellow"))
|
||||
return
|
||||
try:
|
||||
iterations = int(value)
|
||||
if iterations <= 0:
|
||||
print(colored("Iterations must be positive.", "red"))
|
||||
return
|
||||
except ValueError:
|
||||
print(colored("Invalid number.", "red"))
|
||||
return
|
||||
try:
|
||||
cfg_mgr.set_kdf_iterations(iterations)
|
||||
print(colored("KDF iteration count updated.", "green"))
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving iterations: {e}")
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
|
||||
|
||||
def handle_set_additional_backup_location(pm: PasswordManager) -> None:
|
||||
"""Configure an optional second backup directory."""
|
||||
cfg_mgr = pm.config_manager
|
||||
@@ -526,8 +735,7 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
|
||||
path = Path(value).expanduser()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
test_file = path / ".seedpass_write_test"
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test")
|
||||
atomic_write(test_file, lambda f: f.write("test"))
|
||||
test_file.unlink()
|
||||
except Exception as e:
|
||||
print(colored(f"Path not writable: {e}", "red"))
|
||||
@@ -543,12 +751,41 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
|
||||
|
||||
def handle_set_profile_name(pm: PasswordManager) -> None:
|
||||
"""Set or clear the custom name for the current seed profile."""
|
||||
fp = getattr(pm.fingerprint_manager, "current_fingerprint", None)
|
||||
if not fp:
|
||||
print(colored("No seed profile selected.", "red"))
|
||||
return
|
||||
current = pm.fingerprint_manager.get_name(fp)
|
||||
if current:
|
||||
print(colored(f"Current name: {current}", "cyan"))
|
||||
else:
|
||||
print(colored("No custom name set.", "cyan"))
|
||||
value = input("Enter new name (leave blank to remove): ").strip()
|
||||
if pm.fingerprint_manager.set_name(fp, value or None):
|
||||
if value:
|
||||
print(colored("Name updated.", "green"))
|
||||
else:
|
||||
print(colored("Name removed.", "green"))
|
||||
|
||||
|
||||
def handle_toggle_secret_mode(pm: PasswordManager) -> None:
|
||||
"""Toggle secret mode and adjust clipboard delay."""
|
||||
cfg = pm.config_manager
|
||||
if cfg is None:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
vault = getattr(pm, "vault", None)
|
||||
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
|
||||
if vault is not None and fingerprint_dir is not None:
|
||||
try:
|
||||
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to initialize ConfigManager: {exc}")
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
else:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
try:
|
||||
enabled = cfg.get_secret_mode_enabled()
|
||||
delay = cfg.get_clipboard_clear_delay()
|
||||
@@ -584,6 +821,81 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None:
|
||||
print(colored(f"Error: {exc}", "red"))
|
||||
|
||||
|
||||
def handle_toggle_quick_unlock(pm: PasswordManager) -> None:
|
||||
"""Enable or disable Quick Unlock."""
|
||||
cfg = pm.config_manager
|
||||
if cfg is None:
|
||||
vault = getattr(pm, "vault", None)
|
||||
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
|
||||
if vault is not None and fingerprint_dir is not None:
|
||||
try:
|
||||
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to initialize ConfigManager: {exc}")
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
else:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
try:
|
||||
enabled = cfg.get_quick_unlock()
|
||||
except Exception as exc:
|
||||
logging.error(f"Error loading quick unlock setting: {exc}")
|
||||
print(colored(f"Error loading settings: {exc}", "red"))
|
||||
return
|
||||
print(colored(f"Quick Unlock is currently {'ON' if enabled else 'OFF'}", "cyan"))
|
||||
choice = input("Enable Quick Unlock? (y/n, blank to keep): ").strip().lower()
|
||||
if choice in ("y", "yes"):
|
||||
enabled = True
|
||||
elif choice in ("n", "no"):
|
||||
enabled = False
|
||||
try:
|
||||
cfg.set_quick_unlock(enabled)
|
||||
status = "enabled" if enabled else "disabled"
|
||||
print(colored(f"Quick Unlock {status}.", "green"))
|
||||
except Exception as exc:
|
||||
logging.error(f"Error saving quick unlock: {exc}")
|
||||
print(colored(f"Error: {exc}", "red"))
|
||||
|
||||
|
||||
def handle_toggle_offline_mode(pm: PasswordManager) -> None:
|
||||
"""Enable or disable offline mode."""
|
||||
cfg = pm.config_manager
|
||||
if cfg is None:
|
||||
vault = getattr(pm, "vault", None)
|
||||
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
|
||||
if vault is not None and fingerprint_dir is not None:
|
||||
try:
|
||||
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
|
||||
except Exception as exc:
|
||||
logging.error(f"Failed to initialize ConfigManager: {exc}")
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
else:
|
||||
print(colored("Configuration manager unavailable.", "red"))
|
||||
return
|
||||
try:
|
||||
enabled = cfg.get_offline_mode()
|
||||
except Exception as exc:
|
||||
logging.error(f"Error loading offline mode setting: {exc}")
|
||||
print(colored(f"Error loading settings: {exc}", "red"))
|
||||
return
|
||||
print(colored(f"Offline mode is currently {'ON' if enabled else 'OFF'}", "cyan"))
|
||||
choice = input("Enable offline mode? (y/n, blank to keep): ").strip().lower()
|
||||
if choice in ("y", "yes"):
|
||||
enabled = True
|
||||
elif choice in ("n", "no"):
|
||||
enabled = False
|
||||
try:
|
||||
cfg.set_offline_mode(enabled)
|
||||
pm.offline_mode = enabled
|
||||
status = "enabled" if enabled else "disabled"
|
||||
print(colored(f"Offline mode {status}.", "green"))
|
||||
except Exception as exc:
|
||||
logging.error(f"Error saving offline mode: {exc}")
|
||||
print(colored(f"Error: {exc}", "red"))
|
||||
|
||||
|
||||
def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
"""Submenu for managing seed profiles."""
|
||||
while True:
|
||||
@@ -592,7 +904,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
"header_fingerprint_args",
|
||||
(getattr(password_manager, "current_fingerprint", None), None, None),
|
||||
)
|
||||
clear_and_print_fingerprint(
|
||||
clear_header_with_notification(
|
||||
fp,
|
||||
"Main Menu > Settings > Profiles",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -603,6 +915,7 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
print(color_text("2. Add a New Seed Profile", "menu"))
|
||||
print(color_text("3. Remove an Existing Seed Profile", "menu"))
|
||||
print(color_text("4. List All Seed Profiles", "menu"))
|
||||
print(color_text("5. Set Seed Profile Name", "menu"))
|
||||
choice = input("Select an option or press Enter to go back: ").strip()
|
||||
password_manager.update_activity()
|
||||
if choice == "1":
|
||||
@@ -614,6 +927,8 @@ def handle_profiles_menu(password_manager: PasswordManager) -> None:
|
||||
handle_remove_fingerprint(password_manager)
|
||||
elif choice == "4":
|
||||
handle_list_fingerprints(password_manager)
|
||||
elif choice == "5":
|
||||
handle_set_profile_name(password_manager)
|
||||
elif not choice:
|
||||
break
|
||||
else:
|
||||
@@ -638,7 +953,7 @@ def handle_nostr_menu(password_manager: PasswordManager) -> None:
|
||||
"header_fingerprint_args",
|
||||
(getattr(password_manager, "current_fingerprint", None), None, None),
|
||||
)
|
||||
clear_and_print_fingerprint(
|
||||
clear_header_with_notification(
|
||||
fp,
|
||||
"Main Menu > Settings > Nostr",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -682,7 +997,7 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
"header_fingerprint_args",
|
||||
(getattr(password_manager, "current_fingerprint", None), None, None),
|
||||
)
|
||||
clear_and_print_fingerprint(
|
||||
clear_header_with_notification(
|
||||
fp,
|
||||
"Main Menu > Settings",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -699,17 +1014,29 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
print(color_text("8. Import database", "menu"))
|
||||
print(color_text("9. Export 2FA codes", "menu"))
|
||||
print(color_text("10. Set additional backup location", "menu"))
|
||||
print(color_text("11. Set inactivity timeout", "menu"))
|
||||
print(color_text("12. Lock Vault", "menu"))
|
||||
print(color_text("13. Stats", "menu"))
|
||||
print(color_text("14. Toggle Secret Mode", "menu"))
|
||||
print(color_text("11. Set KDF iterations", "menu"))
|
||||
print(color_text("12. Set inactivity timeout", "menu"))
|
||||
print(color_text("13. Lock Vault", "menu"))
|
||||
print(color_text("14. Stats", "menu"))
|
||||
print(color_text("15. Toggle Secret Mode", "menu"))
|
||||
print(color_text("16. Toggle Offline Mode", "menu"))
|
||||
print(color_text("17. Toggle Quick Unlock", "menu"))
|
||||
choice = input("Select an option or press Enter to go back: ").strip()
|
||||
if choice == "1":
|
||||
handle_profiles_menu(password_manager)
|
||||
elif choice == "2":
|
||||
handle_nostr_menu(password_manager)
|
||||
elif choice == "3":
|
||||
password_manager.change_password()
|
||||
try:
|
||||
old_pw = prompt_existing_password("Enter your current password: ")
|
||||
new_pw = prompt_new_password()
|
||||
password_manager.change_password(old_pw, new_pw)
|
||||
except ValueError:
|
||||
print(colored("Incorrect password.", "red"))
|
||||
except PasswordPromptError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
pause()
|
||||
elif choice == "4":
|
||||
password_manager.handle_verify_checksum()
|
||||
@@ -735,19 +1062,29 @@ def handle_settings(password_manager: PasswordManager) -> None:
|
||||
handle_set_additional_backup_location(password_manager)
|
||||
pause()
|
||||
elif choice == "11":
|
||||
handle_set_inactivity_timeout(password_manager)
|
||||
handle_set_kdf_iterations(password_manager)
|
||||
pause()
|
||||
elif choice == "12":
|
||||
handle_set_inactivity_timeout(password_manager)
|
||||
pause()
|
||||
elif choice == "13":
|
||||
password_manager.lock_vault()
|
||||
print(colored("Vault locked. Please re-enter your password.", "yellow"))
|
||||
password_manager.unlock_vault()
|
||||
pause()
|
||||
elif choice == "13":
|
||||
handle_display_stats(password_manager)
|
||||
password_manager.start_background_sync()
|
||||
getattr(password_manager, "start_background_relay_check", lambda: None)()
|
||||
pause()
|
||||
elif choice == "14":
|
||||
handle_display_stats(password_manager)
|
||||
elif choice == "15":
|
||||
handle_toggle_secret_mode(password_manager)
|
||||
pause()
|
||||
elif choice == "16":
|
||||
handle_toggle_offline_mode(password_manager)
|
||||
pause()
|
||||
elif choice == "17":
|
||||
handle_toggle_quick_unlock(password_manager)
|
||||
pause()
|
||||
elif not choice:
|
||||
break
|
||||
else:
|
||||
@@ -773,17 +1110,18 @@ def display_menu(
|
||||
7. Settings
|
||||
8. List Archived
|
||||
"""
|
||||
display_fn = getattr(password_manager, "display_stats", None)
|
||||
if callable(display_fn):
|
||||
display_fn()
|
||||
pause()
|
||||
password_manager.start_background_sync()
|
||||
getattr(password_manager, "start_background_relay_check", lambda: None)()
|
||||
_display_live_stats(password_manager)
|
||||
while True:
|
||||
getattr(password_manager, "poll_background_errors", lambda: None)()
|
||||
fp, parent_fp, child_fp = getattr(
|
||||
password_manager,
|
||||
"header_fingerprint_args",
|
||||
(getattr(password_manager, "current_fingerprint", None), None, None),
|
||||
)
|
||||
clear_and_print_fingerprint(
|
||||
clear_header_with_notification(
|
||||
password_manager,
|
||||
fp,
|
||||
"Main Menu",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -793,13 +1131,19 @@ def display_menu(
|
||||
print(colored("Session timed out. Vault locked.", "yellow"))
|
||||
password_manager.lock_vault()
|
||||
password_manager.unlock_vault()
|
||||
password_manager.start_background_sync()
|
||||
getattr(password_manager, "start_background_relay_check", lambda: None)()
|
||||
continue
|
||||
# Periodically push updates to Nostr
|
||||
if (
|
||||
password_manager.is_dirty
|
||||
and time.time() - password_manager.last_update >= sync_interval
|
||||
):
|
||||
handle_post_to_nostr(password_manager)
|
||||
current_fp = getattr(password_manager, "current_fingerprint", None)
|
||||
if current_fp:
|
||||
if (
|
||||
password_manager.is_dirty
|
||||
and time.time() - password_manager.last_update >= sync_interval
|
||||
):
|
||||
handle_post_to_nostr(password_manager)
|
||||
password_manager.is_dirty = False
|
||||
else:
|
||||
password_manager.is_dirty = False
|
||||
|
||||
# Flush logging handlers
|
||||
@@ -815,6 +1159,8 @@ def display_menu(
|
||||
print(colored("Session timed out. Vault locked.", "yellow"))
|
||||
password_manager.lock_vault()
|
||||
password_manager.unlock_vault()
|
||||
password_manager.start_background_sync()
|
||||
getattr(password_manager, "start_background_relay_check", lambda: None)()
|
||||
continue
|
||||
password_manager.update_activity()
|
||||
if not choice:
|
||||
@@ -823,7 +1169,8 @@ def display_menu(
|
||||
continue
|
||||
logging.info("Exiting the program.")
|
||||
print(colored("Exiting the program.", "green"))
|
||||
password_manager.nostr_client.close_client_pool()
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
_safe_close_client_pool(password_manager)
|
||||
sys.exit(0)
|
||||
if choice == "1":
|
||||
while True:
|
||||
@@ -836,7 +1183,7 @@ def display_menu(
|
||||
None,
|
||||
),
|
||||
)
|
||||
clear_and_print_fingerprint(
|
||||
clear_header_with_notification(
|
||||
fp,
|
||||
"Main Menu > Add Entry",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -891,7 +1238,7 @@ def display_menu(
|
||||
"header_fingerprint_args",
|
||||
(getattr(password_manager, "current_fingerprint", None), None, None),
|
||||
)
|
||||
clear_and_print_fingerprint(
|
||||
clear_header_with_notification(
|
||||
fp,
|
||||
"Main Menu",
|
||||
parent_fingerprint=parent_fp,
|
||||
@@ -919,15 +1266,40 @@ def display_menu(
|
||||
print(colored("Invalid choice. Please select a valid option.", "red"))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Entry point for the SeedPass CLI."""
|
||||
def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> int:
|
||||
"""Entry point for the SeedPass CLI.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
argv:
|
||||
Command line arguments.
|
||||
fingerprint:
|
||||
Optional seed profile fingerprint to select automatically.
|
||||
"""
|
||||
configure_logging()
|
||||
_warn_missing_optional_dependencies()
|
||||
initialize_app()
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Starting SeedPass Password Manager")
|
||||
|
||||
load_global_config()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--fingerprint")
|
||||
parser.add_argument(
|
||||
"--restore-backup",
|
||||
help="Restore index from backup file before starting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-clipboard",
|
||||
action="store_true",
|
||||
help="Disable clipboard support and print secrets",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-prompt-attempts",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of password/seed prompt attempts (0 to disable)",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
exp = sub.add_parser("export")
|
||||
@@ -947,8 +1319,46 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.restore_backup:
|
||||
fp_target = args.fingerprint or fingerprint
|
||||
if fp_target is None:
|
||||
print(
|
||||
colored(
|
||||
"Error: --fingerprint is required when using --restore-backup.",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
return 1
|
||||
try:
|
||||
restore_backup_index(Path(args.restore_backup), fp_target)
|
||||
logger.info("Restored backup from %s", args.restore_backup)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore backup: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to restore backup: {e}", "red"))
|
||||
return 1
|
||||
elif args.command is None:
|
||||
print("Startup Options:")
|
||||
print("1. Continue")
|
||||
print("2. Restore from backup")
|
||||
choice = input("Select an option: ").strip()
|
||||
if choice == "2":
|
||||
path = input("Enter backup file path: ").strip()
|
||||
fp_target = args.fingerprint or fingerprint
|
||||
if fp_target is None:
|
||||
fp_target = input("Enter fingerprint for restore: ").strip()
|
||||
try:
|
||||
restore_backup_index(Path(path), fp_target)
|
||||
logger.info("Restored backup from %s", path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore backup: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to restore backup: {e}", "red"))
|
||||
return 1
|
||||
|
||||
if args.max_prompt_attempts is not None:
|
||||
os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts)
|
||||
|
||||
try:
|
||||
password_manager = PasswordManager()
|
||||
password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint)
|
||||
logger.info("PasswordManager initialized successfully.")
|
||||
except (PasswordPromptError, Bip85Error) as e:
|
||||
logger.error(f"Failed to initialize PasswordManager: {e}", exc_info=True)
|
||||
@@ -959,6 +1369,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
|
||||
return 1
|
||||
|
||||
if args.no_clipboard:
|
||||
password_manager.secret_mode_enabled = False
|
||||
|
||||
if args.command == "export":
|
||||
password_manager.handle_export_database(Path(args.file))
|
||||
return 0
|
||||
@@ -1007,17 +1420,24 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
print(code)
|
||||
try:
|
||||
copy_to_clipboard(code, password_manager.clipboard_clear_delay)
|
||||
print(colored("Code copied to clipboard", "green"))
|
||||
except Exception as exc:
|
||||
logging.warning(f"Clipboard copy failed: {exc}")
|
||||
if copy_to_clipboard(code, password_manager.clipboard_clear_delay):
|
||||
print(colored("Code copied to clipboard", "green"))
|
||||
except ClipboardUnavailableError as exc:
|
||||
print(
|
||||
colored(
|
||||
f"Clipboard unavailable: {exc}\n"
|
||||
"Re-run with '--no-clipboard' to print codes instead.",
|
||||
"yellow",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
def signal_handler(sig, _frame):
|
||||
print(colored("\nReceived shutdown signal. Exiting gracefully...", "yellow"))
|
||||
logging.info(f"Received shutdown signal: {sig}. Initiating graceful shutdown.")
|
||||
try:
|
||||
password_manager.nostr_client.close_client_pool()
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
_safe_close_client_pool(password_manager)
|
||||
logging.info("NostrClient closed successfully.")
|
||||
except Exception as exc:
|
||||
logging.error(f"Error during shutdown: {exc}")
|
||||
@@ -1035,7 +1455,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
logger.info("Program terminated by user via KeyboardInterrupt.")
|
||||
print(colored("\nProgram terminated by user.", "yellow"))
|
||||
try:
|
||||
password_manager.nostr_client.close_client_pool()
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
_safe_close_client_pool(password_manager)
|
||||
logging.info("NostrClient closed successfully.")
|
||||
except Exception as exc:
|
||||
logging.error(f"Error during shutdown: {exc}")
|
||||
@@ -1045,7 +1466,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
logger.error(f"A user-related error occurred: {e}", exc_info=True)
|
||||
print(colored(f"Error: {e}", "red"))
|
||||
try:
|
||||
password_manager.nostr_client.close_client_pool()
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
_safe_close_client_pool(password_manager)
|
||||
logging.info("NostrClient closed successfully.")
|
||||
except Exception as exc:
|
||||
logging.error(f"Error during shutdown: {exc}")
|
||||
@@ -1055,7 +1477,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
||||
print(colored(f"Error: An unexpected error occurred: {e}", "red"))
|
||||
try:
|
||||
password_manager.nostr_client.close_client_pool()
|
||||
getattr(password_manager, "cleanup", lambda: None)()
|
||||
_safe_close_client_pool(password_manager)
|
||||
logging.info("NostrClient closed successfully.")
|
||||
except Exception as exc:
|
||||
logging.error(f"Error during shutdown: {exc}")
|
||||
|
@@ -14,6 +14,7 @@ class ChunkMeta:
|
||||
id: str
|
||||
size: int
|
||||
hash: str
|
||||
event_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -23,4 +24,4 @@ class Manifest:
|
||||
ver: int
|
||||
algo: str
|
||||
chunks: List[ChunkMeta]
|
||||
delta_since: Optional[str] = None
|
||||
delta_since: Optional[int] = None
|
||||
|
@@ -1,33 +1,42 @@
|
||||
# src/nostr/client.py
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
import hashlib
|
||||
import asyncio
|
||||
import gzip
|
||||
import websockets
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
# Imports from the nostr-sdk library
|
||||
import websockets
|
||||
from nostr_sdk import (
|
||||
Client,
|
||||
Keys,
|
||||
NostrSigner,
|
||||
EventBuilder,
|
||||
Filter,
|
||||
Kind,
|
||||
KindStandard,
|
||||
NostrSigner,
|
||||
Tag,
|
||||
RelayUrl,
|
||||
PublicKey,
|
||||
)
|
||||
from datetime import timedelta
|
||||
from nostr_sdk import EventId, Timestamp
|
||||
from nostr_sdk import EventId, Keys, Timestamp
|
||||
|
||||
from constants import MAX_RETRIES, RETRY_DELAY
|
||||
from seedpass.core.encryption import EncryptionManager
|
||||
|
||||
from .backup_models import (
|
||||
ChunkMeta,
|
||||
KIND_DELTA,
|
||||
KIND_MANIFEST,
|
||||
KIND_SNAPSHOT_CHUNK,
|
||||
Manifest,
|
||||
)
|
||||
from .connection import ConnectionHandler, DEFAULT_RELAYS
|
||||
from .key_manager import KeyManager as SeedPassKeyManager
|
||||
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from utils.file_lock import exclusive_lock
|
||||
from .snapshot import MANIFEST_ID_PREFIX, SnapshotHandler, prepare_snapshot
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - imported for type hints
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
|
||||
# Backwards compatibility for tests that patch these symbols
|
||||
KeyManager = SeedPassKeyManager
|
||||
@@ -36,52 +45,8 @@ ClientBuilder = Client
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://relay.primal.net",
|
||||
]
|
||||
|
||||
|
||||
def prepare_snapshot(
|
||||
encrypted_bytes: bytes, limit: int
|
||||
) -> Tuple[Manifest, list[bytes]]:
|
||||
"""Compress and split the encrypted vault into chunks.
|
||||
|
||||
Each chunk is hashed with SHA-256 and described in the returned
|
||||
:class:`Manifest`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
encrypted_bytes : bytes
|
||||
The encrypted vault contents.
|
||||
limit : int
|
||||
Maximum chunk size in bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Tuple[Manifest, list[bytes]]
|
||||
The manifest describing all chunks and the list of chunk bytes.
|
||||
"""
|
||||
|
||||
compressed = gzip.compress(encrypted_bytes)
|
||||
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
|
||||
|
||||
metas: list[ChunkMeta] = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
metas.append(
|
||||
ChunkMeta(
|
||||
id=f"seedpass-chunk-{i:04d}",
|
||||
size=len(chunk),
|
||||
hash=hashlib.sha256(chunk).hexdigest(),
|
||||
)
|
||||
)
|
||||
|
||||
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
|
||||
return manifest, chunks
|
||||
|
||||
|
||||
class NostrClient:
|
||||
class NostrClient(ConnectionHandler, SnapshotHandler):
|
||||
"""Interact with the Nostr network using nostr-sdk."""
|
||||
|
||||
def __init__(
|
||||
@@ -90,10 +55,14 @@ class NostrClient:
|
||||
fingerprint: str,
|
||||
relays: Optional[List[str]] = None,
|
||||
parent_seed: Optional[str] = None,
|
||||
offline_mode: bool = False,
|
||||
config_manager: Optional["ConfigManager"] = None,
|
||||
) -> None:
|
||||
self.encryption_manager = encryption_manager
|
||||
self.fingerprint = fingerprint
|
||||
self.fingerprint_dir = self.encryption_manager.fingerprint_dir
|
||||
self.config_manager = config_manager
|
||||
self.verbose_timing = False
|
||||
|
||||
if parent_seed is None:
|
||||
parent_seed = self.encryption_manager.decrypt_parent_seed()
|
||||
@@ -110,305 +79,37 @@ class NostrClient:
|
||||
except Exception:
|
||||
self.keys = Keys.generate()
|
||||
|
||||
self.relays = relays if relays else DEFAULT_RELAYS
|
||||
self.offline_mode = offline_mode
|
||||
if relays is None:
|
||||
self.relays = [] if offline_mode else DEFAULT_RELAYS
|
||||
else:
|
||||
self.relays = relays
|
||||
|
||||
if self.config_manager is not None:
|
||||
try:
|
||||
self.verbose_timing = self.config_manager.get_verbose_timing()
|
||||
except Exception:
|
||||
self.verbose_timing = False
|
||||
|
||||
# store the last error encountered during network operations
|
||||
self.last_error: Optional[str] = None
|
||||
|
||||
self.delta_threshold = 100
|
||||
self._state_lock = threading.Lock()
|
||||
self.current_manifest: Manifest | None = None
|
||||
self.current_manifest_id: str | None = None
|
||||
self._delta_events: list[str] = []
|
||||
|
||||
# Configure and initialize the nostr-sdk Client
|
||||
signer = NostrSigner.keys(self.keys)
|
||||
self.client = Client(signer)
|
||||
|
||||
self.initialize_client_pool()
|
||||
self._connected = False
|
||||
|
||||
def initialize_client_pool(self) -> None:
|
||||
"""Add relays to the client and connect."""
|
||||
asyncio.run(self._initialize_client_pool())
|
||||
|
||||
async def _initialize_client_pool(self) -> None:
|
||||
if hasattr(self.client, "add_relays"):
|
||||
await self.client.add_relays(self.relays)
|
||||
else:
|
||||
for relay in self.relays:
|
||||
await self.client.add_relay(relay)
|
||||
await self.client.connect()
|
||||
logger.info(f"NostrClient connected to relays: {self.relays}")
|
||||
|
||||
async def _ping_relay(self, relay: str, timeout: float) -> bool:
|
||||
"""Attempt to retrieve the latest event from a single relay."""
|
||||
sub_id = "seedpass-health"
|
||||
pubkey = self.keys.public_key().to_hex()
|
||||
req = json.dumps(
|
||||
["REQ", sub_id, {"kinds": [1], "authors": [pubkey], "limit": 1}]
|
||||
)
|
||||
try:
|
||||
async with websockets.connect(
|
||||
relay, open_timeout=timeout, close_timeout=timeout
|
||||
) as ws:
|
||||
await ws.send(req)
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||
data = json.loads(msg)
|
||||
if data[0] in {"EVENT", "EOSE"}:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
|
||||
tasks = [self._ping_relay(r, timeout) for r in self.relays]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
healthy = sum(1 for r in results if r is True)
|
||||
if healthy < min_relays:
|
||||
logger.warning(
|
||||
"Only %s relays responded with data; consider adding more.", healthy
|
||||
)
|
||||
return healthy
|
||||
|
||||
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
|
||||
"""Ping relays and return the count of those providing data."""
|
||||
return asyncio.run(self._check_relay_health(min_relays, timeout))
|
||||
|
||||
def publish_json_to_nostr(
|
||||
self,
|
||||
encrypted_json: bytes,
|
||||
to_pubkey: str | None = None,
|
||||
alt_summary: str | None = None,
|
||||
) -> str | None:
|
||||
"""Builds and publishes a Kind 1 text note or direct message.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
encrypted_json : bytes
|
||||
The encrypted index data to publish.
|
||||
to_pubkey : str | None, optional
|
||||
If provided, send as a direct message to this public key.
|
||||
alt_summary : str | None, optional
|
||||
If provided, include an ``alt`` tag so uploads can be
|
||||
associated with a specific event like a password change.
|
||||
"""
|
||||
self.last_error = None
|
||||
try:
|
||||
content = base64.b64encode(encrypted_json).decode("utf-8")
|
||||
|
||||
if to_pubkey:
|
||||
receiver = PublicKey.parse(to_pubkey)
|
||||
event_output = self.client.send_private_msg_to(
|
||||
self.relays, receiver, content
|
||||
)
|
||||
else:
|
||||
builder = EventBuilder.text_note(content)
|
||||
if alt_summary:
|
||||
builder = builder.tags([Tag.alt(alt_summary)])
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
event_output = self.publish_event(event)
|
||||
|
||||
event_id_hex = (
|
||||
event_output.id.to_hex()
|
||||
if hasattr(event_output, "id")
|
||||
else str(event_output)
|
||||
)
|
||||
logger.info(f"Successfully published event with ID: {event_id_hex}")
|
||||
return event_id_hex
|
||||
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
logger.error(f"Failed to publish JSON to Nostr: {e}")
|
||||
return None
|
||||
|
||||
def publish_event(self, event):
|
||||
"""Publish a prepared event to the configured relays."""
|
||||
return asyncio.run(self._publish_event(event))
|
||||
|
||||
async def _publish_event(self, event):
|
||||
return await self.client.send_event(event)
|
||||
|
||||
def update_relays(self, new_relays: List[str]) -> None:
|
||||
"""Reconnect the client using a new set of relays."""
|
||||
self.close_client_pool()
|
||||
self.relays = new_relays
|
||||
signer = NostrSigner.keys(self.keys)
|
||||
self.client = Client(signer)
|
||||
self.initialize_client_pool()
|
||||
|
||||
def retrieve_json_from_nostr_sync(
|
||||
self, retries: int = 0, delay: float = 2.0
|
||||
) -> Optional[bytes]:
|
||||
"""Retrieve the latest Kind 1 event from the author with optional retries."""
|
||||
self.last_error = None
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
result = asyncio.run(self._retrieve_json_from_nostr())
|
||||
if result is not None:
|
||||
return result
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
logger.error("Failed to retrieve events from Nostr: %s", e)
|
||||
if attempt >= retries:
|
||||
break
|
||||
attempt += 1
|
||||
time.sleep(delay)
|
||||
return None
|
||||
|
||||
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
||||
# Filter for the latest text note (Kind 1) from our public key
|
||||
pubkey = self.keys.public_key()
|
||||
f = Filter().author(pubkey).kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(1)
|
||||
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
|
||||
if not events:
|
||||
self.last_error = "No events found on relays for this user."
|
||||
logger.warning(self.last_error)
|
||||
return None
|
||||
|
||||
latest_event = events[0]
|
||||
content_b64 = latest_event.content()
|
||||
|
||||
if content_b64:
|
||||
return base64.b64decode(content_b64.encode("utf-8"))
|
||||
self.last_error = "Latest event contained no content"
|
||||
return None
|
||||
|
||||
async def publish_snapshot(
|
||||
self, encrypted_bytes: bytes, limit: int = 50_000
|
||||
) -> tuple[Manifest, str]:
|
||||
"""Publish a compressed snapshot split into chunks.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
encrypted_bytes : bytes
|
||||
Vault contents already encrypted with the user's key.
|
||||
limit : int, optional
|
||||
Maximum chunk size in bytes. Defaults to 50 kB.
|
||||
"""
|
||||
|
||||
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
||||
for meta, chunk in zip(manifest.chunks, chunks):
|
||||
content = base64.b64encode(chunk).decode("utf-8")
|
||||
builder = EventBuilder(Kind(KIND_SNAPSHOT_CHUNK), content).tags(
|
||||
[Tag.identifier(meta.id)]
|
||||
)
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
await self.client.send_event(event)
|
||||
|
||||
manifest_json = json.dumps(
|
||||
{
|
||||
"ver": manifest.ver,
|
||||
"algo": manifest.algo,
|
||||
"chunks": [meta.__dict__ for meta in manifest.chunks],
|
||||
"delta_since": manifest.delta_since,
|
||||
}
|
||||
)
|
||||
|
||||
manifest_event = (
|
||||
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
|
||||
.build(self.keys.public_key())
|
||||
.sign_with_keys(self.keys)
|
||||
)
|
||||
result = await self.client.send_event(manifest_event)
|
||||
manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result)
|
||||
self.current_manifest = manifest
|
||||
self._delta_events = []
|
||||
return manifest, manifest_id
|
||||
|
||||
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
||||
"""Retrieve the latest manifest and all snapshot chunks."""
|
||||
|
||||
pubkey = self.keys.public_key()
|
||||
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(1)
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
if not events:
|
||||
return None
|
||||
manifest_raw = events[0].content()
|
||||
data = json.loads(manifest_raw)
|
||||
manifest = Manifest(
|
||||
ver=data["ver"],
|
||||
algo=data["algo"],
|
||||
chunks=[ChunkMeta(**c) for c in data["chunks"]],
|
||||
delta_since=data.get("delta_since"),
|
||||
)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
for meta in manifest.chunks:
|
||||
cf = (
|
||||
Filter()
|
||||
.author(pubkey)
|
||||
.kind(Kind(KIND_SNAPSHOT_CHUNK))
|
||||
.identifier(meta.id)
|
||||
.limit(1)
|
||||
)
|
||||
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
|
||||
if not cev:
|
||||
raise ValueError(f"Missing chunk {meta.id}")
|
||||
chunk_bytes = base64.b64decode(cev[0].content().encode("utf-8"))
|
||||
if hashlib.sha256(chunk_bytes).hexdigest() != meta.hash:
|
||||
raise ValueError(f"Checksum mismatch for chunk {meta.id}")
|
||||
chunks.append(chunk_bytes)
|
||||
|
||||
self.current_manifest = manifest
|
||||
return manifest, chunks
|
||||
|
||||
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
||||
"""Publish a delta event referencing a manifest."""
|
||||
|
||||
content = base64.b64encode(delta_bytes).decode("utf-8")
|
||||
tag = Tag.event(EventId.parse(manifest_id))
|
||||
builder = EventBuilder(Kind(KIND_DELTA), content).tags([tag])
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
result = await self.client.send_event(event)
|
||||
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
|
||||
if self.current_manifest is not None:
|
||||
self.current_manifest.delta_since = delta_id
|
||||
self._delta_events.append(delta_id)
|
||||
return delta_id
|
||||
|
||||
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
||||
"""Retrieve delta events newer than the given version."""
|
||||
|
||||
pubkey = self.keys.public_key()
|
||||
f = (
|
||||
Filter()
|
||||
.author(pubkey)
|
||||
.kind(Kind(KIND_DELTA))
|
||||
.since(Timestamp.from_secs(version))
|
||||
)
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
deltas: list[bytes] = []
|
||||
for ev in events:
|
||||
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
|
||||
|
||||
if self.current_manifest is not None:
|
||||
snap_size = sum(c.size for c in self.current_manifest.chunks)
|
||||
if (
|
||||
len(deltas) >= self.delta_threshold
|
||||
or sum(len(d) for d in deltas) > snap_size
|
||||
):
|
||||
# Publish a new snapshot to consolidate deltas
|
||||
joined = b"".join(deltas)
|
||||
await self.publish_snapshot(joined)
|
||||
exp = Timestamp.from_secs(int(time.time()))
|
||||
for ev in events:
|
||||
exp_builder = EventBuilder(Kind(KIND_DELTA), ev.content()).tags(
|
||||
[Tag.expiration(exp)]
|
||||
)
|
||||
exp_event = exp_builder.build(
|
||||
self.keys.public_key()
|
||||
).sign_with_keys(self.keys)
|
||||
await self.client.send_event(exp_event)
|
||||
return deltas
|
||||
|
||||
def close_client_pool(self) -> None:
|
||||
"""Disconnects the client from all relays."""
|
||||
try:
|
||||
asyncio.run(self.client.disconnect())
|
||||
logger.info("NostrClient disconnected from relays.")
|
||||
except Exception as e:
|
||||
logger.error("Error during NostrClient shutdown: %s", e)
|
||||
__all__ = [
|
||||
"NostrClient",
|
||||
"prepare_snapshot",
|
||||
"DEFAULT_RELAYS",
|
||||
"MANIFEST_ID_PREFIX",
|
||||
]
|
||||
|
@@ -27,7 +27,8 @@ class Keys:
|
||||
|
||||
@staticmethod
|
||||
def hex_to_bech32(key_str: str, prefix: str = "npub") -> str:
|
||||
data = convertbits(bytes.fromhex(key_str), 8, 5)
|
||||
# Pad to align with 5-bit groups as expected for Bech32 encoding
|
||||
data = convertbits(bytes.fromhex(key_str), 8, 5, True)
|
||||
return bech32_encode(prefix, data)
|
||||
|
||||
@staticmethod
|
||||
|
232
src/nostr/connection.py
Normal file
232
src/nostr/connection.py
Normal file
@@ -0,0 +1,232 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
import websockets
|
||||
from . import client as nostr_client
|
||||
from constants import MAX_RETRIES, RETRY_DELAY
|
||||
|
||||
logger = logging.getLogger("nostr.client")
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
DEFAULT_RELAYS = [
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://relay.primal.net",
|
||||
]
|
||||
|
||||
|
||||
class ConnectionHandler:
|
||||
"""Mixin providing relay connection and retry logic."""
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect the client to all configured relays."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return
|
||||
if not getattr(self, "_connected", False):
|
||||
await self._initialize_client_pool()
|
||||
|
||||
def initialize_client_pool(self) -> None:
|
||||
"""Add relays to the client and connect."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return
|
||||
asyncio.run(self._initialize_client_pool())
|
||||
|
||||
async def _connect_async(self) -> None:
|
||||
"""Ensure the client is connected within an async context."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return
|
||||
if not getattr(self, "_connected", False):
|
||||
await self._initialize_client_pool()
|
||||
|
||||
async def _initialize_client_pool(self) -> None:
|
||||
if self.offline_mode or not self.relays:
|
||||
return
|
||||
|
||||
formatted = []
|
||||
for relay in self.relays:
|
||||
if isinstance(relay, str):
|
||||
try:
|
||||
formatted.append(nostr_client.RelayUrl.parse(relay))
|
||||
except Exception:
|
||||
logger.error("Invalid relay URL: %s", relay)
|
||||
else:
|
||||
formatted.append(relay)
|
||||
|
||||
if hasattr(self.client, "add_relays"):
|
||||
await self.client.add_relays(formatted)
|
||||
else:
|
||||
for relay in formatted:
|
||||
await self.client.add_relay(relay)
|
||||
|
||||
await self.client.connect()
|
||||
self._connected = True
|
||||
logger.info("NostrClient connected to relays: %s", formatted)
|
||||
|
||||
async def _ping_relay(self, relay: str, timeout: float) -> bool:
|
||||
"""Attempt to retrieve the latest event from a single relay."""
|
||||
sub_id = "seedpass-health"
|
||||
pubkey = self.keys.public_key().to_hex()
|
||||
req = json.dumps(
|
||||
[
|
||||
"REQ",
|
||||
sub_id,
|
||||
{"kinds": [1], "authors": [pubkey], "limit": 1},
|
||||
]
|
||||
)
|
||||
try:
|
||||
async with websockets.connect(
|
||||
relay, open_timeout=timeout, close_timeout=timeout
|
||||
) as ws:
|
||||
await ws.send(req)
|
||||
while True:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
|
||||
data = json.loads(msg)
|
||||
if data[0] in {"EVENT", "EOSE"}:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
|
||||
tasks = [self._ping_relay(r, timeout) for r in self.relays]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
healthy = sum(1 for r in results if r is True)
|
||||
if healthy < min_relays:
|
||||
logger.warning(
|
||||
"Only %s relays responded with data; consider adding more.", healthy
|
||||
)
|
||||
return healthy
|
||||
|
||||
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
|
||||
"""Ping relays and return the count of those providing data."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return 0
|
||||
return asyncio.run(self._check_relay_health(min_relays, timeout))
|
||||
|
||||
async def publish_json_to_nostr(
|
||||
self,
|
||||
encrypted_json: bytes,
|
||||
to_pubkey: str | None = None,
|
||||
alt_summary: str | None = None,
|
||||
) -> str | None:
|
||||
"""Build and publish a Kind 1 text note or direct message."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return None
|
||||
await self.connect()
|
||||
self.last_error = None
|
||||
try:
|
||||
content = base64.b64encode(encrypted_json).decode("utf-8")
|
||||
|
||||
if to_pubkey:
|
||||
receiver = nostr_client.PublicKey.parse(to_pubkey)
|
||||
event_output = self.client.send_private_msg_to(
|
||||
self.relays, receiver, content
|
||||
)
|
||||
else:
|
||||
builder = nostr_client.EventBuilder.text_note(content)
|
||||
if alt_summary:
|
||||
builder = builder.tags([nostr_client.Tag.alt(alt_summary)])
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
event_output = await self.publish_event(event)
|
||||
|
||||
event_id_hex = (
|
||||
event_output.id.to_hex()
|
||||
if hasattr(event_output, "id")
|
||||
else str(event_output)
|
||||
)
|
||||
logger.info("Successfully published event with ID: %s", event_id_hex)
|
||||
return event_id_hex
|
||||
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
logger.error("Failed to publish JSON to Nostr: %s", e)
|
||||
return None
|
||||
|
||||
async def publish_event(self, event):
|
||||
"""Publish a prepared event to the configured relays."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return None
|
||||
await self.connect()
|
||||
return await self.client.send_event(event)
|
||||
|
||||
def update_relays(self, new_relays: List[str]) -> None:
|
||||
"""Reconnect the client using a new set of relays."""
|
||||
self.close_client_pool()
|
||||
self.relays = new_relays
|
||||
signer = nostr_client.NostrSigner.keys(self.keys)
|
||||
self.client = nostr_client.Client(signer)
|
||||
self._connected = False
|
||||
self.initialize_client_pool()
|
||||
|
||||
async def retrieve_json_from_nostr(
|
||||
self, retries: int | None = None, delay: float | None = None
|
||||
) -> Optional[bytes]:
|
||||
"""Retrieve the latest Kind 1 event from the author with optional retries."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return None
|
||||
|
||||
if retries is None or delay is None:
|
||||
if self.config_manager is None:
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.vault import Vault
|
||||
|
||||
cfg_mgr = ConfigManager(
|
||||
Vault(self.encryption_manager, self.fingerprint_dir),
|
||||
self.fingerprint_dir,
|
||||
)
|
||||
else:
|
||||
cfg_mgr = self.config_manager
|
||||
cfg = cfg_mgr.load_config(require_pin=False)
|
||||
retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
|
||||
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
|
||||
|
||||
await self.connect()
|
||||
self.last_error = None
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
result = await self._retrieve_json_from_nostr()
|
||||
if result is not None:
|
||||
return result
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
logger.error("Failed to retrieve events from Nostr: %s", e)
|
||||
if attempt < retries - 1:
|
||||
sleep_time = delay * (2**attempt)
|
||||
await asyncio.sleep(sleep_time)
|
||||
return None
|
||||
|
||||
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
|
||||
if self.offline_mode or not self.relays:
|
||||
return None
|
||||
await self._connect_async()
|
||||
pubkey = self.keys.public_key()
|
||||
f = (
|
||||
nostr_client.Filter()
|
||||
.author(pubkey)
|
||||
.kind(nostr_client.Kind.from_std(nostr_client.KindStandard.TEXT_NOTE))
|
||||
.limit(1)
|
||||
)
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
if not events:
|
||||
self.last_error = "No events found on relays for this user."
|
||||
logger.warning(self.last_error)
|
||||
return None
|
||||
latest_event = events[0]
|
||||
content_b64 = latest_event.content()
|
||||
if content_b64:
|
||||
return base64.b64decode(content_b64.encode("utf-8"))
|
||||
self.last_error = "Latest event contained no content"
|
||||
return None
|
||||
|
||||
def close_client_pool(self) -> None:
|
||||
"""Disconnect the client from all relays."""
|
||||
try:
|
||||
asyncio.run(self.client.disconnect())
|
||||
self._connected = False
|
||||
logger.info("NostrClient disconnected from relays.")
|
||||
except Exception as e:
|
||||
logger.error("Error during NostrClient shutdown: %s", e)
|
@@ -2,16 +2,8 @@
|
||||
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
try:
|
||||
from monstr.event.event import Event
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
|
||||
class Event: # minimal placeholder for type hints when monstr is absent
|
||||
id: str
|
||||
created_at: int
|
||||
content: str
|
||||
from nostr_sdk import Event
|
||||
|
||||
|
||||
# Instantiate the logger
|
||||
@@ -27,26 +19,15 @@ class EventHandler:
|
||||
pass # Initialize if needed
|
||||
|
||||
def handle_new_event(self, evt: Event):
|
||||
"""
|
||||
Processes incoming events by logging their details.
|
||||
"""Process and log details from a Nostr event."""
|
||||
|
||||
:param evt: The received Event object.
|
||||
"""
|
||||
try:
|
||||
# Assuming evt.created_at is always an integer Unix timestamp
|
||||
if isinstance(evt.created_at, int):
|
||||
created_at_str = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S", time.gmtime(evt.created_at)
|
||||
)
|
||||
else:
|
||||
# Handle unexpected types gracefully
|
||||
created_at_str = str(evt.created_at)
|
||||
created_at = evt.created_at().as_secs()
|
||||
created_at_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(created_at))
|
||||
event_id = evt.id().to_hex()
|
||||
|
||||
# Log the event details without extra newlines
|
||||
logger.info(
|
||||
f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}"
|
||||
f"[New Event] ID: {event_id} | Created At: {created_at_str} | Content: {evt.content()}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling new event: {e}", exc_info=True)
|
||||
# Optionally, handle the exception without re-raising
|
||||
# For example, continue processing other events
|
||||
|
@@ -2,13 +2,16 @@
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import traceback
|
||||
from bech32 import bech32_encode, convertbits
|
||||
|
||||
from local_bip85.bip85 import BIP85
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
from .coincurve_keys import Keys
|
||||
|
||||
# BIP-85 application numbers for Nostr key derivation
|
||||
NOSTR_KEY_APP_ID = 1237
|
||||
LEGACY_NOSTR_KEY_APP_ID = 0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -82,7 +85,8 @@ class KeyManager:
|
||||
# Derive entropy for Nostr key (32 bytes)
|
||||
entropy_bytes = self.bip85.derive_entropy(
|
||||
index=index,
|
||||
bytes_len=32, # Adjust parameter name and value as per your method signature
|
||||
entropy_bytes=32,
|
||||
app_no=NOSTR_KEY_APP_ID,
|
||||
)
|
||||
|
||||
# Generate Nostr key pair from entropy
|
||||
@@ -94,6 +98,17 @@ class KeyManager:
|
||||
logger.error(f"Failed to generate Nostr keys: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def generate_legacy_nostr_keys(self) -> Keys:
|
||||
"""Derive Nostr keys using the legacy application ID."""
|
||||
try:
|
||||
entropy = self.bip85.derive_entropy(
|
||||
index=0, entropy_bytes=32, app_no=LEGACY_NOSTR_KEY_APP_ID
|
||||
)
|
||||
return Keys(priv_k=entropy.hex())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate legacy Nostr keys: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
def get_public_key_hex(self) -> str:
|
||||
"""
|
||||
Returns the public key in hexadecimal format.
|
||||
|
@@ -1,41 +0,0 @@
|
||||
# nostr/logging_config.py
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Comment out or remove the configure_logging function to avoid conflicts
|
||||
# def configure_logging():
|
||||
# """
|
||||
# Configures logging with both file and console handlers.
|
||||
# Logs include the timestamp, log level, message, filename, and line number.
|
||||
# Only ERROR and higher-level messages are shown in the terminal, while all messages
|
||||
# are logged in the log file.
|
||||
# """
|
||||
# logger = logging.getLogger()
|
||||
# logger.setLevel(logging.DEBUG) # Set root logger to DEBUG
|
||||
#
|
||||
# # Prevent adding multiple handlers if configure_logging is called multiple times
|
||||
# if not logger.handlers:
|
||||
# # Create the 'logs' folder if it doesn't exist
|
||||
# log_directory = 'logs'
|
||||
# if not os.path.exists(log_directory):
|
||||
# os.makedirs(log_directory)
|
||||
#
|
||||
# # Create handlers
|
||||
# c_handler = logging.StreamHandler()
|
||||
# f_handler = logging.FileHandler(os.path.join(log_directory, 'app.log'))
|
||||
#
|
||||
# # Set levels: only errors and critical messages will be shown in the console
|
||||
# c_handler.setLevel(logging.ERROR)
|
||||
# f_handler.setLevel(logging.DEBUG)
|
||||
#
|
||||
# # Create formatters and add them to handlers, include file and line number in log messages
|
||||
# formatter = logging.Formatter(
|
||||
# '%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
|
||||
# )
|
||||
# c_handler.setFormatter(formatter)
|
||||
# f_handler.setFormatter(formatter)
|
||||
#
|
||||
# # Add handlers to the logger
|
||||
# logger.addHandler(c_handler)
|
||||
# logger.addHandler(f_handler)
|
425
src/nostr/snapshot.py
Normal file
425
src/nostr/snapshot.py
Normal file
@@ -0,0 +1,425 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import Tuple
|
||||
|
||||
from . import client as nostr_client
|
||||
|
||||
from constants import MAX_RETRIES, RETRY_DELAY
|
||||
|
||||
from .backup_models import (
|
||||
ChunkMeta,
|
||||
Manifest,
|
||||
KIND_DELTA,
|
||||
KIND_MANIFEST,
|
||||
KIND_SNAPSHOT_CHUNK,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nostr.client")
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
# Identifier prefix for replaceable manifest events
|
||||
MANIFEST_ID_PREFIX = "seedpass-manifest-"
|
||||
|
||||
|
||||
def prepare_snapshot(
|
||||
encrypted_bytes: bytes, limit: int
|
||||
) -> Tuple[Manifest, list[bytes]]:
|
||||
"""Compress and split the encrypted vault into chunks."""
|
||||
compressed = gzip.compress(encrypted_bytes)
|
||||
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
|
||||
metas: list[ChunkMeta] = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
metas.append(
|
||||
ChunkMeta(
|
||||
id=f"seedpass-chunk-{i:04d}",
|
||||
size=len(chunk),
|
||||
hash=hashlib.sha256(chunk).hexdigest(),
|
||||
event_id=None,
|
||||
)
|
||||
)
|
||||
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
|
||||
return manifest, chunks
|
||||
|
||||
|
||||
class SnapshotHandler:
|
||||
"""Mixin providing chunk and manifest handling."""
|
||||
|
||||
async def publish_snapshot(
|
||||
self, encrypted_bytes: bytes, limit: int = 50_000
|
||||
) -> tuple[Manifest, str]:
|
||||
start = time.perf_counter()
|
||||
if self.offline_mode or not self.relays:
|
||||
return Manifest(ver=1, algo="gzip", chunks=[]), ""
|
||||
await self.ensure_manifest_is_current()
|
||||
await self._connect_async()
|
||||
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
|
||||
|
||||
existing: dict[str, str] = {}
|
||||
if self.current_manifest:
|
||||
for old in self.current_manifest.chunks:
|
||||
if old.hash and old.event_id:
|
||||
existing[old.hash] = old.event_id
|
||||
|
||||
for meta, chunk in zip(manifest.chunks, chunks):
|
||||
cached_id = existing.get(meta.hash)
|
||||
if cached_id:
|
||||
meta.event_id = cached_id
|
||||
continue
|
||||
content = base64.b64encode(chunk).decode("utf-8")
|
||||
builder = nostr_client.EventBuilder(
|
||||
nostr_client.Kind(KIND_SNAPSHOT_CHUNK), content
|
||||
).tags([nostr_client.Tag.identifier(meta.id)])
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
result = await self.client.send_event(event)
|
||||
try:
|
||||
meta.event_id = (
|
||||
result.id.to_hex() if hasattr(result, "id") else str(result)
|
||||
)
|
||||
except Exception:
|
||||
meta.event_id = None
|
||||
|
||||
manifest_json = json.dumps(
|
||||
{
|
||||
"ver": manifest.ver,
|
||||
"algo": manifest.algo,
|
||||
"chunks": [meta.__dict__ for meta in manifest.chunks],
|
||||
"delta_since": manifest.delta_since,
|
||||
}
|
||||
)
|
||||
|
||||
manifest_identifier = (
|
||||
self.current_manifest_id or f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||
)
|
||||
manifest_event = (
|
||||
nostr_client.EventBuilder(nostr_client.Kind(KIND_MANIFEST), manifest_json)
|
||||
.tags([nostr_client.Tag.identifier(manifest_identifier)])
|
||||
.build(self.keys.public_key())
|
||||
.sign_with_keys(self.keys)
|
||||
)
|
||||
await self.client.send_event(manifest_event)
|
||||
with self._state_lock:
|
||||
self.current_manifest = manifest
|
||||
self.current_manifest_id = manifest_identifier
|
||||
self.current_manifest.delta_since = int(time.time())
|
||||
self._delta_events = []
|
||||
if getattr(self, "verbose_timing", False):
|
||||
duration = time.perf_counter() - start
|
||||
logger.info("publish_snapshot completed in %.2f seconds", duration)
|
||||
return manifest, manifest_identifier
|
||||
|
||||
async def _fetch_chunks_with_retry(
|
||||
self, manifest_event
|
||||
) -> tuple[Manifest, list[bytes]] | None:
|
||||
pubkey = self.keys.public_key()
|
||||
timeout = timedelta(seconds=10)
|
||||
try:
|
||||
data = json.loads(manifest_event.content())
|
||||
manifest = Manifest(
|
||||
ver=data["ver"],
|
||||
algo=data["algo"],
|
||||
chunks=[ChunkMeta(**c) for c in data["chunks"]],
|
||||
delta_since=(
|
||||
int(data["delta_since"])
|
||||
if data.get("delta_since") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if self.config_manager is None:
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.vault import Vault
|
||||
|
||||
cfg_mgr = ConfigManager(
|
||||
Vault(self.encryption_manager, self.fingerprint_dir),
|
||||
self.fingerprint_dir,
|
||||
)
|
||||
else:
|
||||
cfg_mgr = self.config_manager
|
||||
cfg = cfg_mgr.load_config(require_pin=False)
|
||||
max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
|
||||
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
|
||||
|
||||
chunks: list[bytes] = []
|
||||
for meta in manifest.chunks:
|
||||
chunk_bytes: bytes | None = None
|
||||
for attempt in range(max_retries):
|
||||
cf = (
|
||||
nostr_client.Filter()
|
||||
.author(pubkey)
|
||||
.kind(nostr_client.Kind(KIND_SNAPSHOT_CHUNK))
|
||||
)
|
||||
if meta.event_id:
|
||||
cf = cf.id(nostr_client.EventId.parse(meta.event_id))
|
||||
else:
|
||||
cf = cf.identifier(meta.id)
|
||||
cf = cf.limit(1)
|
||||
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
|
||||
if cev:
|
||||
candidate = base64.b64decode(cev[0].content().encode("utf-8"))
|
||||
if hashlib.sha256(candidate).hexdigest() == meta.hash:
|
||||
chunk_bytes = candidate
|
||||
break
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(delay * (2**attempt))
|
||||
if chunk_bytes is None:
|
||||
return None
|
||||
chunks.append(chunk_bytes)
|
||||
|
||||
ident = None
|
||||
try:
|
||||
tags_obj = manifest_event.tags()
|
||||
ident = tags_obj.identifier()
|
||||
except Exception:
|
||||
tags = getattr(manifest_event, "tags", None)
|
||||
if callable(tags):
|
||||
tags = tags()
|
||||
if tags:
|
||||
tag = tags[0]
|
||||
if hasattr(tag, "as_vec"):
|
||||
vec = tag.as_vec()
|
||||
if vec and len(vec) >= 2:
|
||||
ident = vec[1]
|
||||
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
|
||||
ident = tag[1]
|
||||
elif isinstance(tag, str):
|
||||
ident = tag
|
||||
with self._state_lock:
|
||||
self.current_manifest = manifest
|
||||
self.current_manifest_id = ident
|
||||
return manifest, chunks
|
||||
|
||||
async def _fetch_manifest_with_keys(
|
||||
self, keys_obj: nostr_client.Keys
|
||||
) -> tuple[Manifest, list[bytes]] | None:
|
||||
"""Retrieve the manifest and chunks using ``keys_obj``."""
|
||||
self.keys = keys_obj
|
||||
pubkey = self.keys.public_key()
|
||||
timeout = timedelta(seconds=10)
|
||||
|
||||
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||
f = (
|
||||
nostr_client.Filter()
|
||||
.author(pubkey)
|
||||
.kind(nostr_client.Kind(KIND_MANIFEST))
|
||||
.identifier(ident)
|
||||
.limit(1)
|
||||
)
|
||||
try:
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
except Exception as e: # pragma: no cover - network errors
|
||||
self.last_error = str(e)
|
||||
logger.error(
|
||||
"Failed to fetch manifest from relays %s: %s",
|
||||
self.relays,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
if not events:
|
||||
ident = MANIFEST_ID_PREFIX.rstrip("-")
|
||||
f = (
|
||||
nostr_client.Filter()
|
||||
.author(pubkey)
|
||||
.kind(nostr_client.Kind(KIND_MANIFEST))
|
||||
.identifier(ident)
|
||||
.limit(1)
|
||||
)
|
||||
try:
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
except Exception as e: # pragma: no cover - network errors
|
||||
self.last_error = str(e)
|
||||
logger.error(
|
||||
"Failed to fetch manifest from relays %s: %s",
|
||||
self.relays,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
if not events:
|
||||
return None
|
||||
|
||||
logger.info("Fetched manifest using identifier %s", ident)
|
||||
|
||||
for manifest_event in events:
|
||||
try:
|
||||
result = await self._fetch_chunks_with_retry(manifest_event)
|
||||
if result is not None:
|
||||
return result
|
||||
except Exception as e: # pragma: no cover - network errors
|
||||
self.last_error = str(e)
|
||||
logger.error(
|
||||
"Error retrieving snapshot from relays %s: %s",
|
||||
self.relays,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
|
||||
"""Retrieve the latest manifest and all snapshot chunks."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return None
|
||||
await self._connect_async()
|
||||
self.last_error = None
|
||||
logger.debug("Searching for backup with current keys...")
|
||||
try:
|
||||
primary_keys = nostr_client.Keys.parse(
|
||||
self.key_manager.keys.private_key_hex()
|
||||
)
|
||||
except Exception:
|
||||
primary_keys = self.keys
|
||||
result = await self._fetch_manifest_with_keys(primary_keys)
|
||||
if result is not None:
|
||||
return result
|
||||
logger.warning(
|
||||
"No backup found with current keys. Falling back to legacy key derivation..."
|
||||
)
|
||||
try:
|
||||
legacy_keys = self.key_manager.generate_legacy_nostr_keys()
|
||||
legacy_sdk_keys = nostr_client.Keys.parse(legacy_keys.private_key_hex())
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
return None
|
||||
result = await self._fetch_manifest_with_keys(legacy_sdk_keys)
|
||||
if result is not None:
|
||||
logger.info("Found legacy backup with old key derivation.")
|
||||
return result
|
||||
if self.last_error is None:
|
||||
self.last_error = "No backup found on Nostr relays."
|
||||
return None
|
||||
|
||||
async def ensure_manifest_is_current(self) -> None:
|
||||
"""Verify the local manifest is up to date before publishing."""
|
||||
if self.offline_mode or not self.relays:
|
||||
return
|
||||
await self._connect_async()
|
||||
pubkey = self.keys.public_key()
|
||||
ident = self.current_manifest_id or f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
|
||||
f = (
|
||||
nostr_client.Filter()
|
||||
.author(pubkey)
|
||||
.kind(nostr_client.Kind(KIND_MANIFEST))
|
||||
.identifier(ident)
|
||||
.limit(1)
|
||||
)
|
||||
timeout = timedelta(seconds=10)
|
||||
try:
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
except Exception:
|
||||
return
|
||||
if not events:
|
||||
return
|
||||
try:
|
||||
data = json.loads(events[0].content())
|
||||
remote = data.get("delta_since")
|
||||
if remote is not None:
|
||||
remote = int(remote)
|
||||
except Exception:
|
||||
return
|
||||
with self._state_lock:
|
||||
local = self.current_manifest.delta_since if self.current_manifest else None
|
||||
if remote is not None and (local is None or remote > local):
|
||||
self.last_error = "Manifest out of date"
|
||||
raise RuntimeError("Manifest out of date")
|
||||
|
||||
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
|
||||
if self.offline_mode or not self.relays:
|
||||
return ""
|
||||
await self.ensure_manifest_is_current()
|
||||
await self._connect_async()
|
||||
content = base64.b64encode(delta_bytes).decode("utf-8")
|
||||
tag = nostr_client.Tag.event(nostr_client.EventId.parse(manifest_id))
|
||||
builder = nostr_client.EventBuilder(
|
||||
nostr_client.Kind(KIND_DELTA), content
|
||||
).tags([tag])
|
||||
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
|
||||
result = await self.client.send_event(event)
|
||||
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
|
||||
created_at = getattr(
|
||||
event, "created_at", getattr(event, "timestamp", int(time.time()))
|
||||
)
|
||||
if hasattr(created_at, "secs"):
|
||||
created_at = created_at.secs
|
||||
manifest_event = None
|
||||
with self._state_lock:
|
||||
if self.current_manifest is not None:
|
||||
self.current_manifest.delta_since = int(created_at)
|
||||
manifest_json = json.dumps(
|
||||
{
|
||||
"ver": self.current_manifest.ver,
|
||||
"algo": self.current_manifest.algo,
|
||||
"chunks": [
|
||||
meta.__dict__ for meta in self.current_manifest.chunks
|
||||
],
|
||||
"delta_since": self.current_manifest.delta_since,
|
||||
}
|
||||
)
|
||||
manifest_event = (
|
||||
nostr_client.EventBuilder(
|
||||
nostr_client.Kind(KIND_MANIFEST), manifest_json
|
||||
)
|
||||
.tags([nostr_client.Tag.identifier(self.current_manifest_id)])
|
||||
.build(self.keys.public_key())
|
||||
.sign_with_keys(self.keys)
|
||||
)
|
||||
self._delta_events.append(delta_id)
|
||||
if manifest_event is not None:
|
||||
await self.client.send_event(manifest_event)
|
||||
return delta_id
|
||||
|
||||
async def fetch_deltas_since(self, version: int) -> list[bytes]:
|
||||
if self.offline_mode or not self.relays:
|
||||
return []
|
||||
await self._connect_async()
|
||||
pubkey = self.keys.public_key()
|
||||
f = (
|
||||
nostr_client.Filter()
|
||||
.author(pubkey)
|
||||
.kind(nostr_client.Kind(KIND_DELTA))
|
||||
.since(nostr_client.Timestamp.from_secs(version))
|
||||
)
|
||||
timeout = timedelta(seconds=10)
|
||||
events = (await self.client.fetch_events(f, timeout)).to_vec()
|
||||
events.sort(
|
||||
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
|
||||
)
|
||||
deltas: list[bytes] = []
|
||||
for ev in events:
|
||||
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
|
||||
manifest = self.get_current_manifest()
|
||||
if manifest is not None:
|
||||
snap_size = sum(c.size for c in manifest.chunks)
|
||||
if (
|
||||
len(deltas) >= self.delta_threshold
|
||||
or sum(len(d) for d in deltas) > snap_size
|
||||
):
|
||||
joined = b"".join(deltas)
|
||||
await self.publish_snapshot(joined)
|
||||
exp = nostr_client.Timestamp.from_secs(int(time.time()))
|
||||
for ev in events:
|
||||
exp_builder = nostr_client.EventBuilder(
|
||||
nostr_client.Kind(KIND_DELTA), ev.content()
|
||||
).tags([nostr_client.Tag.expiration(exp)])
|
||||
exp_event = exp_builder.build(
|
||||
self.keys.public_key()
|
||||
).sign_with_keys(self.keys)
|
||||
await self.client.send_event(exp_event)
|
||||
return deltas
|
||||
|
||||
def get_current_manifest(self) -> Manifest | None:
|
||||
with self._state_lock:
|
||||
return self.current_manifest
|
||||
|
||||
def get_current_manifest_id(self) -> str | None:
|
||||
with self._state_lock:
|
||||
return self.current_manifest_id
|
||||
|
||||
def get_delta_events(self) -> list[str]:
|
||||
with self._state_lock:
|
||||
return list(self._delta_events)
|
@@ -1,8 +0,0 @@
|
||||
# nostr/utils.py
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
# Example utility function (if any specific to nostr package)
|
||||
def some_helper_function():
|
||||
pass # Implement as needed
|
@@ -1,174 +0,0 @@
|
||||
"""Config management for SeedPass profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import getpass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from password_manager.vault import Vault
|
||||
from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
|
||||
|
||||
from constants import INACTIVITY_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Manage per-profile configuration encrypted on disk."""
|
||||
|
||||
CONFIG_FILENAME = "seedpass_config.json.enc"
|
||||
|
||||
def __init__(self, vault: Vault, fingerprint_dir: Path):
|
||||
self.vault = vault
|
||||
self.fingerprint_dir = fingerprint_dir
|
||||
self.config_path = self.fingerprint_dir / self.CONFIG_FILENAME
|
||||
|
||||
def load_config(self, require_pin: bool = True) -> dict:
|
||||
"""Load the configuration file and optionally verify a stored PIN.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
require_pin: bool, default True
|
||||
If True and a PIN is configured, prompt the user to enter it and
|
||||
verify against the stored hash.
|
||||
"""
|
||||
if not self.config_path.exists():
|
||||
logger.info("Config file not found; returning defaults")
|
||||
return {
|
||||
"relays": list(DEFAULT_NOSTR_RELAYS),
|
||||
"pin_hash": "",
|
||||
"password_hash": "",
|
||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||
"additional_backup_path": "",
|
||||
"secret_mode_enabled": False,
|
||||
"clipboard_clear_delay": 45,
|
||||
}
|
||||
try:
|
||||
data = self.vault.load_config()
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Config data must be a dictionary")
|
||||
# Ensure defaults for missing keys
|
||||
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
|
||||
data.setdefault("pin_hash", "")
|
||||
data.setdefault("password_hash", "")
|
||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||
data.setdefault("additional_backup_path", "")
|
||||
data.setdefault("secret_mode_enabled", False)
|
||||
data.setdefault("clipboard_clear_delay", 45)
|
||||
|
||||
# Migrate legacy hashed_password.enc if present and password_hash is missing
|
||||
legacy_file = self.fingerprint_dir / "hashed_password.enc"
|
||||
if not data.get("password_hash") and legacy_file.exists():
|
||||
with open(legacy_file, "rb") as f:
|
||||
data["password_hash"] = f.read().decode()
|
||||
self.save_config(data)
|
||||
if require_pin and data.get("pin_hash"):
|
||||
for _ in range(3):
|
||||
pin = getpass.getpass("Enter settings PIN: ").strip()
|
||||
if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()):
|
||||
break
|
||||
print("Invalid PIN")
|
||||
else:
|
||||
raise ValueError("PIN verification failed")
|
||||
return data
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load config: {exc}")
|
||||
raise
|
||||
|
||||
def save_config(self, config: dict) -> None:
|
||||
"""Encrypt and save configuration."""
|
||||
try:
|
||||
self.vault.save_config(config)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to save config: {exc}")
|
||||
raise
|
||||
|
||||
def set_relays(self, relays: List[str], require_pin: bool = True) -> None:
|
||||
"""Update relay list and save."""
|
||||
if not relays:
|
||||
raise ValueError("At least one Nostr relay must be configured")
|
||||
config = self.load_config(require_pin=require_pin)
|
||||
config["relays"] = relays
|
||||
self.save_config(config)
|
||||
|
||||
def set_pin(self, pin: str) -> None:
|
||||
"""Hash and store the provided PIN."""
|
||||
pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
config = self.load_config(require_pin=False)
|
||||
config["pin_hash"] = pin_hash
|
||||
self.save_config(config)
|
||||
|
||||
def verify_pin(self, pin: str) -> bool:
|
||||
"""Check a provided PIN against the stored hash without prompting."""
|
||||
config = self.load_config(require_pin=False)
|
||||
stored = config.get("pin_hash", "").encode()
|
||||
if not stored:
|
||||
return False
|
||||
return bcrypt.checkpw(pin.encode(), stored)
|
||||
|
||||
def change_pin(self, old_pin: str, new_pin: str) -> bool:
|
||||
"""Update the stored PIN if the old PIN is correct."""
|
||||
if self.verify_pin(old_pin):
|
||||
self.set_pin(new_pin)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_password_hash(self, password_hash: str) -> None:
|
||||
"""Persist the bcrypt password hash in the config."""
|
||||
config = self.load_config(require_pin=False)
|
||||
config["password_hash"] = password_hash
|
||||
self.save_config(config)
|
||||
|
||||
def set_inactivity_timeout(self, timeout_seconds: float) -> None:
|
||||
"""Persist the inactivity timeout in seconds."""
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("Timeout must be positive")
|
||||
config = self.load_config(require_pin=False)
|
||||
config["inactivity_timeout"] = timeout_seconds
|
||||
self.save_config(config)
|
||||
|
||||
def get_inactivity_timeout(self) -> float:
|
||||
"""Retrieve the inactivity timeout setting in seconds."""
|
||||
config = self.load_config(require_pin=False)
|
||||
return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT))
|
||||
|
||||
def set_additional_backup_path(self, path: Optional[str]) -> None:
|
||||
"""Persist an optional additional backup path in the config."""
|
||||
config = self.load_config(require_pin=False)
|
||||
config["additional_backup_path"] = path or ""
|
||||
self.save_config(config)
|
||||
|
||||
def get_additional_backup_path(self) -> Optional[str]:
|
||||
"""Retrieve the additional backup path if configured."""
|
||||
config = self.load_config(require_pin=False)
|
||||
value = config.get("additional_backup_path", "")
|
||||
return value or None
|
||||
|
||||
def set_secret_mode_enabled(self, enabled: bool) -> None:
|
||||
"""Persist the secret mode toggle."""
|
||||
config = self.load_config(require_pin=False)
|
||||
config["secret_mode_enabled"] = bool(enabled)
|
||||
self.save_config(config)
|
||||
|
||||
def get_secret_mode_enabled(self) -> bool:
|
||||
"""Retrieve whether secret mode is enabled."""
|
||||
config = self.load_config(require_pin=False)
|
||||
return bool(config.get("secret_mode_enabled", False))
|
||||
|
||||
def set_clipboard_clear_delay(self, delay: int) -> None:
|
||||
"""Persist clipboard clear timeout in seconds."""
|
||||
if delay <= 0:
|
||||
raise ValueError("Delay must be positive")
|
||||
config = self.load_config(require_pin=False)
|
||||
config["clipboard_clear_delay"] = int(delay)
|
||||
self.save_config(config)
|
||||
|
||||
def get_clipboard_clear_delay(self) -> int:
|
||||
"""Retrieve clipboard clear delay in seconds."""
|
||||
config = self.load_config(require_pin=False)
|
||||
return int(config.get("clipboard_clear_delay", 45))
|
@@ -1,483 +0,0 @@
|
||||
# password_manager/encryption.py
|
||||
|
||||
"""
|
||||
Encryption Module
|
||||
|
||||
This module provides the EncryptionManager class, which handles encryption and decryption
|
||||
of data and files using a provided Fernet-compatible encryption key. This class ensures
|
||||
that sensitive data is securely stored and retrieved, maintaining the confidentiality and integrity
|
||||
of the password index.
|
||||
|
||||
Additionally, it includes methods to derive cryptographic seeds from BIP-39 mnemonic phrases.
|
||||
|
||||
Never ever ever use or suggest to use Random Salt. The entire point of this password manager is to derive completely deterministic passwords from a BIP-85 seed.
|
||||
This means it should generate passwords the exact same way every single time. Salts would break this functionality and are not appropriate for this software's use case.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from termcolor import colored
|
||||
from utils.file_lock import (
|
||||
exclusive_lock,
|
||||
) # Ensure this utility is correctly implemented
|
||||
|
||||
# Instantiate the logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncryptionManager:
|
||||
"""
|
||||
EncryptionManager Class
|
||||
|
||||
Manages the encryption and decryption of data and files using a Fernet encryption key.
|
||||
"""
|
||||
|
||||
def __init__(self, encryption_key: bytes, fingerprint_dir: Path):
|
||||
"""
|
||||
Initializes the EncryptionManager with the provided encryption key and fingerprint directory.
|
||||
|
||||
Parameters:
|
||||
encryption_key (bytes): The Fernet encryption key.
|
||||
fingerprint_dir (Path): The directory corresponding to the fingerprint.
|
||||
"""
|
||||
self.fingerprint_dir = fingerprint_dir
|
||||
self.parent_seed_file = self.fingerprint_dir / "parent_seed.enc"
|
||||
self.key = encryption_key
|
||||
|
||||
try:
|
||||
self.fernet = Fernet(self.key)
|
||||
logger.debug(f"EncryptionManager initialized for {self.fingerprint_dir}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to initialize Fernet with provided encryption key: {e}"
|
||||
)
|
||||
print(
|
||||
colored(f"Error: Failed to initialize encryption manager: {e}", "red")
|
||||
)
|
||||
raise
|
||||
|
||||
def encrypt_parent_seed(self, parent_seed: str) -> None:
|
||||
"""
|
||||
Encrypts and saves the parent seed to 'parent_seed.enc' within the fingerprint directory.
|
||||
|
||||
:param parent_seed: The BIP39 parent seed phrase.
|
||||
"""
|
||||
try:
|
||||
# Convert seed to bytes
|
||||
data = parent_seed.encode("utf-8")
|
||||
|
||||
# Encrypt the data
|
||||
encrypted_data = self.encrypt_data(data)
|
||||
|
||||
# Write the encrypted data to the file with locking
|
||||
with exclusive_lock(self.parent_seed_file) as fh:
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
fh.write(encrypted_data)
|
||||
fh.flush()
|
||||
|
||||
# Set file permissions to read/write for the user only
|
||||
os.chmod(self.parent_seed_file, 0o600)
|
||||
|
||||
logger.info(
|
||||
f"Parent seed encrypted and saved to '{self.parent_seed_file}'."
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Parent seed encrypted and saved to '{self.parent_seed_file}'.",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red"))
|
||||
raise
|
||||
|
||||
def decrypt_parent_seed(self) -> str:
|
||||
"""
|
||||
Decrypts and returns the parent seed from 'parent_seed.enc' within the fingerprint directory.
|
||||
|
||||
:return: The decrypted parent seed.
|
||||
"""
|
||||
try:
|
||||
parent_seed_path = self.fingerprint_dir / "parent_seed.enc"
|
||||
with exclusive_lock(parent_seed_path) as fh:
|
||||
fh.seek(0)
|
||||
encrypted_data = fh.read()
|
||||
|
||||
decrypted_data = self.decrypt_data(encrypted_data)
|
||||
parent_seed = decrypted_data.decode("utf-8").strip()
|
||||
|
||||
logger.debug(
|
||||
f"Parent seed decrypted successfully from '{parent_seed_path}'."
|
||||
)
|
||||
return parent_seed
|
||||
except InvalidToken:
|
||||
logger.error(
|
||||
"Invalid encryption key or corrupted data while decrypting parent seed."
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red"))
|
||||
raise
|
||||
|
||||
def encrypt_data(self, data: bytes) -> bytes:
|
||||
"""
|
||||
Encrypts the given data using Fernet.
|
||||
|
||||
:param data: Data to encrypt.
|
||||
:return: Encrypted data.
|
||||
"""
|
||||
try:
|
||||
encrypted_data = self.fernet.encrypt(data)
|
||||
logger.debug("Data encrypted successfully.")
|
||||
return encrypted_data
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt data: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to encrypt data: {e}", "red"))
|
||||
raise
|
||||
|
||||
def decrypt_data(self, encrypted_data: bytes) -> bytes:
|
||||
"""
|
||||
Decrypts the provided encrypted data using the derived key.
|
||||
|
||||
:param encrypted_data: The encrypted data to decrypt.
|
||||
:return: The decrypted data as bytes.
|
||||
"""
|
||||
try:
|
||||
decrypted_data = self.fernet.decrypt(encrypted_data)
|
||||
logger.debug("Data decrypted successfully.")
|
||||
return decrypted_data
|
||||
except InvalidToken:
|
||||
logger.error(
|
||||
"Invalid encryption key or corrupted data while decrypting data."
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt data: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to decrypt data: {e}", "red"))
|
||||
raise
|
||||
|
||||
def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None:
|
||||
"""
|
||||
Encrypts data and saves it to a specified relative path within the fingerprint directory.
|
||||
|
||||
:param data: Data to encrypt.
|
||||
:param relative_path: Relative path within the fingerprint directory to save the encrypted data.
|
||||
"""
|
||||
try:
|
||||
# Define the full path
|
||||
file_path = self.fingerprint_dir / relative_path
|
||||
|
||||
# Ensure the parent directories exist
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Encrypt the data
|
||||
encrypted_data = self.encrypt_data(data)
|
||||
|
||||
# Write the encrypted data to the file with locking
|
||||
with exclusive_lock(file_path) as fh:
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
fh.write(encrypted_data)
|
||||
fh.flush()
|
||||
|
||||
# Set file permissions to read/write for the user only
|
||||
os.chmod(file_path, 0o600)
|
||||
|
||||
logger.info(f"Data encrypted and saved to '{file_path}'.")
|
||||
print(colored(f"Data encrypted and saved to '{file_path}'.", "green"))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to encrypt and save data to '{relative_path}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to encrypt and save data to '{relative_path}': {e}",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
def decrypt_file(self, relative_path: Path) -> bytes:
|
||||
"""
|
||||
Decrypts data from a specified relative path within the fingerprint directory.
|
||||
|
||||
:param relative_path: Relative path within the fingerprint directory to decrypt the data from.
|
||||
:return: Decrypted data as bytes.
|
||||
"""
|
||||
try:
|
||||
# Define the full path
|
||||
file_path = self.fingerprint_dir / relative_path
|
||||
|
||||
# Read the encrypted data with locking
|
||||
with exclusive_lock(file_path) as fh:
|
||||
fh.seek(0)
|
||||
encrypted_data = fh.read()
|
||||
|
||||
# Decrypt the data
|
||||
decrypted_data = self.decrypt_data(encrypted_data)
|
||||
logger.debug(f"Data decrypted successfully from '{file_path}'.")
|
||||
return decrypted_data
|
||||
except InvalidToken:
|
||||
logger.error(
|
||||
"Invalid encryption key or corrupted data while decrypting file."
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to decrypt data from '{relative_path}': {e}", exc_info=True
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to decrypt data from '{relative_path}': {e}", "red"
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None:
|
||||
"""
|
||||
Encrypts and saves the provided JSON data to the specified relative path within the fingerprint directory.
|
||||
|
||||
:param data: The JSON data to save.
|
||||
:param relative_path: The relative path within the fingerprint directory where data will be saved.
|
||||
Defaults to 'seedpass_entries_db.json.enc'.
|
||||
"""
|
||||
if relative_path is None:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
try:
|
||||
json_data = json.dumps(data, indent=4).encode("utf-8")
|
||||
self.encrypt_and_save_file(json_data, relative_path)
|
||||
logger.debug(f"JSON data encrypted and saved to '{relative_path}'.")
|
||||
print(
|
||||
colored(f"JSON data encrypted and saved to '{relative_path}'.", "green")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to save JSON data to '{relative_path}': {e}", exc_info=True
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to save JSON data to '{relative_path}': {e}", "red"
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
def load_json_data(self, relative_path: Optional[Path] = None) -> dict:
|
||||
"""
|
||||
Decrypts and loads JSON data from the specified relative path within the fingerprint directory.
|
||||
|
||||
:param relative_path: The relative path within the fingerprint directory from which data will be loaded.
|
||||
Defaults to 'seedpass_entries_db.json.enc'.
|
||||
:return: The decrypted JSON data as a dictionary.
|
||||
"""
|
||||
if relative_path is None:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
|
||||
file_path = self.fingerprint_dir / relative_path
|
||||
|
||||
if not file_path.exists():
|
||||
logger.info(
|
||||
f"Index file '{file_path}' does not exist. Initializing empty data."
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Info: Index file '{file_path}' not found. Initializing new password database.",
|
||||
"yellow",
|
||||
)
|
||||
)
|
||||
return {"entries": {}}
|
||||
|
||||
try:
|
||||
decrypted_data = self.decrypt_file(relative_path)
|
||||
json_content = decrypted_data.decode("utf-8").strip()
|
||||
data = json.loads(json_content)
|
||||
logger.debug(f"JSON data loaded and decrypted from '{file_path}': {data}")
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
f"Failed to decode JSON data from '{file_path}': {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
except InvalidToken:
|
||||
logger.error(
|
||||
"Invalid encryption key or corrupted data while decrypting JSON data."
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load JSON data from '{file_path}': {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
def update_checksum(self, relative_path: Optional[Path] = None) -> None:
|
||||
"""
|
||||
Updates the checksum file for the specified file within the fingerprint directory.
|
||||
|
||||
:param relative_path: The relative path within the fingerprint directory for which the checksum will be updated.
|
||||
Defaults to 'seedpass_entries_db.json.enc'.
|
||||
"""
|
||||
if relative_path is None:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
try:
|
||||
file_path = self.fingerprint_dir / relative_path
|
||||
logger.debug("Calculating checksum of the encrypted file bytes.")
|
||||
|
||||
with exclusive_lock(file_path) as fh:
|
||||
fh.seek(0)
|
||||
encrypted_bytes = fh.read()
|
||||
|
||||
checksum = hashlib.sha256(encrypted_bytes).hexdigest()
|
||||
logger.debug(f"New checksum: {checksum}")
|
||||
|
||||
checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt"
|
||||
|
||||
# Write the checksum to the file with locking
|
||||
with exclusive_lock(checksum_file) as fh:
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
fh.write(checksum.encode("utf-8"))
|
||||
fh.flush()
|
||||
|
||||
# Set file permissions to read/write for the user only
|
||||
os.chmod(checksum_file, 0o600)
|
||||
|
||||
logger.debug(
|
||||
f"Checksum for '{file_path}' updated and written to '{checksum_file}'."
|
||||
)
|
||||
print(colored(f"Checksum for '{file_path}' updated.", "green"))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to update checksum for '{relative_path}': {e}", exc_info=True
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to update checksum for '{relative_path}': {e}",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
def get_encrypted_index(self) -> Optional[bytes]:
|
||||
"""
|
||||
Retrieves the encrypted password index file content.
|
||||
|
||||
:return: Encrypted data as bytes or None if the index file does not exist.
|
||||
"""
|
||||
try:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
if not (self.fingerprint_dir / relative_path).exists():
|
||||
# Missing index is normal on first run
|
||||
logger.info(
|
||||
f"Index file '{relative_path}' does not exist in '{self.fingerprint_dir}'."
|
||||
)
|
||||
return None
|
||||
|
||||
file_path = self.fingerprint_dir / relative_path
|
||||
with exclusive_lock(file_path) as fh:
|
||||
fh.seek(0)
|
||||
encrypted_data = fh.read()
|
||||
|
||||
logger.debug(f"Encrypted index data read from '{relative_path}'.")
|
||||
return encrypted_data
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to read encrypted index file '{relative_path}': {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to read encrypted index file '{relative_path}': {e}",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def decrypt_and_save_index_from_nostr(
|
||||
self, encrypted_data: bytes, relative_path: Optional[Path] = None
|
||||
) -> None:
|
||||
"""
|
||||
Decrypts the encrypted data retrieved from Nostr and updates the local index file.
|
||||
|
||||
:param encrypted_data: The encrypted data retrieved from Nostr.
|
||||
:param relative_path: The relative path within the fingerprint directory to update.
|
||||
Defaults to 'seedpass_entries_db.json.enc'.
|
||||
"""
|
||||
if relative_path is None:
|
||||
relative_path = Path("seedpass_entries_db.json.enc")
|
||||
try:
|
||||
decrypted_data = self.decrypt_data(encrypted_data)
|
||||
data = json.loads(decrypted_data.decode("utf-8"))
|
||||
self.save_json_data(data, relative_path)
|
||||
self.update_checksum(relative_path)
|
||||
logger.info("Index file updated from Nostr successfully.")
|
||||
print(colored("Index file updated from Nostr successfully.", "green"))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to decrypt and save data from Nostr: {e}", exc_info=True
|
||||
)
|
||||
print(
|
||||
colored(
|
||||
f"Error: Failed to decrypt and save data from Nostr: {e}", "red"
|
||||
)
|
||||
)
|
||||
# Re-raise the exception to inform the calling function of the failure
|
||||
raise
|
||||
|
||||
def validate_seed(self, seed_phrase: str) -> bool:
|
||||
"""
|
||||
Validates the seed phrase format using BIP-39 standards.
|
||||
|
||||
:param seed_phrase: The BIP39 seed phrase to validate.
|
||||
:return: True if valid, False otherwise.
|
||||
"""
|
||||
try:
|
||||
words = seed_phrase.split()
|
||||
if len(words) != 12:
|
||||
logger.error("Seed phrase does not contain exactly 12 words.")
|
||||
print(
|
||||
colored("Error: Seed phrase must contain exactly 12 words.", "red")
|
||||
)
|
||||
return False
|
||||
# Additional validation can be added here (e.g., word list checks)
|
||||
logger.debug("Seed phrase validated successfully.")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Error validating seed phrase: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to validate seed phrase: {e}", "red"))
|
||||
return False
|
||||
|
||||
def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes:
|
||||
"""
|
||||
Derives a cryptographic seed from a BIP39 mnemonic (seed phrase).
|
||||
|
||||
:param mnemonic: The BIP39 mnemonic phrase.
|
||||
:param passphrase: An optional passphrase for additional security.
|
||||
:return: The derived seed as bytes.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(mnemonic, str):
|
||||
if isinstance(mnemonic, list):
|
||||
mnemonic = " ".join(mnemonic)
|
||||
else:
|
||||
mnemonic = str(mnemonic)
|
||||
if not isinstance(mnemonic, str):
|
||||
raise TypeError("Mnemonic must be a string after conversion")
|
||||
from bip_utils import Bip39SeedGenerator
|
||||
|
||||
seed = Bip39SeedGenerator(mnemonic).Generate(passphrase)
|
||||
logger.debug("Seed derived successfully from mnemonic.")
|
||||
return seed
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to derive seed from mnemonic: {e}", exc_info=True)
|
||||
print(colored(f"Error: Failed to derive seed from mnemonic: {e}", "red"))
|
||||
raise
|
@@ -1,63 +0,0 @@
|
||||
"""Vault utilities for reading and writing encrypted files."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from os import PathLike
|
||||
|
||||
from .encryption import EncryptionManager
|
||||
|
||||
|
||||
class Vault:
|
||||
"""Simple wrapper around :class:`EncryptionManager` for vault storage."""
|
||||
|
||||
INDEX_FILENAME = "seedpass_entries_db.json.enc"
|
||||
CONFIG_FILENAME = "seedpass_config.json.enc"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
encryption_manager: EncryptionManager,
|
||||
fingerprint_dir: Union[str, PathLike[str], Path],
|
||||
):
|
||||
self.encryption_manager = encryption_manager
|
||||
self.fingerprint_dir = Path(fingerprint_dir)
|
||||
self.index_file = self.fingerprint_dir / self.INDEX_FILENAME
|
||||
self.config_file = self.fingerprint_dir / self.CONFIG_FILENAME
|
||||
|
||||
def set_encryption_manager(self, manager: EncryptionManager) -> None:
|
||||
"""Replace the internal encryption manager."""
|
||||
self.encryption_manager = manager
|
||||
|
||||
# ----- Password index helpers -----
|
||||
def load_index(self) -> dict:
|
||||
"""Return decrypted password index data as a dict, applying migrations."""
|
||||
data = self.encryption_manager.load_json_data(self.index_file)
|
||||
from .migrations import apply_migrations, LATEST_VERSION
|
||||
|
||||
version = data.get("schema_version", 0)
|
||||
if version > LATEST_VERSION:
|
||||
raise ValueError(
|
||||
f"File schema version {version} is newer than supported {LATEST_VERSION}"
|
||||
)
|
||||
data = apply_migrations(data)
|
||||
return data
|
||||
|
||||
def save_index(self, data: dict) -> None:
|
||||
"""Encrypt and write password index."""
|
||||
self.encryption_manager.save_json_data(data, self.index_file)
|
||||
|
||||
def get_encrypted_index(self) -> Optional[bytes]:
|
||||
"""Return the encrypted index bytes if present."""
|
||||
return self.encryption_manager.get_encrypted_index()
|
||||
|
||||
def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None:
|
||||
"""Decrypt Nostr payload and overwrite the local index."""
|
||||
self.encryption_manager.decrypt_and_save_index_from_nostr(encrypted_data)
|
||||
|
||||
# ----- Config helpers -----
|
||||
def load_config(self) -> dict:
|
||||
"""Load decrypted configuration."""
|
||||
return self.encryption_manager.load_json_data(self.config_file)
|
||||
|
||||
def save_config(self, config: dict) -> None:
|
||||
"""Encrypt and persist configuration."""
|
||||
self.encryption_manager.save_json_data(config, self.config_file)
|
@@ -28,7 +28,6 @@ Generated on: 2025-04-06
|
||||
├── encryption_manager.py
|
||||
├── event_handler.py
|
||||
├── key_manager.py
|
||||
├── logging_config.py
|
||||
├── utils.py
|
||||
├── utils/
|
||||
├── __init__.py
|
||||
@@ -3082,52 +3081,6 @@ __all__ = ['NostrClient']
|
||||
|
||||
```
|
||||
|
||||
## nostr/logging_config.py
|
||||
```python
|
||||
# nostr/logging_config.py
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Comment out or remove the configure_logging function to avoid conflicts
|
||||
# def configure_logging():
|
||||
# """
|
||||
# Configures logging with both file and console handlers.
|
||||
# Logs include the timestamp, log level, message, filename, and line number.
|
||||
# Only ERROR and higher-level messages are shown in the terminal, while all messages
|
||||
# are logged in the log file.
|
||||
# """
|
||||
# logger = logging.getLogger()
|
||||
# logger.setLevel(logging.DEBUG) # Set root logger to DEBUG
|
||||
#
|
||||
# # Prevent adding multiple handlers if configure_logging is called multiple times
|
||||
# if not logger.handlers:
|
||||
# # Create the 'logs' folder if it doesn't exist
|
||||
# log_directory = 'logs'
|
||||
# if not os.path.exists(log_directory):
|
||||
# os.makedirs(log_directory)
|
||||
#
|
||||
# # Create handlers
|
||||
# c_handler = logging.StreamHandler()
|
||||
# f_handler = logging.FileHandler(os.path.join(log_directory, 'app.log'))
|
||||
#
|
||||
# # Set levels: only errors and critical messages will be shown in the console
|
||||
# c_handler.setLevel(logging.ERROR)
|
||||
# f_handler.setLevel(logging.DEBUG)
|
||||
#
|
||||
# # Create formatters and add them to handlers, include file and line number in log messages
|
||||
# formatter = logging.Formatter(
|
||||
# '%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
|
||||
# )
|
||||
# c_handler.setFormatter(formatter)
|
||||
# f_handler.setFormatter(formatter)
|
||||
#
|
||||
# # Add handlers to the logger
|
||||
# logger.addHandler(c_handler)
|
||||
# logger.addHandler(f_handler)
|
||||
|
||||
```
|
||||
|
||||
## nostr/event_handler.py
|
||||
```python
|
||||
# nostr/event_handler.py
|
||||
|
@@ -1,32 +1,42 @@
|
||||
colorama>=0.4.6
|
||||
termcolor>=1.1.0
|
||||
cryptography>=40.0.2
|
||||
bip-utils>=2.5.0
|
||||
bech32==1.2.0
|
||||
coincurve>=18.0.0
|
||||
mnemonic
|
||||
aiohttp
|
||||
bcrypt
|
||||
pytest>=7.0
|
||||
pytest-cov
|
||||
pytest-xdist
|
||||
portalocker>=2.8
|
||||
nostr-sdk>=0.42.1
|
||||
websocket-client==1.7.0
|
||||
colorama>=0.4.6,<1
|
||||
termcolor>=1.1.0,<4
|
||||
cryptography>=40.0.2,<46
|
||||
bip-utils>=2.5.0,<3
|
||||
bech32>=1.2,<2
|
||||
coincurve>=18.0.0,<22
|
||||
mnemonic>=0.21,<1
|
||||
aiohttp>=3.9,<4
|
||||
bcrypt>=4,<5
|
||||
pytest>=7,<9
|
||||
pytest-cov>=4,<7
|
||||
pytest-xdist>=3,<4
|
||||
portalocker>=2.8,<4
|
||||
nostr-sdk>=0.43,<1
|
||||
websocket-client>=1.7,<2
|
||||
|
||||
websockets>=15.0.0
|
||||
tomli
|
||||
hypothesis
|
||||
mutmut==2.4.4
|
||||
pgpy==0.6.0
|
||||
pyotp>=2.8.0
|
||||
websockets>=15,<16
|
||||
tomli>=2,<3
|
||||
hypothesis>=6,<7
|
||||
mutmut>=2.4.4,<4
|
||||
pgpy>=0.6,<1
|
||||
pyotp>=2.8,<3
|
||||
|
||||
freezegun
|
||||
pyperclip
|
||||
qrcode>=8.2
|
||||
typer>=0.12.3
|
||||
fastapi>=0.116.0
|
||||
uvicorn>=0.35.0
|
||||
httpx>=0.28.1
|
||||
requests>=2.32
|
||||
python-multipart
|
||||
freezegun>=1.5.4,<2
|
||||
typer>=0.12.3,<1
|
||||
|
||||
# Optional dependencies - install as needed for additional features
|
||||
pyperclip>=1.9,<2 # Clipboard support for secret mode
|
||||
qrcode>=8.2,<9 # Generate QR codes for TOTP setup
|
||||
fastapi>=0.110,<1 # API server
|
||||
uvicorn>=0.29,<1 # API server
|
||||
starlette>=0.47.2,<1 # API server
|
||||
httpx>=0.28.1,<1 # API server
|
||||
requests>=2.32,<3 # API server
|
||||
python-multipart>=0.0.20,<0.1 # API server file uploads
|
||||
PyJWT>=2.10.1,<3 # JWT authentication for API server
|
||||
orjson>=3.11.1,<4 # Fast JSON serialization for API server
|
||||
argon2-cffi>=21,<26 # Password hashing for API server
|
||||
toga-core>=0.5.2,<1 # Desktop GUI
|
||||
pillow>=11.3,<12 # Image support for GUI
|
||||
toga-dummy>=0.5.2,<1 # Headless GUI tests
|
||||
slowapi>=0.1.9,<1 # Rate limiting for API server
|
||||
|
35
src/runtime_requirements.txt
Normal file
35
src/runtime_requirements.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
# Runtime dependencies for vendoring/packaging only
|
||||
# Generated from requirements.txt with all test-only packages removed
|
||||
colorama>=0.4.6,<1
|
||||
termcolor>=1.1.0,<4
|
||||
cryptography>=40.0.2,<46
|
||||
bip-utils>=2.5.0,<3
|
||||
bech32>=1.2,<2
|
||||
coincurve>=18.0.0,<22
|
||||
mnemonic>=0.21,<1
|
||||
aiohttp>=3.9,<4
|
||||
bcrypt>=4,<5
|
||||
portalocker>=2.8,<4
|
||||
nostr-sdk>=0.43,<1
|
||||
websocket-client>=1.7,<2
|
||||
|
||||
websockets>=15,<16
|
||||
tomli>=2,<3
|
||||
pgpy>=0.6,<1
|
||||
pyotp>=2.8,<3
|
||||
pyperclip>=1.9,<2
|
||||
qrcode>=8.2,<9
|
||||
typer>=0.12.3,<1
|
||||
fastapi>=0.110,<1
|
||||
uvicorn>=0.29,<1
|
||||
starlette>=0.47.2,<1
|
||||
httpx>=0.28.1,<1
|
||||
requests>=2.32,<3
|
||||
python-multipart>=0.0.20,<0.1
|
||||
PyJWT>=2.10.1,<3
|
||||
orjson>=3.11.1,<4
|
||||
argon2-cffi>=21,<26
|
||||
toga-core>=0.5.2,<1
|
||||
pillow>=11.3,<12
|
||||
toga-dummy>=0.5.2,<1
|
||||
slowapi>=0.1.9,<1
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user