Compare commits
519 Commits
c50e0d448b
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4777cc0bb | ||
|
|
4b657e71f1 | ||
|
|
7901c2758d | ||
|
|
2e41e0bae0 | ||
|
|
b9bd152e9d | ||
|
|
89090a8f30 | ||
|
|
73a2579fa8 | ||
|
|
f3d6c0a880 | ||
|
|
97c8f994e1 | ||
|
|
f3cdbed7b6 | ||
|
|
2d1f0926ae | ||
|
|
0c9387fb1d | ||
|
|
f8296fa03b | ||
|
|
64275bc64f | ||
|
|
2d5b86bf20 | ||
|
|
08bdfbc7c4 | ||
|
|
3811b07014 | ||
|
|
6676d2502b | ||
|
|
615afd7483 | ||
|
|
229e483430 | ||
|
|
c2f3c35ac9 | ||
|
|
530c479f19 | ||
|
|
da7d7d162c | ||
|
|
03d0781c39 | ||
|
|
13c2fc2bd7 | ||
|
|
b9b07ec68d | ||
|
|
17bde162cd | ||
|
|
52400230e0 | ||
|
|
272b6a3845 | ||
|
|
02e05f7a05 | ||
|
|
1e858e1d1f | ||
|
|
bd3d53fddb | ||
|
|
3b09adf3b2 | ||
|
|
4f7ab91f14 | ||
|
|
4a726c2631 | ||
|
|
450de33c0a | ||
|
|
dd0010db62 | ||
|
|
29146439bb | ||
|
|
cf66bd97b7 | ||
|
|
061facd5a9 | ||
|
|
bd6a30155e | ||
|
|
8baef5b3cb | ||
|
|
ddfb95d683 | ||
|
|
7df76c692a | ||
|
|
b4d253c60b | ||
|
|
c16adc4335 | ||
|
|
9a8cdf8e4f | ||
|
|
ade44b4ea1 | ||
|
|
1d4b018f9a | ||
|
|
882a89bedd | ||
|
|
37c20b28a6 | ||
|
|
3553150a53 | ||
|
|
b50f49b597 | ||
|
|
1ec8965910 | ||
|
|
ad6e4a2cd9 | ||
|
|
b768f649a2 | ||
|
|
8b197a7525 | ||
|
|
117716f6cf | ||
|
|
c5e8b52e12 | ||
|
|
a1b66a9147 | ||
|
|
934dfe05c2 | ||
|
|
33d2a4a311 | ||
|
|
f17944a404 | ||
|
|
4851857070 | ||
|
|
a6071b4c0c | ||
|
|
ada00895d4 | ||
|
|
42b746f9af | ||
|
|
762a008171 | ||
|
|
f93bce7388 | ||
|
|
8eabaf5f31 | ||
|
|
04142dc116 | ||
|
|
8739f1f67b | ||
|
|
7d6fd76e86 | ||
|
|
4dc034d846 | ||
|
|
3021ef9d9f | ||
|
|
b2749826b1 | ||
|
|
a332a9e80d | ||
|
|
d45dd10917 | ||
|
|
4d02a50cc8 | ||
|
|
4e9d834920 | ||
|
|
631e9af470 | ||
|
|
b2fc56709a | ||
|
|
b928ed407b | ||
|
|
5d9a7ee8d3 | ||
|
|
006e67c361 | ||
|
|
95d1ff833c | ||
|
|
6bca0b3526 | ||
|
|
f45c275566 | ||
|
|
3e4312ca6f | ||
|
|
4fc1357368 | ||
|
|
518b41e9cd | ||
|
|
df58b0dda1 | ||
|
|
ed9fcbe6ba | ||
|
|
0172a06698 | ||
|
|
1de7cda1b0 | ||
|
|
6d5a2570d4 | ||
|
|
6a1c6d5875 | ||
|
|
6d8f699fcb | ||
|
|
25c9eb52a0 | ||
|
|
2df636e454 | ||
|
|
c0921b134d | ||
|
|
575343dc19 | ||
|
|
0443f6a3b4 | ||
|
|
5e8e617a4d | ||
|
|
1c641b4911 | ||
|
|
efac53d527 | ||
|
|
214e1e49f8 | ||
|
|
af8626fb5f | ||
|
|
9c97f9f939 | ||
|
|
76d092d4f6 | ||
|
|
648dcf386e | ||
|
|
1342228a51 | ||
|
|
d539050aec | ||
|
|
8fd9a05875 | ||
|
|
8a72b5e192 | ||
|
|
ca059e7507 | ||
|
|
c3d8778042 | ||
|
|
900ccf1cf4 | ||
|
|
3caa7af194 | ||
|
|
57237af39e | ||
|
|
5da1e520e3 | ||
|
|
f1c615c0ed | ||
|
|
b270dfedb4 | ||
|
|
a28b456191 | ||
|
|
058a49f68b | ||
|
|
97e351fa61 | ||
|
|
7371eff0bb | ||
|
|
308ef2c974 | ||
|
|
60d7c074c3 | ||
|
|
91536ee50d | ||
|
|
da61529de6 | ||
|
|
7370f119ee | ||
|
|
479e5848f5 | ||
|
|
d038b24c6b | ||
|
|
d6d07a19c1 | ||
|
|
d0047e751f | ||
|
|
8bf21501a5 | ||
|
|
b1af0a11bc | ||
|
|
c67d484152 | ||
|
|
fb1f28161c | ||
|
|
520f6ec72c | ||
|
|
9845febb74 | ||
|
|
15d691abb2 | ||
|
|
b1f9f2fbfc | ||
|
|
61f2f9c18f | ||
|
|
7e07d5d664 | ||
|
|
dc683c7e4c | ||
|
|
8e26c8708b | ||
|
|
b9f44a3d4f | ||
|
|
d6703be2b1 | ||
|
|
81f1f8ec31 | ||
|
|
2739eb4194 | ||
|
|
628e2bd636 | ||
|
|
466efe4b8a | ||
|
|
bbdbcca87b | ||
|
|
27c4ac69cb | ||
|
|
3d3e9ac7f2 | ||
|
|
71d51c0bea | ||
|
|
8f78b6dc01 | ||
|
|
315967f4a1 | ||
|
|
b450ecd1cc | ||
|
|
e6eb698c4c | ||
|
|
8855078179 | ||
|
|
bd8102c9ad | ||
|
|
c91b31a7ca | ||
|
|
bb8b86f0d5 | ||
|
|
ed2d299a92 | ||
|
|
7bd1a9dd7d | ||
|
|
026b94092e | ||
|
|
f7e245d6b0 | ||
|
|
6cbd011705 | ||
|
|
e452d8df02 | ||
|
|
5fbdd30a19 | ||
|
|
61dbb4d3a3 | ||
|
|
8eff96da9d | ||
|
|
39ae2ecbf3 | ||
|
|
4be0bcff83 | ||
|
|
918fdef519 | ||
|
|
f872ab5183 | ||
|
|
6eeb292fd0 | ||
|
|
79b10d6a18 | ||
|
|
eb443c38b4 | ||
|
|
00da7e7931 | ||
|
|
87e63c2f77 | ||
|
|
ef7bd5b848 | ||
|
|
1454cd8165 | ||
|
|
381e8ed496 | ||
|
|
38ba31768a | ||
|
|
71ad91592d | ||
|
|
05b1fae9f4 | ||
|
|
e2260e9df4 | ||
|
|
a634b6c745 | ||
|
|
e2381ed2ec | ||
|
|
6e720554fa | ||
|
|
f0d8758a80 | ||
|
|
e5875249bf | ||
|
|
506ad9711d | ||
|
|
33b3f0b019 | ||
|
|
31672b714d | ||
|
|
f1ae5841bc | ||
|
|
9ed7e7c25b | ||
|
|
ad2c0f9e24 | ||
|
|
c7c103e4d1 | ||
|
|
cf3960186c | ||
|
|
1562a2be47 | ||
|
|
ab5a885f10 | ||
|
|
66981588e7 | ||
|
|
da6f08fa35 | ||
|
|
ecb137a120 | ||
|
|
b29a138411 | ||
|
|
fbd029e4cb | ||
|
|
1f764a4639 | ||
|
|
d6831fcfd8 | ||
|
|
2fda9e0d50 | ||
|
|
ab8839a46a | ||
|
|
6f2e868892 | ||
|
|
0841bddcb5 | ||
|
|
c4905c5ee7 | ||
|
|
16888d5a3a | ||
|
|
9ee876cc4b | ||
|
|
768f0d39a5 | ||
|
|
b7180e70f9 | ||
|
|
41043e92dc | ||
|
|
565366493d | ||
|
|
17ff79d5f6 | ||
|
|
85386eb52a | ||
|
|
218ccb8efa | ||
|
|
c1f48ecb71 | ||
|
|
419408bbad | ||
|
|
06913a0aed | ||
|
|
9ec5e9b4e1 | ||
|
|
2e825a9d33 | ||
|
|
5d9ea37b7f | ||
|
|
f32c14f939 | ||
|
|
7407fe512f | ||
|
|
6d96ca8288 | ||
|
|
536ef2464b | ||
|
|
a32f13b63a | ||
|
|
bd7bef7ce4 | ||
|
|
734325a31f | ||
|
|
7ce57353f2 | ||
|
|
b8dfcd0e97 | ||
|
|
e02f62f961 | ||
|
|
1ffe333697 | ||
|
|
e4949c4c06 | ||
|
|
0b59b94a0b | ||
|
|
08086b9a9e | ||
|
|
57dd186bab | ||
|
|
c66fd520f8 | ||
|
|
b951741366 | ||
|
|
3f0f5b1b28 | ||
|
|
f79a67bb15 | ||
|
|
a7dbf35126 | ||
|
|
086b73b260 | ||
|
|
d8a06346b9 | ||
|
|
beff092818 | ||
|
|
aa1ad99e6e | ||
|
|
2756033bf9 | ||
|
|
e79e80b000 | ||
|
|
214f8da673 | ||
|
|
3aa17e6be2 | ||
|
|
399a276fdd | ||
|
|
f44aedfa76 | ||
|
|
a182c1ac5a | ||
|
|
7fa1f2990f | ||
|
|
8e72ed8714 | ||
|
|
19bb5b5293 | ||
|
|
86b5941875 | ||
|
|
98c962796f | ||
|
|
2c94dfaf90 | ||
|
|
7588a75bdc | ||
|
|
44fc157f35 | ||
|
|
ce59223fc0 | ||
|
|
6c8ebb3548 | ||
|
|
7e0950e364 | ||
|
|
101f0093a4 | ||
|
|
86621f075f | ||
|
|
bd13854f59 | ||
|
|
5089c2b7ea | ||
|
|
9488670b1b | ||
|
|
8f603ec069 | ||
|
|
446949c5ce | ||
|
|
c59e6892d8 | ||
|
|
39db697ce5 | ||
|
|
eb14946f06 | ||
|
|
abfc5aed42 | ||
|
|
b55c59bd35 | ||
|
|
2fa54e2144 | ||
|
|
3b4788e5dc | ||
|
|
7fe54472b3 | ||
|
|
9fbf9bb3ee | ||
|
|
39a8e12438 | ||
|
|
d2cb6d8461 | ||
|
|
0003c3e658 | ||
|
|
5a001a805c | ||
|
|
caebe9f97e | ||
|
|
af050f176c | ||
|
|
3372358b31 | ||
|
|
ab36dbd31a | ||
|
|
9c481422ad | ||
|
|
705b171553 | ||
|
|
6ef7aaca53 | ||
|
|
dcb1590391 | ||
|
|
c5f0449843 | ||
|
|
b9c495cdea | ||
|
|
5217d04034 | ||
|
|
559c881dca | ||
|
|
27ca91234f | ||
|
|
dc660c4ce8 | ||
|
|
63fcfae72c | ||
|
|
511d533de0 | ||
|
|
71c182af9a | ||
|
|
f963ae33af | ||
|
|
0589fe3123 | ||
|
|
6f5ef43fe1 | ||
|
|
6904f729dc | ||
|
|
010c4263ba | ||
|
|
ac15f060e9 | ||
|
|
b03058abd9 | ||
|
|
c9cd3696ae | ||
|
|
083b01aa91 | ||
|
|
3c0f8d2c5c | ||
|
|
9add305a10 | ||
|
|
f32fe93202 | ||
|
|
bbafe7fb7e | ||
|
|
5bc75c9f8a | ||
|
|
976db85a45 | ||
|
|
61b16779ab | ||
|
|
5e04fcf1ca | ||
|
|
ae6b025435 | ||
|
|
a3f13fd2af | ||
|
|
7b5d36603b | ||
|
|
b5743efa67 | ||
|
|
4b7f1fd6d6 | ||
|
|
783cb7cc2b | ||
|
|
fba50b89e8 | ||
|
|
15fcaf9797 | ||
|
|
531af03ff1 | ||
|
|
8a16482b9c | ||
|
|
af432de320 | ||
|
|
025629cacf | ||
|
|
e47945d86a | ||
|
|
b52e49a51e | ||
|
|
6ba9ccfa4c | ||
|
|
e1d32b0379 | ||
|
|
3264cccb60 | ||
|
|
553d9d7ca9 | ||
|
|
3f12543c81 | ||
|
|
2ca563a8cd | ||
|
|
62112f50f9 | ||
|
|
81fbe132ad | ||
|
|
706051530e | ||
|
|
23759dc163 | ||
|
|
3c0b4c1589 | ||
|
|
673981379e | ||
|
|
e084790756 | ||
|
|
560a3c63c4 | ||
|
|
113b0b690a | ||
|
|
99d689b9b0 | ||
|
|
23d4f736e1 | ||
|
|
11c274053b | ||
|
|
24a99ba07a | ||
|
|
beac303a77 | ||
|
|
b80b322853 | ||
|
|
1b51b7dbab | ||
|
|
2b83105149 | ||
|
|
da3c3893bb | ||
|
|
9139dd78a0 | ||
|
|
357455d979 | ||
|
|
69bb58c977 | ||
|
|
4341124d38 | ||
|
|
3238ef4dd4 | ||
|
|
f3b915a635 | ||
|
|
76bb61aa10 | ||
|
|
bc95b047a2 | ||
|
|
dc8097589e | ||
|
|
d090fc421e | ||
|
|
856ceb2d93 | ||
|
|
1d5ad5e59e | ||
|
|
eed11acba2 | ||
|
|
14397b33f0 | ||
|
|
8cc1e777be | ||
|
|
fbb64729ce | ||
|
|
2ff3ab1d7f | ||
|
|
0cef607859 | ||
|
|
3d2b021cb2 | ||
|
|
2d4dcb5f6b | ||
|
|
56ab58cbe9 | ||
|
|
be32ea13c6 | ||
|
|
533bfd5bea | ||
|
|
2fd6daad8e | ||
|
|
c0fba2a8dc | ||
|
|
20144e8e02 | ||
|
|
bd9dd206ac | ||
|
|
7781a51848 | ||
|
|
dc8afcb634 | ||
|
|
b4da5bffcf | ||
|
|
04c9503036 | ||
|
|
14aaac672c | ||
|
|
c03a492ee3 | ||
|
|
ad6d8af2f6 | ||
|
|
a1d733ddeb | ||
|
|
76f34bfcf5 | ||
|
|
e0c511e320 | ||
|
|
65e0d3cb80 | ||
|
|
c3edf9d413 | ||
|
|
20350d509b | ||
|
|
b263c27da9 | ||
|
|
494eedbbb8 | ||
|
|
b8afec3560 | ||
|
|
92b9e64ef9 | ||
|
|
fac2e49cf1 | ||
|
|
f3ce76d9fb | ||
|
|
8c315654ae | ||
|
|
a3871ac890 | ||
|
|
10f249d95e | ||
|
|
a6bad4bb3e | ||
|
|
cbd1dbd706 | ||
|
|
b5015b3e9b | ||
|
|
cc279bac0b | ||
|
|
06c8903e2b | ||
|
|
377d73355b | ||
|
|
ed451041b0 | ||
|
|
fe017455d3 | ||
|
|
89b22cb089 | ||
|
|
5dce2c10f9 | ||
|
|
a50099a066 | ||
|
|
15e6ed9c75 | ||
|
|
589d7b90b4 | ||
|
|
06d21bf7c9 | ||
|
|
6890926e31 | ||
|
|
c8535e11f5 | ||
|
|
7853db061e | ||
|
|
3e0cafb269 | ||
|
|
17bf47611f | ||
|
|
9c49e5e148 | ||
|
|
519a6f0e36 | ||
|
|
20ff1d9f47 | ||
|
|
49b78203f8 | ||
|
|
3cf09faf1e | ||
|
|
557fb95b69 | ||
|
|
9cd5924109 | ||
|
|
08b1735b0e | ||
|
|
c7064183d6 | ||
|
|
950ae3d8dd | ||
|
|
2074677278 | ||
|
|
4a98be0dae | ||
|
|
f673b1ddee | ||
|
|
1fb0f8cc03 | ||
|
|
61b1a9710b | ||
|
|
61d6fb723d | ||
|
|
db3f2e15f2 | ||
|
|
b2d8a759ef | ||
|
|
266761232d | ||
|
|
1a30c4ffe0 | ||
|
|
a5ddbf2e40 | ||
|
|
509db707e0 | ||
|
|
23f7cb76b1 | ||
|
|
a95f92fe71 | ||
|
|
91b4b5b7a4 | ||
|
|
5786d9ef1a | ||
|
|
0b0f1cea73 | ||
|
|
0707628d58 | ||
|
|
316036832c | ||
|
|
ee25ffed41 | ||
|
|
24ed740718 | ||
|
|
bc60f0a6b4 | ||
|
|
0eac9c7991 | ||
|
|
87ead533e5 | ||
|
|
2ea7658036 | ||
|
|
1bd86bdb13 | ||
|
|
1e8ffb02a3 | ||
|
|
6c601fae08 | ||
|
|
69c2c7453b | ||
|
|
9a5ae2c704 | ||
|
|
166f1418f7 | ||
|
|
be6928c0d1 | ||
|
|
cc7247e7f6 | ||
|
|
2524270524 | ||
|
|
b71ebcc418 | ||
|
|
051c98dece | ||
|
|
39f04a0b97 | ||
|
|
ff19faff03 | ||
|
|
baf6416805 | ||
|
|
a56114650a | ||
|
|
1916fa0f81 | ||
|
|
68f2908156 | ||
|
|
cdbd648079 | ||
|
|
c50285c4a5 | ||
|
|
4c26b4c534 | ||
|
|
0551efe69e | ||
|
|
336e90fc84 | ||
|
|
8236a18433 | ||
|
|
9a53b264f2 | ||
|
|
5397d385e6 | ||
|
|
26e68b133c | ||
|
|
a1c9d567b1 | ||
|
|
0c800bcd4f | ||
|
|
b48ff0a05c | ||
|
|
8e63ccc23b | ||
|
|
8093649757 | ||
|
|
029784b67a | ||
|
|
78ffeb4b8d | ||
|
|
b4febbbe45 | ||
|
|
caf360c978 | ||
|
|
ff62970917 | ||
|
|
ea9dee00e1 | ||
|
|
7cf7960aff | ||
|
|
71f7bf9797 | ||
|
|
6866250f78 | ||
|
|
98c20b613c | ||
|
|
eae8fd4a24 | ||
|
|
7baec1cd67 | ||
|
|
c7aab28484 | ||
|
|
847051216d | ||
|
|
0d374f3faf | ||
|
|
822547f349 | ||
|
|
01d5fd5d0d | ||
|
|
596daf320a |
11
.claude/settings.json
Normal file
11
.claude/settings.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,2 +1,16 @@
|
|||||||
target/
|
target/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
|
extension/node_modules/
|
||||||
|
extension/dist/
|
||||||
|
extension/dist-firefox/
|
||||||
|
extension/wasm/
|
||||||
|
reference.jpg
|
||||||
|
ref.jpg
|
||||||
|
tools/relay/node_modules/
|
||||||
|
|
||||||
|
# Local Gitea credentials (do not commit)
|
||||||
|
.gitea_env_vars
|
||||||
|
|
||||||
|
# Scratch reviewer subagent output (raw drafts; canonical notes live in docs/superpowers/reviews/2026-*-dev-*-notes.md)
|
||||||
|
docs/superpowers/reviews/.dev-c-content.md
|
||||||
|
|||||||
218
CHANGELOG.md
Normal file
218
CHANGELOG.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v0.5.0 — 2026-05-02
|
||||||
|
|
||||||
|
Three release trains roll into one tag — backup/restore + LastPass
|
||||||
|
import (originally v0.3.0), device authentication (originally v0.4.0),
|
||||||
|
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
|
||||||
|
two confirmed bugs).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
|
||||||
|
Earlier `relicario-server` builds accepted any commit with a
|
||||||
|
`Good signature` line on stderr regardless of which key signed it —
|
||||||
|
device-auth was a no-op. The hook now builds an `allowed_signers`
|
||||||
|
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
|
||||||
|
global git-config mutation), parses the SSH SHA-256 fingerprint out
|
||||||
|
of `git verify-commit --raw` stderr, and rejects unregistered keys or
|
||||||
|
revoked keys whose committer-date is at or after the revocation
|
||||||
|
timestamp. Bootstrap mode is preserved only when **both**
|
||||||
|
`devices.json` AND `revoked.json` are empty (closes an
|
||||||
|
empty-devices.json privilege-escalation route).
|
||||||
|
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
|
||||||
|
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
|
||||||
|
A new `relicario_core::safe_unpack_git_archive` validates each
|
||||||
|
entry's path components (rejects `..`, absolute paths, Windows
|
||||||
|
drive prefixes), rejects symlinks/hardlinks, and caps total
|
||||||
|
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
|
||||||
|
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
|
||||||
|
check after path-joining as defense-in-depth.
|
||||||
|
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
|
||||||
|
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
|
||||||
|
developer escape hatch, not a user knob) is now
|
||||||
|
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
|
||||||
|
the env-var lookup is removed from the binary by the optimiser.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Strength meter no longer goes stale after the regenerate button (B1).**
|
||||||
|
Programmatic `input.value = newPassword` doesn't fire `input`
|
||||||
|
events; the regenerate handler now dispatches a synthetic
|
||||||
|
`InputEvent('input', { bubbles: true })` so the meter listener
|
||||||
|
re-rates the new value.
|
||||||
|
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
|
||||||
|
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
|
||||||
|
used to render verbatim in the fullscreen vault tab and (in some
|
||||||
|
cases) the popup. New `extension/src/shared/error-copy.ts` central
|
||||||
|
registry maps every service-worker error code to friendly
|
||||||
|
title/body/CTA copy; the popup and fullscreen tab consume the
|
||||||
|
same map. The fullscreen lock screen's `vault_locked` block now
|
||||||
|
reads `Vault locked / Unlock your vault to continue. / [Unlock
|
||||||
|
vault]`. A generated test enumerates the live error codes via
|
||||||
|
grep so the registry can't drift.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sidebar logo in the fullscreen vault tab.** The
|
||||||
|
`vault-sidebar__header` now renders the 16-optimized SVG logo
|
||||||
|
inline before the "Relicario" wordmark (20×20 px, `flex-shrink: 0`
|
||||||
|
so it survives narrow-pane wraps). Popup unaffected.
|
||||||
|
- **Password coloring (P1).** Revealed passwords in the popup
|
||||||
|
item-detail, fullscreen item view, field-history viewer, and
|
||||||
|
generator preview render digits and symbols in distinct colors.
|
||||||
|
Defaults: blue digits, red symbols. Users can override via the
|
||||||
|
new Display section in settings (color pickers + live preview
|
||||||
|
swatch + reset). Defaults round-trip via
|
||||||
|
`chrome.storage.sync.password_display_scheme`; cross-device when
|
||||||
|
Chrome sync is enabled.
|
||||||
|
- **Setup wizard hands off to the fullscreen vault tab on completion
|
||||||
|
(P2).** Both create-new and attach-existing flows now open
|
||||||
|
`vault.html` in a new tab and best-effort close the setup tab
|
||||||
|
after device registration succeeds — replaces the prior
|
||||||
|
setup-tab-stays-open terminal screen.
|
||||||
|
- **Sync now button** in the extension settings view — surfaces the
|
||||||
|
previously hidden `{ type: 'sync' }` SW message to users with success /
|
||||||
|
error feedback.
|
||||||
|
- **Device registration from the popup.** The "Register this device"
|
||||||
|
button on the devices view now opens an inline name input and (on
|
||||||
|
confirm) generates a keypair via WASM, persists the private key + name
|
||||||
|
locally, and writes the device to the remote — no setup-wizard detour.
|
||||||
|
Backed by a new `register_this_device` SW message.
|
||||||
|
- **`relicario settings generator-defaults`** — view-and-edit access to the
|
||||||
|
generator defaults stored in `VaultSettings`. Flags: `--random` /
|
||||||
|
`--bip39` to switch mode, `--length`, `--words`, `--symbols`,
|
||||||
|
`--separator` to update fields of the active mode.
|
||||||
|
- **`relicario edit` now supports TOTP items.** Issuer, label, and secret
|
||||||
|
rotation work; rotated secrets are pushed to `field_history` (key:
|
||||||
|
`core:totp_secret`).
|
||||||
|
- **`relicario history <query>`** — view captured field history. Values
|
||||||
|
are masked by default; `--show` reveals them; `--field <name>` filters
|
||||||
|
to one synthetic key (e.g. `login_password`, `totp_secret`).
|
||||||
|
- **`relicario detach <query> <aid>`** — remove an individual attachment
|
||||||
|
from an item. Refuses to drop a Document item's primary attachment
|
||||||
|
(use `purge` instead).
|
||||||
|
- **`relicario status`** — vault summary: root path, item count
|
||||||
|
(active / trashed), attachment count + total bytes, registered device
|
||||||
|
count, last commit (`%h %s`).
|
||||||
|
- **Backup & restore.** New `relicario backup export <out.relbak>` and
|
||||||
|
`relicario backup restore <in.relbak> [<dir>]` commands. The `.relbak`
|
||||||
|
format is a single encrypted file: Argon2id-derived key from a
|
||||||
|
user-chosen backup passphrase (independent of the vault factor),
|
||||||
|
XChaCha20-Poly1305 ciphertext, zstd-compressed JSON envelope.
|
||||||
|
Reference image and `.git/` history are opt-in inclusions
|
||||||
|
(`--include-image`, `--no-history`).
|
||||||
|
- **Vault-tab Backup & Restore panel.** Export downloads the
|
||||||
|
`.relbak` via `chrome.downloads`. Restore takes a file + backup
|
||||||
|
passphrase + new-remote config and writes the vault into a fresh
|
||||||
|
empty repo (refuses to clobber existing). Git history is never
|
||||||
|
bundled from the extension — CLI is the source of full backups.
|
||||||
|
- **LastPass CSV import.** New `relicario import lastpass <csv>`
|
||||||
|
command + vault-tab Import panel (`vault.html#import`).
|
||||||
|
Logins map to `Login` items; rows with `url == "http://sn"`
|
||||||
|
map to `SecureNote` (extra column → body verbatim, structured
|
||||||
|
data preserved as-is for manual re-categorization). TOTP
|
||||||
|
secrets in the `totp` column are base32-decoded into
|
||||||
|
`LoginCore.totp`; bad base32 surfaces a warning and the login
|
||||||
|
is imported without TOTP. Failed rows (missing `name`, missing
|
||||||
|
password on a login) are skipped with a per-row warning.
|
||||||
|
Each row gets a freshly-minted ID — re-running the import
|
||||||
|
creates duplicates rather than corrupting state.
|
||||||
|
- **Popup deep link to the Import panel.** `settings-vault`
|
||||||
|
gains an "import" section with a `LastPass CSV →` button
|
||||||
|
next to the existing `Backup & restore →` button.
|
||||||
|
- **`relicario status` shows last export age.** New `Last export:
|
||||||
|
<human-readable>` line reading `.relicario/last_backup` (a marker
|
||||||
|
file `cmd_backup_export` writes on success). Reads "never" for
|
||||||
|
fresh vaults, "4 days ago" otherwise.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Form layout in the fullscreen vault tab is now visually consistent
|
||||||
|
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
|
||||||
|
form-actions in fullscreen logins now sit inside a `.form-lower`
|
||||||
|
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
|
||||||
|
the `.form-grid` cards above. Removes the visual rhythm break at the
|
||||||
|
2-col → full-width transition. The popup surface is unchanged.
|
||||||
|
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
|
||||||
|
`docs/architecture/overview.md` now describes four codebases (the
|
||||||
|
`relicario-server` pre-receive hook crate is no longer invisible);
|
||||||
|
`CLAUDE.md` project tree and roadmap reflect current state;
|
||||||
|
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
||||||
|
`generate-hook` subcommands and notes the without-the-hook-it's-
|
||||||
|
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
|
||||||
|
parallel artifact in the vault-creation flow; the foundational
|
||||||
|
design spec gains a "historical" status banner pointing readers at
|
||||||
|
the current docs.
|
||||||
|
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
||||||
|
invoked inside an initialized vault. Explicit flags (`--length`,
|
||||||
|
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
||||||
|
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
||||||
|
set, 5 BIP39 words, space separator).
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- **Mid-restore failure leaves the target remote in a half-written
|
||||||
|
state.** `cmd_backup_restore` and the vault-tab Restore panel both
|
||||||
|
write artifacts sequentially via `writeFileCreateOnly`. If the
|
||||||
|
process is interrupted partway, a retry against the same remote
|
||||||
|
refuses to clobber. Workaround: delete the partial repo and retry.
|
||||||
|
- **Cross-tool backup compatibility.** CLI-exported backups stored
|
||||||
|
attachments at `<item_id>/<aid>.enc`; extension stores at flat
|
||||||
|
`<aid>.bin`. The `.relbak` envelope canonicalizes to `<item_id>/<aid>`
|
||||||
|
keys and each tool translates at the boundary. Round-trip works in
|
||||||
|
both directions.
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
|
||||||
|
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
|
||||||
|
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
|
||||||
|
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
|
||||||
|
builds clean under `-D warnings`.
|
||||||
|
- `Cargo.lock` regenerated and committed; was stale since the
|
||||||
|
`--totp-qr` commit.
|
||||||
|
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
||||||
|
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
||||||
|
extraction; behavior unchanged. The dispatcher matches and delegates.
|
||||||
|
- Extracted pure helpers (`escapeHtml`, `ratePassphrase`, `scheduleRate`,
|
||||||
|
`entropyText`, `STRENGTH_LABELS`) from `extension/src/setup/setup.ts`
|
||||||
|
into `setup-helpers.ts`. State-coupled `updateStrengthUi` stays in
|
||||||
|
`setup.ts` since it walks live wizard state. Setup.ts went from
|
||||||
|
1205 → 1137 lines.
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-04-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Setup wizard could silently overwrite an existing vault.** Pointing the
|
||||||
|
wizard at a remote that already contained a Relicario vault would clobber
|
||||||
|
`manifest.enc`, `.relicario/salt`, and friends with no warning. The wizard
|
||||||
|
now probes the remote after the connection test and refuses to create a
|
||||||
|
new vault on top of an existing one. Affected users whose vault was wiped
|
||||||
|
by this bug should restore from the git history of the affected repo
|
||||||
|
(`git log` + `git checkout <pre-init-sha> -- .`).
|
||||||
|
- **New devices registered during initial setup were silently dropped.** The
|
||||||
|
wizard's Step 5 fired `add_device` over a service-worker channel that
|
||||||
|
required an unlocked vault, which is unavailable mid-wizard. Device pubkeys
|
||||||
|
now write directly to `.relicario/devices.json` from the wizard.
|
||||||
|
- **Wizard-created vaults were missing `settings.enc`.** The CLI's `init`
|
||||||
|
writes a default-`VaultSettings` `settings.enc` alongside `manifest.enc`,
|
||||||
|
but the wizard skipped it, causing every `get_vault_settings` SW call to
|
||||||
|
404. The wizard now encrypts and writes `settings.enc` using a new
|
||||||
|
`default_vault_settings_json` WASM helper that keeps defaults in sync
|
||||||
|
with Rust core.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Attach this device to an existing vault — purely from the GUI.** New
|
||||||
|
Step 0 mode picker splits the wizard into "create new vault" and "attach
|
||||||
|
this device." The attach path takes a passphrase + reference image, fetches
|
||||||
|
the existing manifest, verifies the credentials by decrypting it, and only
|
||||||
|
then registers a new device key. No CLI required for multi-device setup.
|
||||||
|
- `GitHost.lastCommit(path)` and `GitHost.writeFileCreateOnly(path, ...)`.
|
||||||
|
- `default_vault_settings_json()` WASM export.
|
||||||
|
|
||||||
|
## v0.1.0 — 2026-04-22
|
||||||
|
|
||||||
|
Initial release.
|
||||||
72
CLAUDE.md
72
CLAUDE.md
@@ -1,41 +1,63 @@
|
|||||||
# CLAUDE.md — idfoto
|
# CLAUDE.md — Relicario
|
||||||
|
|
||||||
|
## Working with the user
|
||||||
|
|
||||||
|
- **Default to "yes" / the recommended option.** When asking the user a multiple-choice or yes/no decision, pick the recommended answer and proceed without prompting. Optional follow-ups in checklists: do them. Subagent dispatch / running tests / writing code: proceed without checking.
|
||||||
|
- **Always pause and ask** before: `rm`, `rm -rf`, `git push --force`, `git reset --hard`, `git branch -D`, deleting files via Bash, dropping tables, force-pushing to main. The system-prompt's "executing actions with care" guidance still applies — this preference does not override that.
|
||||||
|
- This rule does not override genuine intent-discovery: brainstorming-skill clarifying questions about *what to build* still need user input, because picking a default would mean designing the wrong product.
|
||||||
|
- **Sprinkle Mexican Spanish into replies.** Drop 1–2 Spanish words, slang, exclamations, or idioms per reply (replies only — never in code, file contents, commit messages, or other project artifacts), each followed by `[translation]` in square brackets. Mexican flavor is preferred: ¡órale! [alright!], ¡híjole! [yikes!], ¿qué onda? [what's up?], chido [cool], ahorita [right now / in a bit], no manches [no way], ni modo [oh well], no hay bronca [no problem], ¡ya estuvo! [it's done], etc. Skip in one-word acknowledgements where the flourish would feel awkward.
|
||||||
|
|
||||||
## What is this
|
## What is this
|
||||||
|
|
||||||
idfoto is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
|
Relicario is a git-backed, self-hostable password manager with a Rust core. Two-factor vault decryption: passphrase + a reference JPEG carrying a 256-bit secret embedded via DCT steganography. The server only ever sees opaque ciphertext.
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build # build everything
|
cargo build # build everything
|
||||||
cargo test # run all tests (unit + integration)
|
cargo test # run all tests (unit + integration)
|
||||||
cargo test -p idfoto-core # core library tests only
|
cargo test -p relicario-core # core library tests only
|
||||||
cargo run -- --help # CLI help
|
cargo test -p relicario-cli --test basic_flows # CLI integration tests
|
||||||
cargo run -- generate -l 32 # quick smoke test
|
cargo build -p relicario-wasm --target wasm32-unknown-unknown # WASM target
|
||||||
|
cargo run -p relicario-cli -- --help # CLI help
|
||||||
|
cargo run -p relicario-cli -- generate --length 32 # quick smoke test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/
|
crates/
|
||||||
├── idfoto-core/ # Platform-agnostic library (no filesystem, no git, no network)
|
├── relicario-core/ # Platform-agnostic library (no filesystem, no git, no network)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── lib.rs # Re-exports public API
|
│ │ ├── lib.rs # Re-exports public API
|
||||||
│ │ ├── error.rs # IdfotoError enum (thiserror)
|
│ │ ├── error.rs # RelicarioError enum (thiserror)
|
||||||
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 encrypt/decrypt
|
│ │ ├── crypto.rs # Argon2id KDF (length-prefixed, Zeroizing) + XChaCha20-Poly1305
|
||||||
│ │ ├── entry.rs # Entry, ManifestEntry, Manifest structs (serde)
|
│ │ ├── ids.rs # ItemId, FieldId, content-addressed AttachmentId
|
||||||
│ │ ├── vault.rs # encrypt_entry, decrypt_entry, encrypt_manifest, decrypt_manifest
|
│ │ ├── time.rs # now_unix, MonthYear
|
||||||
│ │ └── imgsecret.rs # DCT-based 256-bit secret embedding in JPEGs
|
│ │ ├── item_types/ # per-type cores + ItemType/ItemCore enums
|
||||||
│ └── tests/
|
│ │ ├── item.rs # Item envelope, Field, FieldKind, FieldValue, Section
|
||||||
│ └── integration.rs # Full-workflow and two-factor independence tests
|
│ │ ├── attachment.rs # AttachmentRef, EncryptedAttachment, encrypt/decrypt helpers
|
||||||
└── idfoto-cli/ # CLI binary
|
│ │ ├── manifest.rs # Browse-without-decrypt index (schema_version 2)
|
||||||
└── src/
|
│ │ ├── settings.rs # VaultSettings: retention, generator defaults, caps
|
||||||
└── main.rs # clap CLI: init, add, get, list, edit, rm, sync, generate, device
|
│ │ ├── generators.rs # CSPRNG password + BIP39 + zxcvbn gate
|
||||||
|
│ │ ├── vault.rs # JSON ↔ AEAD wrappers for Item/Manifest/VaultSettings
|
||||||
|
│ │ └── imgsecret.rs # DCT steganography (MAX_DIMENSION cap)
|
||||||
|
│ └── tests/ # integration.rs, attachments.rs, generators.rs, format_v2.rs, field_history.rs
|
||||||
|
├── relicario-cli/ # `relicario` binary
|
||||||
|
│ ├── src/main.rs # clap surface + command handlers
|
||||||
|
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||||
|
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||||
|
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||||
|
├── relicario-wasm/ # WASM bindings for the extension
|
||||||
|
│ ├── src/lib.rs # #[wasm_bindgen] surface
|
||||||
|
│ └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||||
|
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
|
||||||
|
└── src/main.rs # verify-commit + generate-hook subcommands
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key design decisions
|
## Key design decisions
|
||||||
|
|
||||||
- **idfoto-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
|
- **relicario-core is bytes-in/bytes-out.** No filesystem, no network, no git operations. Makes it portable to WASM, Android, iOS.
|
||||||
- **XChaCha20-Poly1305** over AES-GCM — 192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI.
|
- **XChaCha20-Poly1305** over AES-GCM — 192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI.
|
||||||
- **Single master_key** (no per-entry subkeys) — simpler, sufficient for family vault sizes.
|
- **Single master_key** (no per-entry subkeys) — simpler, sufficient for family vault sizes.
|
||||||
- **imgsecret uses central-embed DCT** — embeds only in the middle 70% of the image (15% crumple zone for crop tolerance), with majority voting across 5-50 redundant copies.
|
- **imgsecret uses central-embed DCT** — embeds only in the middle 70% of the image (15% crumple zone for crop tolerance), with majority voting across 5-50 redundant copies.
|
||||||
@@ -49,25 +71,25 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
|||||||
→ Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
|
→ Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
|
||||||
→ master_key (32 bytes)
|
→ master_key (32 bytes)
|
||||||
→ XChaCha20-Poly1305(nonce=random 24 bytes)
|
→ XChaCha20-Poly1305(nonce=random 24 bytes)
|
||||||
→ encrypted entry/manifest
|
→ encrypted Item/Manifest/VaultSettings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
||||||
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
||||||
- Entry IDs are random 8-char hex strings.
|
- Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
|
||||||
- Git history is preserved as an audit log — no squashing.
|
- Git history is preserved as an audit log — no squashing.
|
||||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
||||||
|
|
||||||
## Remote
|
## Remote
|
||||||
|
|
||||||
Source code: `ssh://git@git.adlee.work:2222/alee/idfoto.git`
|
Source code: `ssh://git@git.adlee.work:2222/alee/relicario.git`
|
||||||
|
|
||||||
## Design spec
|
## Design spec
|
||||||
|
|
||||||
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-idfoto-design.md`
|
Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2026-04-11-relicario-design.md`
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).
|
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.
|
||||||
|
|||||||
2581
Cargo.lock
generated
2581
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/idfoto-core",
|
"crates/relicario-core",
|
||||||
"crates/idfoto-cli",
|
"crates/relicario-cli",
|
||||||
|
"crates/relicario-wasm",
|
||||||
|
"crates/relicario-server",
|
||||||
]
|
]
|
||||||
|
|||||||
110
README.md
110
README.md
@@ -1,4 +1,8 @@
|
|||||||
# idfoto
|
<p align="center">
|
||||||
|
<img src="extension/icons/relicario-logo.svg" alt="Relicario" width="128" height="128">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# Relicario
|
||||||
|
|
||||||
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
|
A git-backed, self-hostable password manager where decryption requires two independent factors: a passphrase you memorize and a reference JPEG that carries a hidden secret. Compromise of either factor alone is insufficient.
|
||||||
|
|
||||||
@@ -19,7 +23,7 @@ Your reference photo (something you have)
|
|||||||
your device (opaque ciphertext)
|
your device (opaque ciphertext)
|
||||||
```
|
```
|
||||||
|
|
||||||
At vault creation, idfoto embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
|
At vault creation, Relicario embeds a random 256-bit secret into a carrier JPEG using DCT steganography. This photo becomes your **reference image** — a second factor that lives on your devices (and optionally as a "dead drop" on social media, since it survives JPEG re-encoding and mild cropping).
|
||||||
|
|
||||||
To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there.
|
To unlock the vault, you provide your passphrase and point the client at the reference image. The client extracts the hidden secret, concatenates it with your passphrase, and runs Argon2id to derive the master key. Everything else follows from there.
|
||||||
|
|
||||||
@@ -29,10 +33,12 @@ To unlock the vault, you provide your passphrase and point the client at the ref
|
|||||||
|
|
||||||
A git repository containing:
|
A git repository containing:
|
||||||
- `manifest.enc` — opaque binary blob
|
- `manifest.enc` — opaque binary blob
|
||||||
- `entries/*.enc` — more opaque binary blobs
|
- `items/*.enc` — more opaque binary blobs
|
||||||
- `.idfoto/salt` — a random 32-byte value (not secret)
|
- `attachments/<item-id>/*.enc` — encrypted attachment blobs
|
||||||
- `.idfoto/params.json` — Argon2id parameters (not secret)
|
- `settings.enc` — encrypted vault settings
|
||||||
- `.idfoto/devices.json` — authorized device public keys
|
- `.relicario/salt` — a random 32-byte value (not secret)
|
||||||
|
- `.relicario/params.json` — Argon2id parameters (not secret)
|
||||||
|
- `.relicario/devices.json` — authorized device public keys
|
||||||
|
|
||||||
That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images.
|
That's it. No plaintext. No metadata about what's inside. No keys, no passphrases, no reference images.
|
||||||
|
|
||||||
@@ -54,7 +60,7 @@ No single point of failure. The two-factor design means the passphrase alone can
|
|||||||
| LastPass | ~40-60 bits (master password only) | 1 |
|
| LastPass | ~40-60 bits (master password only) | 1 |
|
||||||
| Bitwarden | ~40-60 bits (master password only) | 1 |
|
| Bitwarden | ~40-60 bits (master password only) | 1 |
|
||||||
| 1Password | password + 128-bit Secret Key | 2 |
|
| 1Password | password + 128-bit Secret Key | 2 |
|
||||||
| **idfoto** | **password + 256-bit image secret** | **2** |
|
| **Relicario** | **password + 256-bit image secret** | **2** |
|
||||||
|
|
||||||
### What we don't protect against
|
### What we don't protect against
|
||||||
|
|
||||||
@@ -69,31 +75,31 @@ No single point of failure. The two-factor design means the passphrase alone can
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Create a vault (pick any JPEG as the carrier)
|
# Create a vault (pick any JPEG as the carrier)
|
||||||
idfoto init --image vacation.jpg --output reference.jpg
|
relicario init --image vacation.jpg --output reference.jpg
|
||||||
|
|
||||||
# Add a credential
|
# Add a credential
|
||||||
idfoto add
|
relicario add
|
||||||
|
|
||||||
# Retrieve it
|
# Retrieve it
|
||||||
idfoto get github
|
relicario get github
|
||||||
|
|
||||||
# List everything
|
# List everything
|
||||||
idfoto list
|
relicario list
|
||||||
|
|
||||||
# Sync with your git remote
|
# Sync with your git remote
|
||||||
idfoto sync
|
relicario sync
|
||||||
|
|
||||||
# Generate a random password
|
# Generate a random password
|
||||||
idfoto generate -l 32
|
relicario generate -l 32
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variable
|
### Environment variable
|
||||||
|
|
||||||
Set `IDFOTO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
|
Set `RELICARIO_IMAGE=/path/to/reference.jpg` to avoid being prompted for the image path on every command.
|
||||||
|
|
||||||
## The reference image
|
## The reference image
|
||||||
|
|
||||||
The reference JPEG is generated once during `idfoto init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
|
The reference JPEG is generated once during `relicario init`. It looks like a normal photo — because it is one. The 256-bit secret is embedded in the DCT coefficients of the luminance channel using Quantization Index Modulation, with heavy redundancy and Reed-Solomon-style majority voting across multiple copies.
|
||||||
|
|
||||||
The embedding survives:
|
The embedding survives:
|
||||||
- JPEG recompression (tested down to quality 85)
|
- JPEG recompression (tested down to quality 85)
|
||||||
@@ -105,20 +111,31 @@ This means your reference image can live on your Instagram, your personal websit
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
idfoto/
|
relicario/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── idfoto-core/ # Platform-agnostic library (no filesystem, no network)
|
│ ├── relicario-core/ # Platform-agnostic library (no filesystem, no network)
|
||||||
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
|
│ │ ├── crypto.rs # Argon2id KDF + XChaCha20-Poly1305 AEAD
|
||||||
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
|
│ │ ├── imgsecret.rs # DCT steganography: embed/extract 256-bit secrets in JPEGs
|
||||||
│ │ ├── entry.rs # Entry, Manifest data model (serde)
|
│ │ ├── item.rs # Item, Field, Manifest data model (serde)
|
||||||
│ │ └── vault.rs # Encrypt/decrypt entries and manifests
|
│ │ ├── item_types/ # Per-type cores (Login, SecureNote, Card, Identity, Key, Document, Totp)
|
||||||
│ └── idfoto-cli/ # CLI binary: filesystem, git, terminal I/O
|
│ │ ├── attachment.rs # Encrypted attachment helpers (content-addressed)
|
||||||
|
│ │ ├── settings.rs # VaultSettings (retention, generator defaults, caps)
|
||||||
|
│ │ ├── backup.rs # `.relbak` encrypted-backup envelope
|
||||||
|
│ │ ├── device.rs # ed25519 device keys + revocation entries
|
||||||
|
│ │ └── vault.rs # Encrypt/decrypt items, manifest, settings
|
||||||
|
│ ├── relicario-cli/ # CLI binary: filesystem, git, terminal I/O
|
||||||
|
│ ├── relicario-wasm/ # Thin wasm-bindgen wrapper for the browser extension
|
||||||
|
│ └── relicario-server/ # Pre-receive hook: device-signature verification
|
||||||
|
├── extension/ # Chrome MV3 / Firefox WebExtension (TypeScript)
|
||||||
└── docs/
|
└── docs/
|
||||||
|
├── ARCHITECTURE.md # System overview + flow diagrams
|
||||||
|
├── SECURITY.md # Manifest integrity model + threat notes
|
||||||
|
├── architecture/ # Cross-codebase + per-codebase architecture docs
|
||||||
└── superpowers/
|
└── superpowers/
|
||||||
└── specs/ # Design specification with full threat model
|
└── specs/ # Design specifications with full threat model
|
||||||
```
|
```
|
||||||
|
|
||||||
`idfoto-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
|
`relicario-core` takes bytes and returns bytes. It has no knowledge of filesystems, git, or networks. This makes it portable to WASM (browser extension), Android (JNI), and iOS (Swift bridge).
|
||||||
|
|
||||||
### Crypto primitives
|
### Crypto primitives
|
||||||
|
|
||||||
@@ -140,28 +157,33 @@ Every write generates a fresh random nonce. The version byte allows future forma
|
|||||||
|
|
||||||
```
|
```
|
||||||
my-vault.git/
|
my-vault.git/
|
||||||
├── manifest.enc # Encrypted entry index (names, URLs, timestamps)
|
├── manifest.enc # Encrypted item index (names, URLs, timestamps)
|
||||||
├── entries/
|
├── settings.enc # Encrypted vault settings (retention, caps, generator defaults)
|
||||||
│ ├── a1b2c3d4.enc # One encrypted entry per file
|
├── items/
|
||||||
│ └── e5f6a7b8.enc
|
│ ├── a1b2c3d4e5f6a7b8.enc # One encrypted item per file
|
||||||
└── .idfoto/
|
│ └── …
|
||||||
|
├── attachments/
|
||||||
|
│ └── <item-id>/
|
||||||
|
│ └── <aid>.enc # Content-addressed encrypted attachment blob
|
||||||
|
└── .relicario/
|
||||||
├── salt # 32-byte random salt (not secret)
|
├── salt # 32-byte random salt (not secret)
|
||||||
├── params.json # KDF parameters
|
├── params.json # KDF parameters
|
||||||
└── devices.json # Authorized device public keys
|
├── devices.json # Authorized device public keys
|
||||||
|
└── revoked.json # Revoked device records (when device auth is enabled)
|
||||||
```
|
```
|
||||||
|
|
||||||
Entry IDs are random hex strings. Git history is preserved — every add/edit/delete is a commit. "When was this password last rotated?" is answered by `git log`.
|
Item IDs are random 16-char hex strings (64 bits of entropy). Git history is preserved — every add/edit/delete is a commit. "When was this password last rotated?" is answered by `git log` and by the per-item field history.
|
||||||
|
|
||||||
## Device management
|
## Device management
|
||||||
|
|
||||||
Each device generates its own ed25519 keypair. The public key is stored in `.idfoto/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
|
Each device generates its own ed25519 keypair. The public key is stored in `.relicario/devices.json` (committed to the repo). Device keys are used for commit signing — they do NOT participate in vault decryption.
|
||||||
|
|
||||||
Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed.
|
Revoking a device: remove its key from `devices.json` and commit. No passphrase or reference image rotation needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
idfoto device add --name laptop
|
relicario device add --name laptop
|
||||||
idfoto device list
|
relicario device list
|
||||||
idfoto device revoke laptop
|
relicario device revoke laptop
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
@@ -169,23 +191,27 @@ idfoto device revoke laptop
|
|||||||
Requires Rust stable (1.70+).
|
Requires Rust stable (1.70+).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone ssh://git@git.adlee.work:2222/alee/idfoto.git
|
git clone ssh://git@git.adlee.work:2222/alee/relicario.git
|
||||||
cd idfoto
|
cd relicario
|
||||||
cargo build --release
|
cargo build --release
|
||||||
cargo test
|
cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary is at `target/release/idfoto`.
|
The binary is at `target/release/relicario`.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [ ] WASM build + Chrome browser extension (inline crypto, no native messaging)
|
- [x] WASM build + Chrome MV3 browser extension (inline crypto, no native messaging)
|
||||||
- [ ] Secure notes (free-form encrypted text entries)
|
- [x] Firefox WebExtension build
|
||||||
- [ ] Secure document storage (encrypted file attachments up to 5-10 MB)
|
- [x] Typed items: Login, SecureNote, Identity, Card, Key, Document, TOTP
|
||||||
- [ ] `idfoto unlock` daemon (ssh-agent-style, holds master key for a TTL)
|
- [x] Secure document storage (encrypted file attachments)
|
||||||
|
- [x] Backup & restore (`.relbak` encrypted envelope)
|
||||||
|
- [x] LastPass CSV import
|
||||||
|
- [x] Device authentication (ed25519 commit signing + pre-receive hook)
|
||||||
|
- [ ] Import from Bitwarden / 1Password
|
||||||
|
- [ ] `relicario unlock` daemon (ssh-agent-style, holds master key for a TTL)
|
||||||
- [ ] Android/iOS clients (Rust core compiles to ARM)
|
- [ ] Android/iOS clients (Rust core compiles to ARM)
|
||||||
- [ ] Import from LastPass/Bitwarden/1Password
|
- [ ] Safari extension
|
||||||
- [ ] Firefox/Safari extensions
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "idfoto-cli"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "CLI for idfoto password manager"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "idfoto"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
idfoto-core = { path = "../idfoto-core" }
|
|
||||||
clap = { version = "4", features = ["derive"] }
|
|
||||||
anyhow = "1"
|
|
||||||
rpassword = "5"
|
|
||||||
arboard = "3"
|
|
||||||
dirs = "5"
|
|
||||||
hex = "0.4"
|
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|
||||||
rand = "0.8"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
@@ -1,716 +0,0 @@
|
|||||||
use anyhow::{bail, Context, Result};
|
|
||||||
use clap::{Parser, Subcommand};
|
|
||||||
use idfoto_core::{
|
|
||||||
decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id,
|
|
||||||
Entry, KdfParams, Manifest, ManifestEntry,
|
|
||||||
};
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use rand::RngCore;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs;
|
|
||||||
use std::io::{self, BufRead, Write};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
// ─── CLI structure ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(
|
|
||||||
name = "idfoto",
|
|
||||||
version,
|
|
||||||
about = "Git-backed password manager with reference image authentication"
|
|
||||||
)]
|
|
||||||
struct Cli {
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Commands,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Commands {
|
|
||||||
/// Initialize a new idfoto vault
|
|
||||||
Init {
|
|
||||||
#[arg(long)]
|
|
||||||
image: PathBuf,
|
|
||||||
#[arg(long, default_value = "reference.jpg")]
|
|
||||||
output: PathBuf,
|
|
||||||
},
|
|
||||||
/// Add a new password entry
|
|
||||||
Add,
|
|
||||||
/// Get a password entry by name
|
|
||||||
Get { name: String },
|
|
||||||
/// List all entries
|
|
||||||
List,
|
|
||||||
/// Edit an existing entry
|
|
||||||
Edit { name: String },
|
|
||||||
/// Remove an entry
|
|
||||||
Rm { name: String },
|
|
||||||
/// Sync vault with git remote
|
|
||||||
Sync,
|
|
||||||
/// Generate a random password
|
|
||||||
Generate {
|
|
||||||
#[arg(short, long, default_value = "20")]
|
|
||||||
length: usize,
|
|
||||||
},
|
|
||||||
/// Manage devices
|
|
||||||
Device {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: DeviceCommands,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum DeviceCommands {
|
|
||||||
/// Add a new device
|
|
||||||
Add {
|
|
||||||
#[arg(long)]
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
/// List registered devices
|
|
||||||
List,
|
|
||||||
/// Revoke a device
|
|
||||||
Revoke { name: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Device entry ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct DeviceEntry {
|
|
||||||
name: String,
|
|
||||||
public_key: String, // hex-encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helper functions ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn vault_dir() -> PathBuf {
|
|
||||||
std::env::current_dir().expect("failed to get current directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn idfoto_dir() -> PathBuf {
|
|
||||||
vault_dir().join(".idfoto")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_salt() -> Result<[u8; 32]> {
|
|
||||||
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
|
|
||||||
let mut salt = [0u8; 32];
|
|
||||||
if data.len() != 32 {
|
|
||||||
bail!("invalid salt file: expected 32 bytes, got {}", data.len());
|
|
||||||
}
|
|
||||||
salt.copy_from_slice(&data);
|
|
||||||
Ok(salt)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_params() -> Result<KdfParams> {
|
|
||||||
let data = fs::read_to_string(idfoto_dir().join("params.json"))
|
|
||||||
.context("failed to read params.json")?;
|
|
||||||
let params: KdfParams = serde_json::from_str(&data).context("failed to parse params.json")?;
|
|
||||||
Ok(params)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_image_path() -> Result<PathBuf> {
|
|
||||||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
|
||||||
return Ok(PathBuf::from(path));
|
|
||||||
}
|
|
||||||
let path = prompt("Reference image path")?;
|
|
||||||
Ok(PathBuf::from(path))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
|
||||||
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
|
|
||||||
|
|
||||||
let jpeg_data = fs::read(image_path).context("failed to read reference image")?;
|
|
||||||
let image_secret =
|
|
||||||
idfoto_core::imgsecret::extract(&jpeg_data).context("failed to extract image secret")?;
|
|
||||||
|
|
||||||
let salt = read_salt()?;
|
|
||||||
let params = read_params()?;
|
|
||||||
|
|
||||||
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
|
||||||
.context("failed to derive master key")?;
|
|
||||||
|
|
||||||
Ok(master_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
|
|
||||||
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
|
|
||||||
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
|
|
||||||
Ok(manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
|
||||||
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
|
|
||||||
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_commit(message: &str) -> Result<()> {
|
|
||||||
let status = Command::new("git")
|
|
||||||
.args(["add", "-A"])
|
|
||||||
.status()
|
|
||||||
.context("failed to run git add")?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("git add failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = Command::new("git")
|
|
||||||
.args(["commit", "-m", message])
|
|
||||||
.status()
|
|
||||||
.context("failed to run git commit")?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("git commit failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_iso8601() -> String {
|
|
||||||
let duration = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!("{}", duration.as_secs())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt(message: &str) -> Result<String> {
|
|
||||||
eprint!("{}: ", message);
|
|
||||||
io::stderr().flush()?;
|
|
||||||
let mut line = String::new();
|
|
||||||
io::stdin().lock().read_line(&mut line)?;
|
|
||||||
Ok(line.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
|
||||||
let value = prompt(message)?;
|
|
||||||
if value.is_empty() {
|
|
||||||
Ok(None)
|
|
||||||
} else {
|
|
||||||
Ok(Some(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
|
||||||
eprint!("{} [{}]: ", field, current);
|
|
||||||
io::stderr().flush()?;
|
|
||||||
let mut line = String::new();
|
|
||||||
io::stdin().lock().read_line(&mut line)?;
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
Ok(current.to_string())
|
|
||||||
} else {
|
|
||||||
Ok(trimmed.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_password(length: usize) -> String {
|
|
||||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
|
||||||
let mut rng = OsRng;
|
|
||||||
(0..length)
|
|
||||||
.map(|_| {
|
|
||||||
let idx = (rng.next_u32() as usize) % CHARSET.len();
|
|
||||||
CHARSET[idx] as char
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Command implementations ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|
||||||
// 1. Read carrier JPEG
|
|
||||||
let carrier = fs::read(&image).context("failed to read carrier image")?;
|
|
||||||
|
|
||||||
// 2. Generate random image_secret
|
|
||||||
let mut image_secret = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut image_secret);
|
|
||||||
|
|
||||||
// 3. Embed secret into carrier
|
|
||||||
let reference_jpeg =
|
|
||||||
idfoto_core::imgsecret::embed(&carrier, &image_secret).context("failed to embed secret")?;
|
|
||||||
|
|
||||||
// 4. Save reference JPEG
|
|
||||||
fs::write(&output, &reference_jpeg).context("failed to write reference image")?;
|
|
||||||
eprintln!("Reference image saved to {}", output.display());
|
|
||||||
|
|
||||||
// 5. Prompt for passphrase
|
|
||||||
let passphrase = loop {
|
|
||||||
let p1 = rpassword::prompt_password_stderr("Passphrase (min 8 chars): ")
|
|
||||||
.context("failed to read passphrase")?;
|
|
||||||
if p1.len() < 8 {
|
|
||||||
eprintln!("Passphrase must be at least 8 characters.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let p2 = rpassword::prompt_password_stderr("Confirm passphrase: ")
|
|
||||||
.context("failed to read passphrase confirmation")?;
|
|
||||||
if p1 != p2 {
|
|
||||||
eprintln!("Passphrases do not match.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break p1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 6. Generate random salt
|
|
||||||
let mut salt = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut salt);
|
|
||||||
|
|
||||||
// 7. Derive master key
|
|
||||||
let params = KdfParams::default();
|
|
||||||
let master_key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)
|
|
||||||
.context("failed to derive master key")?;
|
|
||||||
|
|
||||||
// 8. Create directory structure
|
|
||||||
let idfoto = idfoto_dir();
|
|
||||||
fs::create_dir_all(&idfoto).context("failed to create .idfoto directory")?;
|
|
||||||
fs::create_dir_all(vault_dir().join("entries")).context("failed to create entries directory")?;
|
|
||||||
|
|
||||||
// 9. Write config files
|
|
||||||
fs::write(idfoto.join("salt"), &salt).context("failed to write salt")?;
|
|
||||||
fs::write(
|
|
||||||
idfoto.join("params.json"),
|
|
||||||
serde_json::to_string_pretty(¶ms)?,
|
|
||||||
)
|
|
||||||
.context("failed to write params.json")?;
|
|
||||||
fs::write(idfoto.join("devices.json"), "[]").context("failed to write devices.json")?;
|
|
||||||
|
|
||||||
// 10. Encrypt empty manifest
|
|
||||||
let manifest = Manifest::new();
|
|
||||||
let manifest_enc = encrypt_manifest(&master_key, &manifest).context("failed to encrypt manifest")?;
|
|
||||||
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
|
|
||||||
.context("failed to write manifest.enc")?;
|
|
||||||
|
|
||||||
// 11. Create .gitignore
|
|
||||||
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
|
|
||||||
.context("failed to write .gitignore")?;
|
|
||||||
|
|
||||||
// 12. Git init and commit
|
|
||||||
let status = Command::new("git").arg("init").status()?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("git init failed");
|
|
||||||
}
|
|
||||||
git_commit("feat: initialize idfoto vault")?;
|
|
||||||
|
|
||||||
// 13. Success
|
|
||||||
eprintln!("Vault initialized successfully.");
|
|
||||||
eprintln!("IMPORTANT: Keep your reference image safe — you need it to unlock the vault.");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_generate(length: usize) -> Result<()> {
|
|
||||||
println!("{}", generate_password(length));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_add() -> Result<()> {
|
|
||||||
let image_path = get_image_path()?;
|
|
||||||
let master_key = unlock(&image_path)?;
|
|
||||||
|
|
||||||
let name = prompt("Name")?;
|
|
||||||
if name.is_empty() {
|
|
||||||
bail!("Name cannot be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = prompt_optional("URL (optional)")?;
|
|
||||||
let username = prompt_optional("Username (optional)")?;
|
|
||||||
|
|
||||||
let password = {
|
|
||||||
let p = prompt_optional("Password (Enter to auto-generate)")?;
|
|
||||||
match p {
|
|
||||||
Some(pw) if !pw.is_empty() => pw,
|
|
||||||
_ => {
|
|
||||||
let gen = generate_password(20);
|
|
||||||
eprintln!("Generated password: {}", gen);
|
|
||||||
gen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let notes = prompt_optional("Notes (optional)")?;
|
|
||||||
let totp_secret = prompt_optional("TOTP secret (optional)")?;
|
|
||||||
|
|
||||||
let now = now_iso8601();
|
|
||||||
let entry = Entry {
|
|
||||||
name: name.clone(),
|
|
||||||
url: url.clone(),
|
|
||||||
username: username.clone(),
|
|
||||||
password,
|
|
||||||
notes,
|
|
||||||
totp_secret,
|
|
||||||
created_at: now.clone(),
|
|
||||||
updated_at: now.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let entry_id = generate_entry_id();
|
|
||||||
let encrypted = encrypt_entry(&master_key, &entry).context("failed to encrypt entry")?;
|
|
||||||
fs::write(
|
|
||||||
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
|
|
||||||
encrypted,
|
|
||||||
)
|
|
||||||
.context("failed to write entry file")?;
|
|
||||||
|
|
||||||
let mut manifest = read_manifest(&master_key)?;
|
|
||||||
manifest.add_entry(
|
|
||||||
entry_id.clone(),
|
|
||||||
ManifestEntry {
|
|
||||||
name: name.clone(),
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
updated_at: now,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
write_manifest(&master_key, &manifest)?;
|
|
||||||
|
|
||||||
git_commit(&format!("feat: add entry '{}'", name))?;
|
|
||||||
eprintln!("Entry '{}' added (id: {})", name, entry_id);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
|
|
||||||
let results = manifest.search(query);
|
|
||||||
if results.is_empty() {
|
|
||||||
bail!("no entries matching '{}'", query);
|
|
||||||
}
|
|
||||||
|
|
||||||
if results.len() == 1 {
|
|
||||||
let (id, entry) = results[0];
|
|
||||||
return Ok((id.clone(), entry.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("Multiple matches:");
|
|
||||||
for (i, (id, entry)) in results.iter().enumerate() {
|
|
||||||
eprintln!(
|
|
||||||
" {}) {} (id: {}, url: {})",
|
|
||||||
i + 1,
|
|
||||||
entry.name,
|
|
||||||
id,
|
|
||||||
entry.url.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let choice = prompt("Choose entry number")?;
|
|
||||||
let idx: usize = choice.parse::<usize>().context("invalid number")? - 1;
|
|
||||||
if idx >= results.len() {
|
|
||||||
bail!("invalid selection");
|
|
||||||
}
|
|
||||||
|
|
||||||
let (id, entry) = results[idx];
|
|
||||||
Ok((id.clone(), entry.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_get(query: String) -> Result<()> {
|
|
||||||
let image_path = get_image_path()?;
|
|
||||||
let master_key = unlock(&image_path)?;
|
|
||||||
|
|
||||||
let manifest = read_manifest(&master_key)?;
|
|
||||||
let (entry_id, _) = search_and_select(&manifest, &query)?;
|
|
||||||
|
|
||||||
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
|
|
||||||
.context("failed to read entry file")?;
|
|
||||||
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
|
|
||||||
|
|
||||||
println!("Name: {}", entry.name);
|
|
||||||
println!(
|
|
||||||
"URL: {}",
|
|
||||||
entry.url.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"Username: {}",
|
|
||||||
entry.username.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
println!("Password: {}", entry.password);
|
|
||||||
if let Some(notes) = &entry.notes {
|
|
||||||
println!("Notes: {}", notes);
|
|
||||||
}
|
|
||||||
if let Some(totp) = &entry.totp_secret {
|
|
||||||
println!("TOTP: {}", totp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy password to clipboard with 30s TTL
|
|
||||||
match arboard::Clipboard::new() {
|
|
||||||
Ok(mut clipboard) => {
|
|
||||||
if clipboard.set_text(&entry.password).is_ok() {
|
|
||||||
eprintln!("Password copied to clipboard (clearing in 30s)");
|
|
||||||
let pw = entry.password.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
|
||||||
if let Ok(mut cb) = arboard::Clipboard::new() {
|
|
||||||
if let Ok(current) = cb.get_text() {
|
|
||||||
if current == pw {
|
|
||||||
let _ = cb.set_text("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
eprintln!("(clipboard unavailable)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_list() -> Result<()> {
|
|
||||||
let image_path = get_image_path()?;
|
|
||||||
let master_key = unlock(&image_path)?;
|
|
||||||
|
|
||||||
let manifest = read_manifest(&master_key)?;
|
|
||||||
|
|
||||||
let mut entries: Vec<_> = manifest.entries.iter().collect();
|
|
||||||
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
|
|
||||||
|
|
||||||
if entries.is_empty() {
|
|
||||||
eprintln!("No entries in vault.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{:<10} {:<30} {:<30} {}", "ID", "Name", "URL", "Username");
|
|
||||||
println!("{}", "-".repeat(80));
|
|
||||||
for (id, entry) in entries {
|
|
||||||
println!(
|
|
||||||
"{:<10} {:<30} {:<30} {}",
|
|
||||||
id,
|
|
||||||
entry.name,
|
|
||||||
entry.url.as_deref().unwrap_or("-"),
|
|
||||||
entry.username.as_deref().unwrap_or("-")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_edit(query: String) -> Result<()> {
|
|
||||||
let image_path = get_image_path()?;
|
|
||||||
let master_key = unlock(&image_path)?;
|
|
||||||
|
|
||||||
let manifest = read_manifest(&master_key)?;
|
|
||||||
let (entry_id, _) = search_and_select(&manifest, &query)?;
|
|
||||||
|
|
||||||
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
|
|
||||||
.context("failed to read entry file")?;
|
|
||||||
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
|
|
||||||
|
|
||||||
eprintln!("Editing '{}' (Enter to keep current value)", entry.name);
|
|
||||||
|
|
||||||
let name = prompt_with_default("Name", &entry.name)?;
|
|
||||||
let url = prompt_with_default("URL", entry.url.as_deref().unwrap_or(""))?;
|
|
||||||
let url = if url.is_empty() { None } else { Some(url) };
|
|
||||||
let username = prompt_with_default("Username", entry.username.as_deref().unwrap_or(""))?;
|
|
||||||
let username = if username.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(username)
|
|
||||||
};
|
|
||||||
let password = prompt_with_default("Password", &entry.password)?;
|
|
||||||
let notes = prompt_with_default("Notes", entry.notes.as_deref().unwrap_or(""))?;
|
|
||||||
let notes = if notes.is_empty() { None } else { Some(notes) };
|
|
||||||
let totp_secret = prompt_with_default("TOTP secret", entry.totp_secret.as_deref().unwrap_or(""))?;
|
|
||||||
let totp_secret = if totp_secret.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(totp_secret)
|
|
||||||
};
|
|
||||||
|
|
||||||
let now = now_iso8601();
|
|
||||||
let updated_entry = Entry {
|
|
||||||
name: name.clone(),
|
|
||||||
url: url.clone(),
|
|
||||||
username: username.clone(),
|
|
||||||
password,
|
|
||||||
notes,
|
|
||||||
totp_secret,
|
|
||||||
created_at: entry.created_at,
|
|
||||||
updated_at: now.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let encrypted = encrypt_entry(&master_key, &updated_entry).context("failed to encrypt entry")?;
|
|
||||||
fs::write(
|
|
||||||
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
|
|
||||||
encrypted,
|
|
||||||
)
|
|
||||||
.context("failed to write entry file")?;
|
|
||||||
|
|
||||||
let mut manifest = read_manifest(&master_key)?;
|
|
||||||
manifest.add_entry(
|
|
||||||
entry_id,
|
|
||||||
ManifestEntry {
|
|
||||||
name: name.clone(),
|
|
||||||
url,
|
|
||||||
username,
|
|
||||||
updated_at: now,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
write_manifest(&master_key, &manifest)?;
|
|
||||||
|
|
||||||
git_commit(&format!("feat: edit entry '{}'", name))?;
|
|
||||||
eprintln!("Entry '{}' updated.", name);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_rm(query: String) -> Result<()> {
|
|
||||||
let image_path = get_image_path()?;
|
|
||||||
let master_key = unlock(&image_path)?;
|
|
||||||
|
|
||||||
let manifest = read_manifest(&master_key)?;
|
|
||||||
let (entry_id, entry) = search_and_select(&manifest, &query)?;
|
|
||||||
|
|
||||||
let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?;
|
|
||||||
if confirm.to_lowercase() != "y" {
|
|
||||||
eprintln!("Cancelled.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry_path = vault_dir()
|
|
||||||
.join("entries")
|
|
||||||
.join(format!("{}.enc", entry_id));
|
|
||||||
if entry_path.exists() {
|
|
||||||
fs::remove_file(&entry_path).context("failed to remove entry file")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut manifest = read_manifest(&master_key)?;
|
|
||||||
manifest.remove_entry(&entry_id);
|
|
||||||
write_manifest(&master_key, &manifest)?;
|
|
||||||
|
|
||||||
git_commit(&format!("feat: remove entry '{}'", entry.name))?;
|
|
||||||
eprintln!("Entry '{}' removed.", entry.name);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_sync() -> Result<()> {
|
|
||||||
eprintln!("Pulling...");
|
|
||||||
let status = Command::new("git")
|
|
||||||
.args(["pull", "--rebase"])
|
|
||||||
.status()
|
|
||||||
.context("failed to run git pull")?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("git pull --rebase failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("Pushing...");
|
|
||||||
let status = Command::new("git")
|
|
||||||
.arg("push")
|
|
||||||
.status()
|
|
||||||
.context("failed to run git push")?;
|
|
||||||
if !status.success() {
|
|
||||||
bail!("git push failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("Sync complete.");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Device management ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
|
||||||
let path = idfoto_dir().join("devices.json");
|
|
||||||
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
|
|
||||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&data).context("failed to parse devices.json")?;
|
|
||||||
Ok(devices)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
|
||||||
let data = serde_json::to_string_pretty(devices)?;
|
|
||||||
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_device_add(name: String) -> Result<()> {
|
|
||||||
use ed25519_dalek::SigningKey;
|
|
||||||
|
|
||||||
let mut devices = read_devices()?;
|
|
||||||
|
|
||||||
// Check for duplicate
|
|
||||||
if devices.iter().any(|d| d.name == name) {
|
|
||||||
bail!("device '{}' already exists", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate ed25519 keypair
|
|
||||||
let signing_key = SigningKey::generate(&mut OsRng);
|
|
||||||
let verifying_key = signing_key.verifying_key();
|
|
||||||
|
|
||||||
let private_key_hex = hex::encode(signing_key.to_bytes());
|
|
||||||
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
|
||||||
|
|
||||||
// Save private key
|
|
||||||
let config_dir = dirs::config_dir()
|
|
||||||
.context("failed to find config directory")?
|
|
||||||
.join("idfoto");
|
|
||||||
fs::create_dir_all(&config_dir).context("failed to create config directory")?;
|
|
||||||
let key_path = config_dir.join(format!("{}.key", name));
|
|
||||||
fs::write(&key_path, &private_key_hex).context("failed to write private key")?;
|
|
||||||
|
|
||||||
// Set restrictive permissions on the key file (Unix only)
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to devices.json
|
|
||||||
devices.push(DeviceEntry {
|
|
||||||
name: name.clone(),
|
|
||||||
public_key: public_key_hex,
|
|
||||||
});
|
|
||||||
write_devices(&devices)?;
|
|
||||||
|
|
||||||
git_commit(&format!("feat: add device '{}'", name))?;
|
|
||||||
eprintln!("Device '{}' added.", name);
|
|
||||||
eprintln!("Private key saved to {}", key_path.display());
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_device_list() -> Result<()> {
|
|
||||||
let devices = read_devices()?;
|
|
||||||
|
|
||||||
if devices.is_empty() {
|
|
||||||
eprintln!("No devices registered.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{:<20} {}", "Name", "Public Key");
|
|
||||||
println!("{}", "-".repeat(60));
|
|
||||||
for device in &devices {
|
|
||||||
println!("{:<20} {}", device.name, device.public_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_device_revoke(name: String) -> Result<()> {
|
|
||||||
let mut devices = read_devices()?;
|
|
||||||
let initial_len = devices.len();
|
|
||||||
devices.retain(|d| d.name != name);
|
|
||||||
|
|
||||||
if devices.len() == initial_len {
|
|
||||||
bail!("device '{}' not found", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
write_devices(&devices)?;
|
|
||||||
git_commit(&format!("feat: revoke device '{}'", name))?;
|
|
||||||
eprintln!("Device '{}' revoked.", name);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
let cli = Cli::parse();
|
|
||||||
|
|
||||||
match cli.command {
|
|
||||||
Commands::Init { image, output } => cmd_init(image, output),
|
|
||||||
Commands::Add => cmd_add(),
|
|
||||||
Commands::Get { name } => cmd_get(name),
|
|
||||||
Commands::List => cmd_list(),
|
|
||||||
Commands::Edit { name } => cmd_edit(name),
|
|
||||||
Commands::Rm { name } => cmd_rm(name),
|
|
||||||
Commands::Sync => cmd_sync(),
|
|
||||||
Commands::Generate { length } => cmd_generate(length),
|
|
||||||
Commands::Device { action } => match action {
|
|
||||||
DeviceCommands::Add { name } => cmd_device_add(name),
|
|
||||||
DeviceCommands::List => cmd_device_list(),
|
|
||||||
DeviceCommands::Revoke { name } => cmd_device_revoke(name),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "idfoto-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Core library for idfoto password manager"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
thiserror = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
argon2 = "0.5"
|
|
||||||
chacha20poly1305 = "0.10"
|
|
||||||
rand = "0.8"
|
|
||||||
sha2 = "0.10"
|
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
use argon2::{Algorithm, Argon2, Params, Version};
|
|
||||||
use chacha20poly1305::{
|
|
||||||
aead::{Aead, KeyInit},
|
|
||||||
XChaCha20Poly1305, XNonce,
|
|
||||||
};
|
|
||||||
use rand::{rngs::OsRng, RngCore};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::error::{IdfotoError, Result};
|
|
||||||
|
|
||||||
const VERSION_BYTE: u8 = 0x01;
|
|
||||||
const NONCE_LEN: usize = 24;
|
|
||||||
const TAG_LEN: usize = 16;
|
|
||||||
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
|
|
||||||
|
|
||||||
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
let cipher = XChaCha20Poly1305::new(key.into());
|
|
||||||
|
|
||||||
let mut nonce_bytes = [0u8; NONCE_LEN];
|
|
||||||
OsRng.fill_bytes(&mut nonce_bytes);
|
|
||||||
let nonce = XNonce::from(nonce_bytes);
|
|
||||||
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(&nonce, plaintext)
|
|
||||||
.map_err(|e| IdfotoError::Encrypt(e.to_string()))?;
|
|
||||||
|
|
||||||
// Output: version(1) || nonce(24) || ciphertext+tag
|
|
||||||
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
|
||||||
output.push(VERSION_BYTE);
|
|
||||||
output.extend_from_slice(&nonce_bytes);
|
|
||||||
output.extend_from_slice(&ciphertext);
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
if data.len() < HEADER_LEN + TAG_LEN {
|
|
||||||
return Err(IdfotoError::Format(
|
|
||||||
"data too short to be valid ciphertext".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let version = data[0];
|
|
||||||
if version != VERSION_BYTE {
|
|
||||||
return Err(IdfotoError::Format(format!(
|
|
||||||
"unknown version byte: 0x{:02x}",
|
|
||||||
version
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
|
|
||||||
let ciphertext = &data[HEADER_LEN..];
|
|
||||||
|
|
||||||
let cipher = XChaCha20Poly1305::new(key.into());
|
|
||||||
let plaintext = cipher
|
|
||||||
.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|_| IdfotoError::Decrypt)?;
|
|
||||||
|
|
||||||
Ok(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct KdfParams {
|
|
||||||
pub argon2_m: u32,
|
|
||||||
pub argon2_t: u32,
|
|
||||||
pub argon2_p: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for KdfParams {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
argon2_m: 65536,
|
|
||||||
argon2_t: 3,
|
|
||||||
argon2_p: 4,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn derive_master_key(
|
|
||||||
passphrase: &[u8],
|
|
||||||
image_secret: &[u8; 32],
|
|
||||||
salt: &[u8; 32],
|
|
||||||
params: &KdfParams,
|
|
||||||
) -> Result<[u8; 32]> {
|
|
||||||
let argon2_params = Params::new(
|
|
||||||
params.argon2_m,
|
|
||||||
params.argon2_t,
|
|
||||||
params.argon2_p,
|
|
||||||
Some(32),
|
|
||||||
)
|
|
||||||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
|
||||||
|
|
||||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
|
||||||
|
|
||||||
// Concatenate passphrase + image_secret as the password input
|
|
||||||
let mut password = Vec::with_capacity(passphrase.len() + 32);
|
|
||||||
password.extend_from_slice(passphrase);
|
|
||||||
password.extend_from_slice(image_secret);
|
|
||||||
|
|
||||||
let mut output = [0u8; 32];
|
|
||||||
argon2
|
|
||||||
.hash_password_into(&password, salt, &mut output)
|
|
||||||
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn fast_params() -> KdfParams {
|
|
||||||
KdfParams {
|
|
||||||
argon2_m: 256,
|
|
||||||
argon2_t: 1,
|
|
||||||
argon2_p: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_master_key_deterministic() {
|
|
||||||
let passphrase = b"test-passphrase";
|
|
||||||
let image_secret = [0x42u8; 32];
|
|
||||||
let salt = [0x01u8; 32];
|
|
||||||
let params = fast_params();
|
|
||||||
|
|
||||||
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
|
||||||
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(key1, key2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_master_key_different_passphrase() {
|
|
||||||
let image_secret = [0x42u8; 32];
|
|
||||||
let salt = [0x01u8; 32];
|
|
||||||
let params = fast_params();
|
|
||||||
|
|
||||||
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, ¶ms).unwrap();
|
|
||||||
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, ¶ms).unwrap();
|
|
||||||
|
|
||||||
assert_ne!(key1, key2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn derive_master_key_different_image_secret() {
|
|
||||||
let passphrase = b"test-passphrase";
|
|
||||||
let salt = [0x01u8; 32];
|
|
||||||
let params = fast_params();
|
|
||||||
|
|
||||||
let image_secret1 = [0x11u8; 32];
|
|
||||||
let image_secret2 = [0x22u8; 32];
|
|
||||||
|
|
||||||
let key1 = derive_master_key(passphrase, &image_secret1, &salt, ¶ms).unwrap();
|
|
||||||
let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).unwrap();
|
|
||||||
|
|
||||||
assert_ne!(key1, key2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encrypt_decrypt_round_trip() {
|
|
||||||
let key = [0xABu8; 32];
|
|
||||||
let plaintext = b"hello, idfoto!";
|
|
||||||
|
|
||||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
|
||||||
let decrypted = decrypt(&key, &ciphertext).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decrypted, plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_wrong_key_fails() {
|
|
||||||
let key = [0xABu8; 32];
|
|
||||||
let wrong_key = [0xCDu8; 32];
|
|
||||||
let plaintext = b"sensitive data";
|
|
||||||
|
|
||||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
|
||||||
let result = decrypt(&wrong_key, &ciphertext);
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(matches!(result.unwrap_err(), IdfotoError::Decrypt));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decrypt_tampered_data_fails() {
|
|
||||||
let key = [0xABu8; 32];
|
|
||||||
let plaintext = b"sensitive data";
|
|
||||||
|
|
||||||
let mut ciphertext = encrypt(&key, plaintext).unwrap();
|
|
||||||
// Flip a byte in the ciphertext portion (after header)
|
|
||||||
let flip_pos = HEADER_LEN + 2;
|
|
||||||
ciphertext[flip_pos] ^= 0xFF;
|
|
||||||
|
|
||||||
let result = decrypt(&key, &ciphertext);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ciphertext_format_has_correct_structure() {
|
|
||||||
let key = [0x11u8; 32];
|
|
||||||
let plaintext = b"test plaintext for structure check";
|
|
||||||
|
|
||||||
let ciphertext = encrypt(&key, plaintext).unwrap();
|
|
||||||
|
|
||||||
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
|
|
||||||
let expected_len = 1 + 24 + plaintext.len() + 16;
|
|
||||||
assert_eq!(ciphertext.len(), expected_len);
|
|
||||||
|
|
||||||
// Version byte must be 0x01
|
|
||||||
assert_eq!(ciphertext[0], 0x01);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
use rand::Rng;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
/// A single password entry (stored encrypted in entries/<id>.enc).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Entry {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub password: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub totp_secret: Option<String>,
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Summary info about an entry (stored in the manifest).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ManifestEntry {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub url: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub username: Option<String>,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The vault manifest — maps entry IDs to their metadata.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Manifest {
|
|
||||||
pub entries: HashMap<String, ManifestEntry>,
|
|
||||||
pub version: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Manifest {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Manifest {
|
|
||||||
entries: HashMap::new(),
|
|
||||||
version: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_entry(&mut self, id: String, entry: ManifestEntry) {
|
|
||||||
self.entries.insert(id, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_entry(&mut self, id: &str) -> Option<ManifestEntry> {
|
|
||||||
self.entries.remove(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> {
|
|
||||||
let q = query.to_lowercase();
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, e)| {
|
|
||||||
e.name.to_lowercase().contains(&q)
|
|
||||||
|| e.url
|
|
||||||
.as_deref()
|
|
||||||
.map(|u| u.to_lowercase().contains(&q))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Manifest {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a random 8-character hex string to use as an entry ID.
|
|
||||||
pub fn generate_entry_id() -> String {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let bytes: [u8; 4] = rng.gen();
|
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entry_serialization_round_trip() {
|
|
||||||
let entry = Entry {
|
|
||||||
name: "GitHub".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: Some("alice".to_string()),
|
|
||||||
password: "s3cr3t".to_string(),
|
|
||||||
notes: None,
|
|
||||||
totp_secret: None,
|
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&entry).unwrap();
|
|
||||||
let decoded: Entry = serde_json::from_str(&json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decoded.name, entry.name);
|
|
||||||
assert_eq!(decoded.url, entry.url);
|
|
||||||
assert_eq!(decoded.username, entry.username);
|
|
||||||
assert_eq!(decoded.password, entry.password);
|
|
||||||
assert_eq!(decoded.notes, entry.notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_add_and_lookup() {
|
|
||||||
let mut manifest = Manifest::new();
|
|
||||||
let me = ManifestEntry {
|
|
||||||
name: "GitHub".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: Some("alice".to_string()),
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
};
|
|
||||||
manifest.add_entry("abc12345".to_string(), me);
|
|
||||||
|
|
||||||
assert!(manifest.entries.contains_key("abc12345"));
|
|
||||||
assert_eq!(manifest.entries["abc12345"].name, "GitHub");
|
|
||||||
|
|
||||||
let removed = manifest.remove_entry("abc12345");
|
|
||||||
assert!(removed.is_some());
|
|
||||||
assert!(!manifest.entries.contains_key("abc12345"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_serialization_round_trip() {
|
|
||||||
let mut manifest = Manifest::new();
|
|
||||||
manifest.add_entry(
|
|
||||||
"deadbeef".to_string(),
|
|
||||||
ManifestEntry {
|
|
||||||
name: "Gmail".to_string(),
|
|
||||||
url: Some("https://mail.google.com".to_string()),
|
|
||||||
username: Some("user@gmail.com".to_string()),
|
|
||||||
updated_at: "2024-06-01T00:00:00Z".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let json = serde_json::to_string(&manifest).unwrap();
|
|
||||||
let decoded: Manifest = serde_json::from_str(&json).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decoded.version, 1);
|
|
||||||
assert!(decoded.entries.contains_key("deadbeef"));
|
|
||||||
assert_eq!(decoded.entries["deadbeef"].name, "Gmail");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generate_entry_id_is_8_hex_chars() {
|
|
||||||
let id = generate_entry_id();
|
|
||||||
assert_eq!(id.len(), 8);
|
|
||||||
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_search_case_insensitive() {
|
|
||||||
let mut manifest = Manifest::new();
|
|
||||||
manifest.add_entry(
|
|
||||||
"id001".to_string(),
|
|
||||||
ManifestEntry {
|
|
||||||
name: "GitHub Account".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: None,
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
manifest.add_entry(
|
|
||||||
"id002".to_string(),
|
|
||||||
ManifestEntry {
|
|
||||||
name: "Work Email".to_string(),
|
|
||||||
url: Some("https://mail.example.com".to_string()),
|
|
||||||
username: None,
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// partial name match, case-insensitive
|
|
||||||
let results = manifest.search("github");
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
assert_eq!(results[0].1.name, "GitHub Account");
|
|
||||||
|
|
||||||
// partial URL match
|
|
||||||
let results = manifest.search("mail.example");
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
assert_eq!(results[0].1.name, "Work Email");
|
|
||||||
|
|
||||||
// no match
|
|
||||||
let results = manifest.search("nonexistent");
|
|
||||||
assert_eq!(results.len(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum IdfotoError {
|
|
||||||
#[error("key derivation failed: {0}")]
|
|
||||||
Kdf(String),
|
|
||||||
|
|
||||||
#[error("encryption failed: {0}")]
|
|
||||||
Encrypt(String),
|
|
||||||
|
|
||||||
#[error("decryption failed: wrong key or corrupted data")]
|
|
||||||
Decrypt,
|
|
||||||
|
|
||||||
#[error("invalid vault format: {0}")]
|
|
||||||
Format(String),
|
|
||||||
|
|
||||||
#[error("entry not found: {0}")]
|
|
||||||
EntryNotFound(String),
|
|
||||||
|
|
||||||
#[error("imgsecret: {0}")]
|
|
||||||
ImgSecret(String),
|
|
||||||
|
|
||||||
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
|
||||||
ImageTooSmall {
|
|
||||||
min_width: u32,
|
|
||||||
min_height: u32,
|
|
||||||
actual_width: u32,
|
|
||||||
actual_height: u32,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("extraction failed: no valid secret found in image")]
|
|
||||||
ExtractionFailed,
|
|
||||||
|
|
||||||
#[error("json error: {0}")]
|
|
||||||
Json(#[from] serde_json::Error),
|
|
||||||
|
|
||||||
#[error("device key error: {0}")]
|
|
||||||
DeviceKey(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, IdfotoError>;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
pub mod error;
|
|
||||||
pub use error::{IdfotoError, Result};
|
|
||||||
|
|
||||||
pub mod crypto;
|
|
||||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
|
||||||
|
|
||||||
pub mod entry;
|
|
||||||
pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
|
|
||||||
|
|
||||||
pub mod vault;
|
|
||||||
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
|
|
||||||
|
|
||||||
pub mod imgsecret;
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use crate::crypto;
|
|
||||||
use crate::entry::{Entry, Manifest};
|
|
||||||
use crate::error::Result;
|
|
||||||
|
|
||||||
pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result<Vec<u8>> {
|
|
||||||
let json = serde_json::to_vec(entry)?;
|
|
||||||
crypto::encrypt(master_key, &json)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result<Entry> {
|
|
||||||
let json = crypto::decrypt(master_key, data)?;
|
|
||||||
let entry: Entry = serde_json::from_slice(&json)?;
|
|
||||||
Ok(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result<Vec<u8>> {
|
|
||||||
let json = serde_json::to_vec(manifest)?;
|
|
||||||
crypto::encrypt(master_key, &json)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
|
|
||||||
let json = crypto::decrypt(master_key, data)?;
|
|
||||||
let manifest: Manifest = serde_json::from_slice(&json)?;
|
|
||||||
Ok(manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::entry::ManifestEntry;
|
|
||||||
|
|
||||||
fn test_key_a() -> [u8; 32] {
|
|
||||||
[0x42u8; 32]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_key_b() -> [u8; 32] {
|
|
||||||
[0x99u8; 32]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sample_entry() -> Entry {
|
|
||||||
Entry {
|
|
||||||
name: "GitHub".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: Some("alice".to_string()),
|
|
||||||
password: "secret123".to_string(),
|
|
||||||
notes: None,
|
|
||||||
totp_secret: None,
|
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entry_encrypt_decrypt_round_trip() {
|
|
||||||
let key = test_key_a();
|
|
||||||
let entry = sample_entry();
|
|
||||||
|
|
||||||
let ciphertext = encrypt_entry(&key, &entry).unwrap();
|
|
||||||
let decoded = decrypt_entry(&key, &ciphertext).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decoded.name, "GitHub");
|
|
||||||
assert_eq!(decoded.password, "secret123");
|
|
||||||
assert_eq!(decoded.username, Some("alice".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_encrypt_decrypt_round_trip() {
|
|
||||||
let key = test_key_a();
|
|
||||||
let mut manifest = Manifest::new();
|
|
||||||
manifest.add_entry(
|
|
||||||
"deadbeef".to_string(),
|
|
||||||
ManifestEntry {
|
|
||||||
name: "GitHub".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: Some("alice".to_string()),
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let ciphertext = encrypt_manifest(&key, &manifest).unwrap();
|
|
||||||
let decoded = decrypt_manifest(&key, &ciphertext).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(decoded.version, 1);
|
|
||||||
assert!(decoded.entries.contains_key("deadbeef"));
|
|
||||||
assert_eq!(decoded.entries["deadbeef"].name, "GitHub");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn entry_wrong_key_fails() {
|
|
||||||
let key_a = test_key_a();
|
|
||||||
let key_b = test_key_b();
|
|
||||||
let entry = sample_entry();
|
|
||||||
|
|
||||||
let ciphertext = encrypt_entry(&key_a, &entry).unwrap();
|
|
||||||
let result = decrypt_entry(&key_b, &ciphertext);
|
|
||||||
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
use idfoto_core::{
|
|
||||||
decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest,
|
|
||||||
generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry,
|
|
||||||
};
|
|
||||||
use rand::RngCore;
|
|
||||||
|
|
||||||
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
|
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
|
||||||
use image::{ImageBuffer, ImageEncoder, Rgb};
|
|
||||||
let img = ImageBuffer::from_fn(width, height, |x, y| {
|
|
||||||
Rgb([
|
|
||||||
((x * 7 + y * 13) % 256) as u8,
|
|
||||||
((x * 11 + y * 3) % 256) as u8,
|
|
||||||
((x * 5 + y * 17) % 256) as u8,
|
|
||||||
])
|
|
||||||
});
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
|
||||||
encoder
|
|
||||||
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
|
||||||
.unwrap();
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fast_params() -> KdfParams {
|
|
||||||
KdfParams {
|
|
||||||
argon2_m: 256,
|
|
||||||
argon2_t: 1,
|
|
||||||
argon2_p: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn full_vault_workflow() {
|
|
||||||
// 1. Generate carrier JPEG
|
|
||||||
let carrier = make_test_jpeg(400, 300);
|
|
||||||
|
|
||||||
// 2. Generate random image_secret and embed
|
|
||||||
let mut image_secret = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut image_secret);
|
|
||||||
let stego = idfoto_core::imgsecret::embed(&carrier, &image_secret).unwrap();
|
|
||||||
|
|
||||||
// 3. Extract and verify
|
|
||||||
let extracted = idfoto_core::imgsecret::extract(&stego).unwrap();
|
|
||||||
assert_eq!(extracted, image_secret, "extracted image_secret must match embedded");
|
|
||||||
|
|
||||||
// 4. Derive master_key with fast params
|
|
||||||
let passphrase = b"test-passphrase-long-enough";
|
|
||||||
let mut salt = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut salt);
|
|
||||||
let params = fast_params();
|
|
||||||
let master_key = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
|
||||||
|
|
||||||
// 5. Create and encrypt an Entry
|
|
||||||
let entry = Entry {
|
|
||||||
name: "GitHub".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: Some("alice".to_string()),
|
|
||||||
password: "supersecret123!".to_string(),
|
|
||||||
notes: Some("my main account".to_string()),
|
|
||||||
totp_secret: None,
|
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let encrypted = encrypt_entry(&master_key, &entry).unwrap();
|
|
||||||
|
|
||||||
// 6. Decrypt and verify fields match
|
|
||||||
let decrypted = decrypt_entry(&master_key, &encrypted).unwrap();
|
|
||||||
assert_eq!(decrypted.name, "GitHub");
|
|
||||||
assert_eq!(decrypted.password, "supersecret123!");
|
|
||||||
assert_eq!(decrypted.username, Some("alice".to_string()));
|
|
||||||
assert_eq!(decrypted.url, Some("https://github.com".to_string()));
|
|
||||||
assert_eq!(decrypted.notes, Some("my main account".to_string()));
|
|
||||||
|
|
||||||
// 7. Wrong passphrase -> different key -> decrypt fails
|
|
||||||
let wrong_key = derive_master_key(b"wrong-passphrase-entirely", &image_secret, &salt, ¶ms).unwrap();
|
|
||||||
assert!(
|
|
||||||
decrypt_entry(&wrong_key, &encrypted).is_err(),
|
|
||||||
"decryption with wrong passphrase must fail"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 8. Wrong image_secret -> different key -> decrypt fails
|
|
||||||
let mut wrong_secret = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut wrong_secret);
|
|
||||||
// Make sure it's actually different
|
|
||||||
if wrong_secret == image_secret {
|
|
||||||
wrong_secret[0] ^= 0xFF;
|
|
||||||
}
|
|
||||||
let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap();
|
|
||||||
assert!(
|
|
||||||
decrypt_entry(&wrong_key2, &encrypted).is_err(),
|
|
||||||
"decryption with wrong image_secret must fail"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9. Manifest round-trip
|
|
||||||
let entry_id = generate_entry_id();
|
|
||||||
let mut manifest = Manifest::new();
|
|
||||||
manifest.add_entry(
|
|
||||||
entry_id.clone(),
|
|
||||||
ManifestEntry {
|
|
||||||
name: "GitHub".to_string(),
|
|
||||||
url: Some("https://github.com".to_string()),
|
|
||||||
username: Some("alice".to_string()),
|
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap();
|
|
||||||
let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(manifest_dec.version, 1);
|
|
||||||
assert!(manifest_dec.entries.contains_key(&entry_id));
|
|
||||||
assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn two_factor_independence() {
|
|
||||||
let mut salt = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut salt);
|
|
||||||
let params = fast_params();
|
|
||||||
|
|
||||||
let passphrase_a = b"passphrase-alpha";
|
|
||||||
let passphrase_b = b"passphrase-bravo";
|
|
||||||
|
|
||||||
let mut image_secret_a = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut image_secret_a);
|
|
||||||
let mut image_secret_b = [0u8; 32];
|
|
||||||
rand::thread_rng().fill_bytes(&mut image_secret_b);
|
|
||||||
// Ensure they differ
|
|
||||||
if image_secret_a == image_secret_b {
|
|
||||||
image_secret_b[0] ^= 0xFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. (passphrase_A, image_A)
|
|
||||||
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, ¶ms).unwrap();
|
|
||||||
|
|
||||||
// 2. (passphrase_B, image_A) -> different from #1
|
|
||||||
let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, ¶ms).unwrap();
|
|
||||||
assert_ne!(key_aa, key_ba, "different passphrase must produce different key");
|
|
||||||
|
|
||||||
// 3. (passphrase_A, image_B) -> different from #1
|
|
||||||
let key_ab = derive_master_key(passphrase_a, &image_secret_b, &salt, ¶ms).unwrap();
|
|
||||||
assert_ne!(key_aa, key_ab, "different image_secret must produce different key");
|
|
||||||
|
|
||||||
// 4. (passphrase_B, image_B) -> different from all above
|
|
||||||
let key_bb = derive_master_key(passphrase_b, &image_secret_b, &salt, ¶ms).unwrap();
|
|
||||||
assert_ne!(key_bb, key_aa, "key_bb must differ from key_aa");
|
|
||||||
assert_ne!(key_bb, key_ba, "key_bb must differ from key_ba");
|
|
||||||
assert_ne!(key_bb, key_ab, "key_bb must differ from key_ab");
|
|
||||||
}
|
|
||||||
539
crates/relicario-cli/ARCHITECTURE.md
Normal file
539
crates/relicario-cli/ARCHITECTURE.md
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
# Architecture: relicario-cli
|
||||||
|
|
||||||
|
## What this crate is for
|
||||||
|
|
||||||
|
The `relicario` binary is the platform layer for `relicario-core`: it adds
|
||||||
|
filesystem layout, a hardened `git` shell-out, interactive `rpassword` prompts,
|
||||||
|
clipboard handoff, and a clap-based command surface. The crate has two design
|
||||||
|
roles. First, it is the developer / power-user surface that exposes everything
|
||||||
|
the core can do (every `ItemCore` variant, every `VaultSettings` knob, history
|
||||||
|
inspection, device key management). Second, it is the only working interface
|
||||||
|
during disaster recovery — the extension may be uninstalled, the device may be
|
||||||
|
new — so it intentionally maintains feature parity with the extension's vault
|
||||||
|
tab. It deliberately shells out to `git` rather than depending on libgit2 /
|
||||||
|
gitoxide; this keeps the dep tree slim, lets the user override `git` config
|
||||||
|
locally, and lets recovery debugging happen with familiar tooling.
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
The crate is three files of source and a `tests/` directory. Each source file
|
||||||
|
has one job.
|
||||||
|
|
||||||
|
- **`src/main.rs`** (`main.rs:1-1719`) — clap surface plus every command
|
||||||
|
handler. Internal structure: a top-level `Cli` / `Commands` enum
|
||||||
|
(`main.rs:13-275`), a flat dispatcher `match` in `main()`
|
||||||
|
(`main.rs:277-303`), per-command handlers named `cmd_<verb>`, and a layer of
|
||||||
|
per-type item helpers (`build_<type>_item` for `cmd_add`, `edit_<type>` for
|
||||||
|
`cmd_edit`). The per-type split is recent: commit `3f0f5b1` extracted
|
||||||
|
~217-line `match` arms in `cmd_add` and `cmd_edit` into focused functions,
|
||||||
|
one per `ItemCore` variant, so each builder/editor reads top-to-bottom and
|
||||||
|
can be tested through the same integration paths. Owns all clap argument
|
||||||
|
parsing, all interactive prompts (`prompt`, `prompt_optional`, `prompt_keep`,
|
||||||
|
`prompt_keep_opt`, `prompt_yesno`, `prompt_secret`), and the shared
|
||||||
|
`commit_paths` helper that is the single chokepoint for git commits during
|
||||||
|
vault mutations.
|
||||||
|
|
||||||
|
- **`src/session.rs`** (`session.rs:1-152`) — `UnlockedVault` lifecycle. Holds
|
||||||
|
the derived master key in `Zeroizing<[u8; 32]>` for one CLI invocation; the
|
||||||
|
key wipes via `Zeroize` on scope exit (`session.rs:22-25`). Owns the
|
||||||
|
`unlock_interactive` flow (vault root walk → salt read → params read →
|
||||||
|
reference image extract → passphrase prompt → KDF) at `session.rs:33-59`,
|
||||||
|
the typed `load_*` / `save_*` accessors for `Item` / `Manifest` /
|
||||||
|
`VaultSettings`, the `read_salt` / `read_params` helpers, the
|
||||||
|
`RELICARIO_IMAGE` lookup, and `atomic_write` (`session.rs:144-151`) which
|
||||||
|
every disk write to a vault file goes through. Owns the env-var escape
|
||||||
|
hatches `RELICARIO_TEST_PASSPHRASE` (`session.rs:42`) and `RELICARIO_IMAGE`
|
||||||
|
(`session.rs:125`) that integration tests use to bypass the TTY.
|
||||||
|
|
||||||
|
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
|
||||||
|
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories
|
||||||
|
looking for a `.relicario/` marker; `vault_dir` and `relicario_dir` wrap it
|
||||||
|
for `cwd`-rooted callers; `git_command` (`helpers.rs:45-55`) is the
|
||||||
|
hardened-`git` factory that every git invocation in the crate (production
|
||||||
|
code, not tests) goes through; `iso8601` (`helpers.rs:60-64`) formats Unix
|
||||||
|
seconds for human-readable output (audit M11). The hardening is
|
||||||
|
load-bearing — see Invariants & Gotchas below.
|
||||||
|
|
||||||
|
## Invariants & contracts
|
||||||
|
|
||||||
|
These are the load-bearing rules the crate relies on. Each has been verified
|
||||||
|
in code; cite the line if you change it.
|
||||||
|
|
||||||
|
- **Every vault-mutating command unlocks via `UnlockedVault`.** The struct
|
||||||
|
holds the master key in `Zeroizing<[u8; 32]>` and drops via `Zeroize` on
|
||||||
|
scope exit (`session.rs:22-25`). No command bypasses this except
|
||||||
|
`cmd_generate` outside a vault dir and `cmd_init` (which derives the key
|
||||||
|
inline before there is a vault to unlock).
|
||||||
|
|
||||||
|
- **Every `git` invocation in production code goes through
|
||||||
|
`helpers::git_command`.** A grep for `Command::new("git")` outside
|
||||||
|
`helpers.rs` finds zero hits in `src/`; the only other match is in
|
||||||
|
`tests/edit_and_history.rs:18`, which is test-side verification of the git
|
||||||
|
log and is exempt by design. `git_command` injects
|
||||||
|
`core.hooksPath=/dev/null`, `commit.gpgsign=false`, and `core.editor=true`
|
||||||
|
via `-c` flags (`helpers.rs:48-52`). Direct `Command::new("git")` would
|
||||||
|
bypass the hardening — don't.
|
||||||
|
|
||||||
|
- **Every file write to a vault file uses `atomic_write`.** `atomic_write`
|
||||||
|
(`session.rs:144-151`) writes `<path>.tmp` then renames over `<path>`; a
|
||||||
|
partial write never appears as the live file. All `UnlockedVault::save_*`
|
||||||
|
helpers route through it. (`cmd_init` writes pre-creation files via
|
||||||
|
`fs::write` at `main.rs:373-393`; that path doesn't need atomicity because
|
||||||
|
the vault doesn't exist yet — failure leaves a half-built vault that the
|
||||||
|
next run rejects via `relicario_dir.exists()` at `main.rs:326`.)
|
||||||
|
|
||||||
|
- **Every commit during a mutating command uses `commit_paths`.**
|
||||||
|
`commit_paths` (`main.rs:767-775`) does `git add <paths> && git commit -m
|
||||||
|
<msg>` through the hardened wrapper. Commit message convention is
|
||||||
|
`<verb>: <title> (<id>)` — `add:`, `edit:`, `trash:`, `restore:`, `purge:`,
|
||||||
|
`attach:`, `detach:`, `settings: update`, `device: add <name>`, `device:
|
||||||
|
revoke <name>`, `init: new relicario vault (format v2)`, `trash empty:
|
||||||
|
purged N item(s)`. `cmd_purge` and `cmd_trash_empty` and `cmd_device` use
|
||||||
|
`git_command` directly (not `commit_paths`) because they need a slightly
|
||||||
|
different add/commit pattern; they still go through the hardened wrapper.
|
||||||
|
|
||||||
|
- **`cmd_generate` is the only command that runs without unlock — and only
|
||||||
|
when invoked outside a vault directory.** Inside a vault,
|
||||||
|
`cmd_generate` unlocks to read `settings.generator_defaults`
|
||||||
|
(`main.rs:1440-1445`); explicit flags override the stored defaults. This is
|
||||||
|
why the smoke-test `cargo run -p relicario-cli -- generate --length 32`
|
||||||
|
works without any setup.
|
||||||
|
|
||||||
|
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
||||||
|
directly; `Item::new` (called inside every `build_*_item`) does it via
|
||||||
|
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
||||||
|
|
||||||
|
- **Manifest is always saved last.** Within a single command, the order is:
|
||||||
|
write item file → mutate manifest → save manifest → commit. If the process
|
||||||
|
dies between step 1 and step 3, the next run sees an item file with no
|
||||||
|
manifest entry; `cmd_status` / `cmd_list` ignore it because they read the
|
||||||
|
manifest, not the directory. (Recovery would manually re-`add` to surface
|
||||||
|
it.)
|
||||||
|
|
||||||
|
- **Vault root is always discovered, never assumed to be `cwd`.**
|
||||||
|
`helpers::vault_dir` walks up from `cwd` looking for `.relicario/`, so any
|
||||||
|
command run from a subdirectory of the vault works (verified by
|
||||||
|
`vault_detection.rs:23-40`). v1 vaults using `.idfoto/` are naturally
|
||||||
|
rejected because they don't contain `.relicario/` — no compat shim needed
|
||||||
|
(`vault_detection.rs:42-59`).
|
||||||
|
|
||||||
|
- **`prompt_secret` reads `RELICARIO_TEST_ITEM_SECRET` before falling back to
|
||||||
|
`rpassword`.** This is the only way integration tests can drive the
|
||||||
|
per-item secret prompts (Login password, Card number, TOTP secret rotation,
|
||||||
|
Key material) without a real TTY. The check is at `main.rs:308-313`.
|
||||||
|
|
||||||
|
## Key flows
|
||||||
|
|
||||||
|
### Vault init (`cmd_init`, `main.rs:315-418`)
|
||||||
|
|
||||||
|
1. Refuse if `.relicario/` already exists (`main.rs:326-328`).
|
||||||
|
2. Read passphrase twice (or once via `RELICARIO_TEST_PASSPHRASE`); confirm
|
||||||
|
they match; run `validate_passphrase_strength` (zxcvbn-backed) and bail
|
||||||
|
with audit-H3 message on weak input (`main.rs:331-348`).
|
||||||
|
3. Generate a 32-byte random `image_secret` via `OsRng`, embed it into the
|
||||||
|
carrier JPEG via `imgsecret::embed`, write the stego output to `--output`
|
||||||
|
(`main.rs:351-360`).
|
||||||
|
4. Generate a 32-byte salt and pin `KdfParams { argon2_m: 65536, argon2_t: 3,
|
||||||
|
argon2_p: 4 }` (production-grade) at `main.rs:363-365`.
|
||||||
|
5. `derive_master_key(passphrase, image_secret, salt, params)` →
|
||||||
|
`Zeroizing<[u8;32]>` (`main.rs:368`).
|
||||||
|
6. Create `.relicario/`, `items/`, `attachments/` dirs; write
|
||||||
|
`.relicario/{salt, params.json, devices.json}`; encrypt and write
|
||||||
|
`manifest.enc` (empty `Manifest::new()`) and `settings.enc`
|
||||||
|
(`VaultSettings::default()`) (`main.rs:370-393`).
|
||||||
|
7. Write `.gitignore` listing the reference image filename (so the second
|
||||||
|
factor never accidentally ends up in git) (`main.rs:396-400`).
|
||||||
|
8. `git init` then initial commit `init: new relicario vault (format v2)`
|
||||||
|
via `git_command` (`main.rs:403-412`). Note the initial commit does NOT
|
||||||
|
go through `commit_paths` — it precedes the existence of an
|
||||||
|
`UnlockedVault`, so the path list is hand-spelled.
|
||||||
|
|
||||||
|
### Vault unlock (`UnlockedVault::unlock_interactive`, `session.rs:33-59`)
|
||||||
|
|
||||||
|
1. `vault_dir()` walks up from cwd to find `.relicario/`; bails with the
|
||||||
|
"run `relicario init` first" message on miss (`helpers.rs:21-26`).
|
||||||
|
2. `read_salt` reads `.relicario/salt` (32 bytes; rejects any other length).
|
||||||
|
3. `read_params` deserializes `.relicario/params.json` and extracts the
|
||||||
|
nested `kdf` sub-object as `KdfParams` (`session.rs:110-121`). The nested
|
||||||
|
shape exists because `params.json` also stores `format_version`, `aead`,
|
||||||
|
and `salt_path` for forward-compat probing.
|
||||||
|
4. `get_image_path` honours `RELICARIO_IMAGE`, then a `<vault>/reference.jpg`
|
||||||
|
convention, then prompts (`session.rs:124-140`).
|
||||||
|
5. Read the reference image bytes; `imgsecret::extract` runs the DCT
|
||||||
|
majority-vote decode to recover the 32-byte image secret
|
||||||
|
(`session.rs:38-40`).
|
||||||
|
6. Read the passphrase via `RELICARIO_TEST_PASSPHRASE` or `rpassword`
|
||||||
|
(`session.rs:42-49`).
|
||||||
|
7. `derive_master_key` produces the master key; `UnlockedVault { root,
|
||||||
|
master_key }` is returned and lives until the command function returns.
|
||||||
|
|
||||||
|
### Item add (`cmd_add`, `main.rs:419-456`)
|
||||||
|
|
||||||
|
1. Unlock the vault and load the manifest.
|
||||||
|
2. Match on the `AddKind` variant and dispatch to the matching
|
||||||
|
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
|
||||||
|
builders; only `build_document_item` takes `&UnlockedVault` because it
|
||||||
|
needs `attachment_caps` and writes the encrypted blob alongside the item.
|
||||||
|
3. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||||
|
favorite-flag, primary attachment if any).
|
||||||
|
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||||
|
`vault.save_manifest(&manifest)`.
|
||||||
|
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||||
|
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
||||||
|
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
||||||
|
|
||||||
|
### Item edit (`cmd_edit`, `main.rs:938-977`)
|
||||||
|
|
||||||
|
1. Unlock, load manifest, resolve query → item id, load the item.
|
||||||
|
2. Universally-editable fields (title, group, tags) are prompted via
|
||||||
|
`prompt_keep` / `prompt_keep_opt` first; blank input keeps the current
|
||||||
|
value (`main.rs:952-956`).
|
||||||
|
3. Borrow `&mut item.field_history` once into a local `history` binding
|
||||||
|
(`main.rs:958`), then `match` on `&mut item.core` and dispatch to the
|
||||||
|
per-type `edit_<type>` helper (`main.rs:959-967`). The history-tracking
|
||||||
|
editors (`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`,
|
||||||
|
`edit_totp`) take `&mut FieldHistory`; the others (`edit_identity`,
|
||||||
|
`edit_document_message`) don't.
|
||||||
|
4. Each editor that mutates a tracked secret calls `push_history(history,
|
||||||
|
"<key>", old_value)` (`main.rs:1095-1109`) — see the History flow below
|
||||||
|
for the synthetic-key convention.
|
||||||
|
5. `item.modified = now_unix()`, save, upsert manifest, commit
|
||||||
|
`edit: <title> (<id>)`.
|
||||||
|
|
||||||
|
`edit_document_message` (`main.rs:1050-1052`) just prints "use `attach` /
|
||||||
|
`extract` instead" — Document items can't be field-edited; they're
|
||||||
|
attachment-shaped.
|
||||||
|
|
||||||
|
The `FieldHistory` type alias (`main.rs:983-986`) is purely cosmetic; it
|
||||||
|
exists so the editor signatures don't have to spell out the full
|
||||||
|
`HashMap<FieldId, Vec<FieldHistoryEntry>>`.
|
||||||
|
|
||||||
|
### History capture and view (`push_history` + `cmd_history`)
|
||||||
|
|
||||||
|
`push_history` (`main.rs:1095-1109`) records an old value under a synthetic
|
||||||
|
`FieldId(format!("core:{key}"))`. The `core:` prefix namespaces these keys so
|
||||||
|
they can never collide with real custom-field UUIDs from the typed-item
|
||||||
|
custom-fields work. The keys used in the codebase are:
|
||||||
|
|
||||||
|
- `core:login_password` (`main.rs:998`)
|
||||||
|
- `core:secure_note_body` (`main.rs:1012`)
|
||||||
|
- `core:card_number` (`main.rs:1031`)
|
||||||
|
- `core:key_material` (`main.rs:1045`)
|
||||||
|
- `core:totp_secret` (`main.rs:1063`)
|
||||||
|
|
||||||
|
`cmd_history` (`main.rs:1111-1159`) reads `item.field_history`, sorts the
|
||||||
|
keys, strips the `core:` prefix for display, and prints each entry list
|
||||||
|
masked or revealed depending on `--show`. The `--field <name>` filter
|
||||||
|
matches against either the stripped name (`login_password`) or the raw key
|
||||||
|
(`core:login_password`) so both forms work (`main.rs:1126-1129`). The
|
||||||
|
`relicario history bank --field totp_secret` form is what
|
||||||
|
`edit_and_history.rs` exercises.
|
||||||
|
|
||||||
|
### Trash & purge (`cmd_rm` / `cmd_restore` / `cmd_purge` / `cmd_trash_empty`)
|
||||||
|
|
||||||
|
- `cmd_rm` (`main.rs:1161-1176`) calls `Item::soft_delete()` (sets
|
||||||
|
`trashed_at`), saves, upserts manifest, commits `trash:`.
|
||||||
|
- `cmd_restore` (`main.rs:1178-1193`) is the inverse: `Item::restore()`,
|
||||||
|
same wrap-up, commit `restore:`.
|
||||||
|
- `cmd_purge` (`main.rs:1220-1237`) calls `purge_item` (`main.rs:1197-1218`)
|
||||||
|
which removes the item file, the attachment dir, the manifest entry, and
|
||||||
|
`git rm -rf --ignore-unmatch`s the paths. Then a single `git add
|
||||||
|
manifest.enc` + commit `purge: <title> (<id>)`.
|
||||||
|
- `cmd_trash_empty` (`main.rs:1246-1282`) is the only multi-item mutating
|
||||||
|
command. It loads settings once, iterates all items past their
|
||||||
|
`trash_retention` window, calls `purge_item` for each, then does a single
|
||||||
|
`git add manifest.enc` + commit `trash empty: purged N item(s)`. The
|
||||||
|
single-unlock-per-batch shape was the fix in commit `b5015b3` — the
|
||||||
|
earlier version re-prompted for the passphrase per item.
|
||||||
|
|
||||||
|
### Attach / detach / extract
|
||||||
|
|
||||||
|
- `cmd_attach` (`main.rs:1283-1339`) loads `attachment_caps` from settings
|
||||||
|
and rejects if the item has hit `per_item_max_count`. `encrypt_attachment`
|
||||||
|
enforces `per_attachment_max_bytes`. The encrypted blob lands at
|
||||||
|
`attachments/<item_id>/<aid>.enc`; the `aid` is content-addressed by core.
|
||||||
|
Commit message: `attach: <file> → <title> (<id>)`.
|
||||||
|
- `cmd_detach` (`main.rs:1376-1424`, added in `3f0f5b1`) removes one
|
||||||
|
attachment from the item, deletes the encrypted blob, rewrites the item.
|
||||||
|
Refuses if the target `aid` is a `Document` item's `primary_attachment`
|
||||||
|
(`main.rs:1392-1400`) — that would orphan the item; use `purge` instead.
|
||||||
|
Commit message: `detach: <filename> from <title> (<id>)`.
|
||||||
|
- `cmd_extract` (`main.rs:1354-1375`) decrypts the blob and writes the
|
||||||
|
plaintext to `--out` or to `<filename>` in cwd. Read-only: no commit, no
|
||||||
|
state mutation.
|
||||||
|
- `cmd_attachments` (`main.rs:1341-1352`) lists `aid`, size, mime, filename
|
||||||
|
— read-only.
|
||||||
|
|
||||||
|
### Generate (`cmd_generate`, `main.rs:1426-1489`)
|
||||||
|
|
||||||
|
Has two distinct modes:
|
||||||
|
|
||||||
|
- **Outside a vault** — `vault_dir()` returns `Err`; `vault_defaults` stays
|
||||||
|
`None`; defaults are hard-coded (`length: 20`, `symbols: SafeOnly`,
|
||||||
|
`words: 5`, `separator: " "`, `Capitalization::Lower`). No unlock prompt.
|
||||||
|
- **Inside a vault** — `vault_dir()` succeeds; full unlock; load
|
||||||
|
`settings.generator_defaults`. Explicit flags override the stored defaults
|
||||||
|
field-by-field. `--bip39` flips mode; absent that flag, the mode is
|
||||||
|
whatever the stored default is. Tests:
|
||||||
|
`settings.rs::generate_uses_vault_default_length` (length-tracking) and
|
||||||
|
`basic_flows.rs::generate_random_and_bip39` (no-vault smoke).
|
||||||
|
|
||||||
|
The two-mode shape is deliberate (see Gotchas) and is why `cmd_generate` is
|
||||||
|
the only command outside `cmd_init` that touches `helpers::vault_dir()`
|
||||||
|
directly instead of going through `UnlockedVault::unlock_interactive()`.
|
||||||
|
|
||||||
|
### Sync (`cmd_sync`, `main.rs:1582-1590`)
|
||||||
|
|
||||||
|
`git pull --rebase` then `git push`, both via the hardened wrapper. No
|
||||||
|
unlock — sync moves opaque ciphertext, the master key is never needed. This
|
||||||
|
is the only command that fails on conflict; it doesn't try to resolve.
|
||||||
|
Resolution happens manually in the user's git tooling.
|
||||||
|
|
||||||
|
### Status (`cmd_status`, `main.rs:1592-1631`, added in `3f0f5b1`)
|
||||||
|
|
||||||
|
Unlocks; loads manifest; counts items (active vs trashed), attachments
|
||||||
|
(count + total bytes), devices (parsed from `devices.json`); shells out to
|
||||||
|
`git log -1 --pretty=format:%h %s` for the last-commit summary line. All
|
||||||
|
read-only — no commit, no state change.
|
||||||
|
|
||||||
|
### Device management (`cmd_device`, `main.rs:1632-1702`)
|
||||||
|
|
||||||
|
Add: generate ed25519 keypair via `OsRng`, append `{name, public_key}` to
|
||||||
|
`.relicario/devices.json`, write the secret signing key to
|
||||||
|
`<config_dir>/relicario/devices/<name>.key` with `0o600` on Unix, commit
|
||||||
|
`device: add <name>`. List: print `name pubkey_hex`. Revoke: filter by name,
|
||||||
|
rewrite `devices.json`, commit `device: revoke <name>`. Note that device
|
||||||
|
keys are kept entirely separate from the KDF (passphrase × image stays
|
||||||
|
unchanged across device add/revoke), as per the design spec.
|
||||||
|
|
||||||
|
### Backup-passphrase-style commands (none yet)
|
||||||
|
|
||||||
|
The import / export / `import-lastpass` commands described in
|
||||||
|
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` are
|
||||||
|
not yet implemented. When they land they'll fit in the dispatcher
|
||||||
|
(`main.rs:279-302`) alongside `Sync` and `Status`. Don't add stubs here
|
||||||
|
until that work begins.
|
||||||
|
|
||||||
|
## Cross-cutting concerns
|
||||||
|
|
||||||
|
- **Error model.** Every `cmd_*` returns `anyhow::Result<()>`. Core errors
|
||||||
|
bubble up through `?` from `RelicarioError`. Per-step context is added
|
||||||
|
via `with_context(|| ...)` chains, e.g. `format!("failed to read {}",
|
||||||
|
path.display())`. AEAD authentication failures intentionally surface as
|
||||||
|
the ambiguous "wrong passphrase or corrupt vault" message from core — the
|
||||||
|
CLI does not differentiate. clap argument errors are produced by clap
|
||||||
|
(e.g., `--days` and `--forever` together fail at the
|
||||||
|
`SettingsAction::TrashRetention` arm in `main.rs:1504-1510`).
|
||||||
|
|
||||||
|
- **Atomicity.** Every disk write to a vault file goes through
|
||||||
|
`session.rs::atomic_write` (`session.rs:144-151`): write `<path>.tmp`, then
|
||||||
|
rename over `<path>`. Manifest is the single source of truth and is
|
||||||
|
always written *last* in any multi-file operation, so a process kill
|
||||||
|
between item-write and manifest-write leaves an orphan item file (which
|
||||||
|
doesn't appear in `list`/`status`) but never a manifest pointing to a
|
||||||
|
missing file.
|
||||||
|
|
||||||
|
- **Git history as audit log.** Per-action commits, never amended, never
|
||||||
|
squashed. The verb prefix on commit subjects (`add:`, `edit:`, `trash:`,
|
||||||
|
`restore:`, `purge:`, `attach:`, `detach:`, `settings:`, `device:`,
|
||||||
|
`init:`) makes `git log --oneline` a literal audit trail. Tests verify
|
||||||
|
this by greping `git log` directly (e.g., `edit_and_history.rs:18-22`).
|
||||||
|
|
||||||
|
- **Where secrets live.**
|
||||||
|
- Master key — `UnlockedVault.master_key: Zeroizing<[u8; 32]>`
|
||||||
|
(`session.rs:24`). Wipes on drop.
|
||||||
|
- Image secret — `Zeroizing<[u8; 32]>`, lives only inside
|
||||||
|
`unlock_interactive` until the KDF call (`session.rs:40`).
|
||||||
|
- Passphrase — `Zeroizing<String>` from `rpassword::prompt_password` or
|
||||||
|
the env var (`session.rs:42-49`, `main.rs:333-342`).
|
||||||
|
- Item secrets — `Zeroizing<String>` for `Login.password`, `Card.number`,
|
||||||
|
`Card.cvv`, `Card.pin`, `Key.key_material`, `SecureNote.body`, and
|
||||||
|
`Zeroizing<Vec<u8>>` for `TotpCore.config.secret` (decoded from
|
||||||
|
base32). All flow through core types.
|
||||||
|
- Clipboard copy — `Zeroizing<String>` cloned into the detached 30s
|
||||||
|
auto-clear thread (`main.rs:873-889`).
|
||||||
|
|
||||||
|
- **Test escape hatches.** Three env vars exist for integration tests; all
|
||||||
|
are read at exactly one site each:
|
||||||
|
- `RELICARIO_TEST_PASSPHRASE` — `session.rs:42` (unlock) and
|
||||||
|
`main.rs:333,338` (init).
|
||||||
|
- `RELICARIO_IMAGE` — `session.rs:125` (image path resolution).
|
||||||
|
- `RELICARIO_TEST_ITEM_SECRET` — `main.rs:309` (`prompt_secret` only).
|
||||||
|
None of them have a production fall-through; absent the var, the code
|
||||||
|
always prompts. They are safe in production binaries because the user
|
||||||
|
would have to set them explicitly.
|
||||||
|
|
||||||
|
- **Generate-without-unlock is intentional.** It is NOT an oversight.
|
||||||
|
`relicario generate --length 32` is the documented smoke test (see the
|
||||||
|
repo's CLAUDE.md) and works as a standalone CSPRNG password generator
|
||||||
|
outside any vault. Inside a vault it does require unlock — see Gotchas.
|
||||||
|
|
||||||
|
## Test architecture
|
||||||
|
|
||||||
|
All tests are integration tests; there are no `#[cfg(test)]` modules in
|
||||||
|
`src/main.rs` or `src/session.rs`. `helpers.rs` has four unit tests
|
||||||
|
(`helpers.rs:67-100`) that exercise vault-dir walking and `iso8601`
|
||||||
|
formatting in isolation. Everything else is `tests/`.
|
||||||
|
|
||||||
|
- **`tests/common/mod.rs`** (`117 lines`) — the harness. `TestVault::init()`
|
||||||
|
spins up a fresh `TempDir`, generates a 400×300 JPEG via
|
||||||
|
`make_test_jpeg()` (deterministic noise; no binary fixtures), runs
|
||||||
|
`relicario init --image carrier.jpg --output reference.jpg` with
|
||||||
|
`RELICARIO_TEST_PASSPHRASE` set, and stashes the passphrase + reference
|
||||||
|
image path on the struct. `run` and `run_with_input` are the two ways to
|
||||||
|
invoke the binary against the test vault: both inherit
|
||||||
|
`RELICARIO_IMAGE` + `RELICARIO_TEST_PASSPHRASE`; the latter pipes extra
|
||||||
|
newlines into stdin (used for interactive prompts that aren't
|
||||||
|
`rpassword`-driven). The note at the top warns Task 23 implementers
|
||||||
|
about the new-item-password rpassword path; the fix landed as
|
||||||
|
`RELICARIO_TEST_ITEM_SECRET` in commit `20350d5`.
|
||||||
|
|
||||||
|
- **`tests/basic_flows.rs`** (`136 lines`) — covers the init layout
|
||||||
|
(`.relicario/{salt,params.json,devices.json}`, `manifest.enc`,
|
||||||
|
`settings.enc`, `reference.jpg`, `.gitignore`, `.git`); the `params.json`
|
||||||
|
v2 shape; `add login` + `list`; `get` masking semantics (with and
|
||||||
|
without `--show`); the rm/restore/purge cycle including `list --trashed`;
|
||||||
|
and the two-mode `generate` smoke (random length + bip39 word count) run
|
||||||
|
outside a vault.
|
||||||
|
|
||||||
|
- **`tests/edit_and_history.rs`** (`191 lines`) — drives `edit` end-to-end
|
||||||
|
by piping stdin lines (blank to keep, `y` to confirm) plus
|
||||||
|
`RELICARIO_TEST_ITEM_SECRET` for the rpassword leg. `edit_password_*`
|
||||||
|
verifies the item file is rewritten and the `edit: bank` commit lands.
|
||||||
|
The four `history_command_*` tests cover masked listing, `--show`
|
||||||
|
reveal, "no history captured" output, and per-field filtering. The
|
||||||
|
`edit_totp_rotates_secret_and_captures_history` test (added 2026-04-27
|
||||||
|
in commit `3f0f5b1` — fixes a stub at the old `main.rs:925`) drives the
|
||||||
|
full TOTP edit including issuer / label / secret rotation.
|
||||||
|
|
||||||
|
- **`tests/attachments.rs`** (`106 lines`) — `attach`/`attachments`/
|
||||||
|
`extract` round-trip (verifies the bytes survive the encrypt-decrypt
|
||||||
|
hop); `detach` removes both the attachment ref and the encrypted blob
|
||||||
|
on disk; `detach` rejects an unknown `aid`; `attach` rejects payloads
|
||||||
|
over `per_attachment_max_bytes`. The detach test (`detach_*`) and the
|
||||||
|
cap test were added in `3f0f5b1` / `20350d5` respectively.
|
||||||
|
|
||||||
|
- **`tests/settings.rs`** (`135 lines`) — `settings show` and
|
||||||
|
`settings trash-retention --days 60` round-trip; the conflicting-flags
|
||||||
|
rejection (`--days` + `--forever`); the
|
||||||
|
`generate_uses_vault_default_length` test that verifies (a) default
|
||||||
|
vault length is 20, (b) updating `settings generator-defaults --length
|
||||||
|
32` changes the default, (c) explicit `--length 8` overrides the stored
|
||||||
|
default; the multi-shape `cmd_status` smoke; and the
|
||||||
|
`generate_works_outside_vault` test that verifies the no-unlock path
|
||||||
|
works in a bare `TempDir` with no `.relicario/`.
|
||||||
|
|
||||||
|
- **`tests/vault_detection.rs`** (`59 lines`) — three tests covering audit
|
||||||
|
L8: `list` refuses without a marker; `list` from a nested subdirectory
|
||||||
|
finds the parent `.relicario/`; a v1 `.idfoto/` directory is rejected
|
||||||
|
with the `.relicario` hint in the error message.
|
||||||
|
|
||||||
|
The whole test suite uses `assert_cmd` to spawn the real binary against a
|
||||||
|
real temp directory, so they exercise actual fs / git / KDF code paths.
|
||||||
|
The KDF runs with the production-grade `m=64MiB, t=3, p=4` parameters in
|
||||||
|
the test path (`main.rs:365`), which is why init takes a noticeable beat
|
||||||
|
in the test runner. The core's "fast Argon2id for tests" CLAUDE.md note
|
||||||
|
applies to `relicario-core` unit tests, not these CLI integration tests.
|
||||||
|
|
||||||
|
## Gotchas & non-obvious decisions
|
||||||
|
|
||||||
|
- **`cmd_generate` runs without unlock outside a vault, but with unlock
|
||||||
|
inside.** This is two ergonomic guarantees in one command. Outside, it's
|
||||||
|
a fast standalone CSPRNG tool — useful for smoke tests, scripts, and any
|
||||||
|
user who installed `relicario` just for the generator. Inside, it
|
||||||
|
consults `settings.generator_defaults` so the user gets the policy they
|
||||||
|
configured. The branch is the `vault_dir().is_ok()` check at
|
||||||
|
`main.rs:1440`. Tests pin both behaviours.
|
||||||
|
|
||||||
|
- **TOTP edit pushes history under the synthetic key `core:totp_secret`,
|
||||||
|
not `core:totp` or anything else.** This is what `relicario history
|
||||||
|
<query> --field totp_secret` matches against. The naming convention
|
||||||
|
("type underscore field") is shared across all five history-tracked
|
||||||
|
fields (see Invariants). If you add a new history-tracked field, pick a
|
||||||
|
matching `<type>_<field>` form so the user-facing `--field` filter
|
||||||
|
stays predictable.
|
||||||
|
|
||||||
|
- **`detach` refuses a Document item's primary attachment.**
|
||||||
|
(`main.rs:1392-1400`) Document items model "this item *is* a file"; the
|
||||||
|
primary blob isn't optional. The error directs the user to `purge`
|
||||||
|
instead. Non-primary attachments on a Document (e.g., a scanned
|
||||||
|
contract with an addendum) detach normally.
|
||||||
|
|
||||||
|
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
|
||||||
|
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
|
||||||
|
carried 217-line `match` arms. The split-out functions are easier to
|
||||||
|
read, easier to test individually (the existing integration tests still
|
||||||
|
drive them through the same paths), and easier to grow when a new
|
||||||
|
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
||||||
|
|
||||||
|
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
||||||
|
reasons. (1) Dep tree: pulling in libgit2 doubles compile time and
|
||||||
|
adds a C dependency. (2) Override surface: users can put any
|
||||||
|
`~/.gitconfig` they want and it Just Works (subject to the hardening
|
||||||
|
flags). (3) Recovery: when something is wrong with a vault, the user
|
||||||
|
can poke around with `git log`, `git show`, `git fsck` directly; the
|
||||||
|
CLI's git interactions are not opaque.
|
||||||
|
|
||||||
|
- **The hardened-`git` injection set is load-bearing.** `git_command`
|
||||||
|
prepends three `-c` flags before the user-supplied args
|
||||||
|
(`helpers.rs:48-52`):
|
||||||
|
- `core.hooksPath=/dev/null` — a malicious or buggy hook in a cloned
|
||||||
|
vault could otherwise run arbitrary code on every commit. Master key
|
||||||
|
is in memory at the time of commit; this matters.
|
||||||
|
- `commit.gpgsign=false` — if the user has global GPG signing on, the
|
||||||
|
GPG agent prompt would block on `git commit` and hold the master
|
||||||
|
key alive in memory until the user types the passphrase. Disable it
|
||||||
|
for relicario commits.
|
||||||
|
- `core.editor=true` — `true(1)` exits 0 with no output. If `git`
|
||||||
|
decides to drop into `$EDITOR` (rebase conflict markers, missing
|
||||||
|
`-m`), this neutralises it without crashing the rebase. We pass
|
||||||
|
`-m <msg>` ourselves; this flag is the seatbelt.
|
||||||
|
All three were added together in audit H4. A user can still run
|
||||||
|
`git` themselves with their own config to inspect or repair the
|
||||||
|
vault — the hardening only applies to relicario's invocations.
|
||||||
|
|
||||||
|
- **`cmd_init` uses production-grade `KdfParams { m: 65536, t: 3, p: 4
|
||||||
|
}`** (`main.rs:365`), even in tests. `RELICARIO_TEST_PASSPHRASE`
|
||||||
|
bypasses the prompt but does not lower the KDF cost. This is a
|
||||||
|
trade-off: integration tests pay the full Argon2id cost (~half a
|
||||||
|
second per init on a modern machine), but the same code path runs in
|
||||||
|
production. Don't lower the params here — the core's test-only fast
|
||||||
|
params are for `relicario-core` unit tests.
|
||||||
|
|
||||||
|
- **`params.json` has a nested `kdf` object, not a flat one.**
|
||||||
|
`read_params` (`session.rs:110-121`) deserializes via a private
|
||||||
|
`ParamsFile { kdf: KdfParams }` struct. The nesting exists so
|
||||||
|
`format_version`, `aead`, and `salt_path` can co-exist in the same
|
||||||
|
file for forward-compat. An earlier version of `read_params` tried
|
||||||
|
to deserialize the whole file as `KdfParams` and failed silently —
|
||||||
|
that bug was fixed in commit `b263c27`.
|
||||||
|
|
||||||
|
- **`commit_paths` is the convention but not always the call site.**
|
||||||
|
`cmd_purge`, `cmd_trash_empty`, and `cmd_device` use `git_command`
|
||||||
|
directly because their add/commit pattern doesn't quite fit
|
||||||
|
`commit_paths(vault, msg, &[paths...])`. They still use the
|
||||||
|
hardened wrapper, just at one level lower. If you find yourself
|
||||||
|
writing a new command with the same shape, prefer `commit_paths`;
|
||||||
|
reach for `git_command` directly only when you need the slightly
|
||||||
|
different control flow these three have.
|
||||||
|
|
||||||
|
- **Initial commit at `cmd_init` does not use `commit_paths`.**
|
||||||
|
Reason: `commit_paths` takes `&UnlockedVault`, but `cmd_init` doesn't
|
||||||
|
construct one — it uses the master key inline before the vault
|
||||||
|
exists. The init commit goes through `git_command` directly
|
||||||
|
(`main.rs:403-412`). This is the only production code site outside
|
||||||
|
`commit_paths` that does so.
|
||||||
|
|
||||||
|
- **`Lock` is a no-op (`main.rs:301`).** The CLI doesn't cache a
|
||||||
|
session — every command re-derives the master key. The command
|
||||||
|
exists only for UX parity with the extension, where `lock` actually
|
||||||
|
evicts a cached session. Printed message: `no cached session to
|
||||||
|
lock`.
|
||||||
|
|
||||||
|
- **`resolve_query` accepts an item id or a case-insensitive title
|
||||||
|
substring** (`main.rs:855-871`). Exact id-match wins; otherwise it
|
||||||
|
defers to `Manifest::search`. Multi-hit substring matches are
|
||||||
|
rejected with an "ambiguous" error listing the matched titles. This
|
||||||
|
is why every `cmd_*` that takes a `query: String` (get, edit,
|
||||||
|
history, rm, restore, purge, attach, attachments, extract, detach)
|
||||||
|
works the same way.
|
||||||
37
crates/relicario-cli/Cargo.toml
Normal file
37
crates/relicario-cli/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[package]
|
||||||
|
name = "relicario-cli"
|
||||||
|
version = "0.5.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "CLI for relicario password manager"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "relicario"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
relicario-core = { path = "../relicario-core" }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
anyhow = "1"
|
||||||
|
rpassword = "7"
|
||||||
|
arboard = "3"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
dirs = "5"
|
||||||
|
hex = "0.4"
|
||||||
|
rand = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
zeroize = "1"
|
||||||
|
url = "2"
|
||||||
|
data-encoding = "2"
|
||||||
|
tar = { version = "0.4", default-features = false }
|
||||||
|
clap_complete = "4"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||||
|
rqrr = "0.7"
|
||||||
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||||
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
serde_json = "1"
|
||||||
313
crates/relicario-cli/src/commands/add.rs
Normal file
313
crates/relicario-cli/src/commands/add.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
//! `relicario add <kind>` — create a new item of the given type.
|
||||||
|
//!
|
||||||
|
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
|
||||||
|
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
|
||||||
|
//! `Document` builder is the only one that needs the unlocked vault (for the
|
||||||
|
//! attachment-cap settings + writing the encrypted blob alongside the item).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::AddKind;
|
||||||
|
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||||
|
use crate::prompt::{prompt, prompt_optional, prompt_secret};
|
||||||
|
|
||||||
|
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let item = match kind {
|
||||||
|
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
|
||||||
|
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
||||||
|
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
||||||
|
build_secure_note_item(title, body_prompt, group, tags)?,
|
||||||
|
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
||||||
|
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
||||||
|
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
||||||
|
build_card_item(title, holder, expiry, kind, group, tags)?,
|
||||||
|
AddKind::Key { title, label, algorithm, group, tags } =>
|
||||||
|
build_key_item(title, label, algorithm, group, tags)?,
|
||||||
|
AddKind::Document { title, file, group, tags } =>
|
||||||
|
build_document_item(&vault, title, file, group, tags)?,
|
||||||
|
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
||||||
|
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let mut paths: Vec<String> = vec![
|
||||||
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
|
"manifest.enc".into(),
|
||||||
|
];
|
||||||
|
for att in &item.attachments {
|
||||||
|
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
||||||
|
}
|
||||||
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
|
super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
|
||||||
|
|
||||||
|
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_login_item(
|
||||||
|
title: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
password_prompt: bool,
|
||||||
|
password: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
favorite: bool,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||||||
|
use relicario_core::{Item, ItemCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
||||||
|
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
||||||
|
let parsed_url = match url {
|
||||||
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let password = if let Some(p) = password {
|
||||||
|
Some(Zeroizing::new(p))
|
||||||
|
} else if password_prompt {
|
||||||
|
Some(Zeroizing::new(prompt_secret("Password: ")?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let totp = if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||||
|
username, password, url: parsed_url, totp,
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
item.favorite = favorite;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_secure_note_item(
|
||||||
|
title: Option<String>,
|
||||||
|
body_prompt: bool,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::SecureNoteCore;
|
||||||
|
use relicario_core::{Item, ItemCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let body = if body_prompt {
|
||||||
|
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
s
|
||||||
|
} else {
|
||||||
|
prompt("Body")?
|
||||||
|
};
|
||||||
|
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||||
|
body: Zeroizing::new(body),
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_identity_item(
|
||||||
|
title: Option<String>,
|
||||||
|
full_name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
phone: Option<String>,
|
||||||
|
date_of_birth: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::IdentityCore;
|
||||||
|
use relicario_core::{Item, ItemCore};
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let dob = match date_of_birth {
|
||||||
|
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||||
|
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
||||||
|
full_name, address: None, phone, email, date_of_birth: dob,
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_card_item(
|
||||||
|
title: Option<String>,
|
||||||
|
holder: Option<String>,
|
||||||
|
expiry: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::{CardCore, CardKind};
|
||||||
|
use relicario_core::{Item, ItemCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||||
|
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||||
|
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
||||||
|
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
|
||||||
|
let pin = if pin.is_empty() { None } else { Some(pin) };
|
||||||
|
|
||||||
|
let parsed_expiry = match expiry {
|
||||||
|
Some(s) => Some(parse_month_year(&s)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let parsed_kind = match kind.as_str() {
|
||||||
|
"credit" => CardKind::Credit,
|
||||||
|
"debit" => CardKind::Debit,
|
||||||
|
"gift" => CardKind::Gift,
|
||||||
|
"loyalty" => CardKind::Loyalty,
|
||||||
|
"other" => CardKind::Other,
|
||||||
|
other => anyhow::bail!("unknown card kind: {other}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
||||||
|
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_key_item(
|
||||||
|
title: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
algorithm: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::KeyCore;
|
||||||
|
use relicario_core::{Item, ItemCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
||||||
|
let mut key_material = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
||||||
|
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||||
|
let public_key = prompt_optional("Public key (blank to skip)")?;
|
||||||
|
|
||||||
|
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
||||||
|
key_material: Zeroizing::new(key_material),
|
||||||
|
label, public_key, algorithm,
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_document_item(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
title: Option<String>,
|
||||||
|
file: PathBuf,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::DocumentCore;
|
||||||
|
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let bytes = fs::read(&file)
|
||||||
|
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||||
|
let caps = vault.load_settings()?.attachment_caps;
|
||||||
|
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
|
||||||
|
let filename = file.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let mime_type = guess_mime(&filename);
|
||||||
|
|
||||||
|
let primary_attachment = enc.id.clone();
|
||||||
|
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||||
|
filename: filename.clone(),
|
||||||
|
mime_type: mime_type.clone(),
|
||||||
|
primary_attachment: primary_attachment.clone(),
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
item.attachments.push(AttachmentRef {
|
||||||
|
id: primary_attachment.clone(),
|
||||||
|
filename, mime_type,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
created: item.created,
|
||||||
|
});
|
||||||
|
|
||||||
|
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||||
|
fs::create_dir_all(&att_dir)?;
|
||||||
|
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build_totp_item(
|
||||||
|
title: Option<String>,
|
||||||
|
issuer: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
secret: Option<String>,
|
||||||
|
period: u32,
|
||||||
|
digits: u8,
|
||||||
|
algorithm: String,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
|
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
||||||
|
use relicario_core::{Item, ItemCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
|
let secret_b32 = match secret {
|
||||||
|
Some(s) => s,
|
||||||
|
None => prompt_secret("TOTP secret (base32): ")?,
|
||||||
|
};
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
||||||
|
"sha1" => TotpAlgorithm::Sha1,
|
||||||
|
"sha256" => TotpAlgorithm::Sha256,
|
||||||
|
"sha512" => TotpAlgorithm::Sha512,
|
||||||
|
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
||||||
|
config: TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes),
|
||||||
|
algorithm: algo,
|
||||||
|
digits,
|
||||||
|
period_seconds: period,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
},
|
||||||
|
issuer, label,
|
||||||
|
}));
|
||||||
|
item.group = group;
|
||||||
|
item.tags = tags;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
175
crates/relicario-cli/src/commands/attach.rs
Normal file
175
crates/relicario-cli/src/commands/attach.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::parse::guess_mime;
|
||||||
|
|
||||||
|
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
let settings = vault.load_settings()?;
|
||||||
|
let caps = settings.attachment_caps;
|
||||||
|
|
||||||
|
if item.attachments.len() as u32 >= caps.per_item_max_count {
|
||||||
|
anyhow::bail!("item already has {} attachments (max {})",
|
||||||
|
item.attachments.len(), caps.per_item_max_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = fs::read(&file)
|
||||||
|
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||||
|
|
||||||
|
// Check per-vault total attachment bytes cap (audit I3).
|
||||||
|
let current_total: u64 = manifest.items.values()
|
||||||
|
.flat_map(|e| &e.attachment_summaries)
|
||||||
|
.map(|s| s.size)
|
||||||
|
.sum();
|
||||||
|
let new_size = bytes.len() as u64;
|
||||||
|
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||||
|
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||||
|
if current_total + new_size > hard_cap {
|
||||||
|
anyhow::bail!(
|
||||||
|
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||||
|
current_total, new_size, hard_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if current_total + new_size > soft_cap {
|
||||||
|
eprintln!(
|
||||||
|
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||||
|
soft_cap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||||
|
|
||||||
|
let filename = file.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let mime_type = guess_mime(&filename);
|
||||||
|
let aref = AttachmentRef {
|
||||||
|
id: enc.id.clone(),
|
||||||
|
filename,
|
||||||
|
mime_type,
|
||||||
|
size: bytes.len() as u64,
|
||||||
|
created: now_unix(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||||
|
fs::create_dir_all(&att_dir)?;
|
||||||
|
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||||
|
|
||||||
|
item.attachments.push(aref);
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let paths = [
|
||||||
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
|
"manifest.enc".into(),
|
||||||
|
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
|
||||||
|
];
|
||||||
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
|
super::commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||||
|
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||||
|
crate::helpers::sanitize_for_commit(&item.title),
|
||||||
|
item.id.as_str()), &path_refs)?;
|
||||||
|
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_attachments(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||||
|
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||||
|
for a in &item.attachments {
|
||||||
|
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::decrypt_attachment;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||||
|
let path = vault.root().join("attachments").join(item.id.as_str())
|
||||||
|
.join(format!("{}.enc", aid));
|
||||||
|
let bytes = fs::read(&path)
|
||||||
|
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
|
let plaintext = decrypt_attachment(&bytes, vault.key())?;
|
||||||
|
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
|
||||||
|
fs::write(&out_path, plaintext.as_slice())
|
||||||
|
.with_context(|| format!("failed to write {}", out_path.display()))?;
|
||||||
|
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
|
||||||
|
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||||
|
|
||||||
|
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||||
|
if let ItemCore::Document(d) = &item.core {
|
||||||
|
if d.primary_attachment.as_str() == aid {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot detach the primary attachment of a Document item; \
|
||||||
|
use `purge {}` to delete the whole item",
|
||||||
|
item.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = item.attachments.remove(pos);
|
||||||
|
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||||
|
.join(format!("{}.enc", removed.id.as_str()));
|
||||||
|
if blob_path.exists() {
|
||||||
|
fs::remove_file(&blob_path)
|
||||||
|
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||||
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||||
|
super::commit_paths(
|
||||||
|
&vault,
|
||||||
|
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&item_path, "manifest.enc", &blob_relpath],
|
||||||
|
)?;
|
||||||
|
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
303
crates/relicario-cli/src/commands/backup.rs
Normal file
303
crates/relicario-cli/src/commands/backup.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
//! `relicario backup export` / `relicario backup restore` — pack/unpack the
|
||||||
|
//! encrypted `.relbak` envelope.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::BackupAction;
|
||||||
|
|
||||||
|
pub fn cmd_backup(action: BackupAction) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
BackupAction::Export { out, include_image, image, no_history } => {
|
||||||
|
cmd_backup_export(out, include_image, image, no_history)
|
||||||
|
}
|
||||||
|
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cmd_backup_export(
|
||||||
|
out: PathBuf,
|
||||||
|
include_image: bool,
|
||||||
|
image: Option<PathBuf>,
|
||||||
|
no_history: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::{backup, validate_passphrase_strength};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
|
||||||
|
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
||||||
|
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
|
};
|
||||||
|
let confirm = if crate::test_backup_passphrase_override().is_some() {
|
||||||
|
passphrase.clone()
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
|
};
|
||||||
|
if passphrase.as_str() != confirm.as_str() {
|
||||||
|
anyhow::bail!("passphrases do not match");
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||||
|
anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read everything from disk that the envelope needs.
|
||||||
|
let salt = fs::read(root.join(".relicario").join("salt"))
|
||||||
|
.with_context(|| "failed to read .relicario/salt")?;
|
||||||
|
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
|
.with_context(|| "failed to read .relicario/params.json")?;
|
||||||
|
// devices.json was removed in the B1 security audit fix; fall back to
|
||||||
|
// an empty array so backups of post-B1 vaults still pack cleanly.
|
||||||
|
// Task 12 will remove the devices field from the backup format entirely.
|
||||||
|
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
||||||
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
|
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||||
|
.with_context(|| "failed to read manifest.enc")?;
|
||||||
|
let settings_enc = fs::read(root.join("settings.enc"))
|
||||||
|
.with_context(|| "failed to read settings.enc")?;
|
||||||
|
|
||||||
|
// Items.
|
||||||
|
let mut item_files = Vec::new();
|
||||||
|
let items_dir = root.join("items");
|
||||||
|
if items_dir.is_dir() {
|
||||||
|
for entry in fs::read_dir(&items_dir)? {
|
||||||
|
let p = entry?.path();
|
||||||
|
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||||
|
let id = p.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
|
||||||
|
.to_string();
|
||||||
|
let bytes = fs::read(&p)?;
|
||||||
|
item_files.push((id, bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments. Layout: attachments/<item_id>/<aid>.enc
|
||||||
|
let mut attach_files = Vec::new();
|
||||||
|
let attach_dir = root.join("attachments");
|
||||||
|
if attach_dir.is_dir() {
|
||||||
|
for entry in fs::read_dir(&attach_dir)? {
|
||||||
|
let item_dir = entry?.path();
|
||||||
|
if !item_dir.is_dir() { continue; }
|
||||||
|
let item_id = item_dir.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
|
||||||
|
.to_string();
|
||||||
|
for sub in fs::read_dir(&item_dir)? {
|
||||||
|
let p = sub?.path();
|
||||||
|
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||||
|
let aid = p.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
|
||||||
|
.to_string();
|
||||||
|
let bytes = fs::read(&p)?;
|
||||||
|
attach_files.push((item_id.clone(), aid, bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional reference image.
|
||||||
|
let image_bytes = if include_image {
|
||||||
|
let path = match image {
|
||||||
|
Some(p) => p,
|
||||||
|
None => crate::session::get_image_path()?,
|
||||||
|
};
|
||||||
|
Some(fs::read(&path)
|
||||||
|
.with_context(|| format!("failed to read reference image {}", path.display()))?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional .git/ tar.
|
||||||
|
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
|
||||||
|
|
||||||
|
let items_refs: Vec<backup::BackupItem> = item_files.iter()
|
||||||
|
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
|
||||||
|
.collect();
|
||||||
|
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
|
||||||
|
.map(|(iid, aid, bytes)| backup::BackupAttachment {
|
||||||
|
item_id: iid.clone(),
|
||||||
|
attachment_id: aid.clone(),
|
||||||
|
ciphertext: bytes,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let input = backup::BackupInput {
|
||||||
|
salt: &salt,
|
||||||
|
params_json: ¶ms_json,
|
||||||
|
devices_json: &devices_json,
|
||||||
|
manifest_enc: &manifest_enc,
|
||||||
|
settings_enc: &settings_enc,
|
||||||
|
items: items_refs,
|
||||||
|
attachments: attach_refs,
|
||||||
|
reference_jpg: image_bytes.as_deref(),
|
||||||
|
git_archive: git_archive.as_deref(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = backup::pack_backup(input, &passphrase)?;
|
||||||
|
|
||||||
|
// atomic_write via the existing pattern: write `.tmp`, rename.
|
||||||
|
let tmp = {
|
||||||
|
let mut t = out.as_os_str().to_owned();
|
||||||
|
t.push(".tmp");
|
||||||
|
PathBuf::from(t)
|
||||||
|
};
|
||||||
|
fs::write(&tmp, &bytes)
|
||||||
|
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, &out)
|
||||||
|
.with_context(|| format!("failed to rename {}", out.display()))?;
|
||||||
|
|
||||||
|
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
|
||||||
|
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||||
|
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
|
||||||
|
|
||||||
|
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
|
||||||
|
eprintln!(
|
||||||
|
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
|
||||||
|
out.display(), mib
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
|
||||||
|
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut buf);
|
||||||
|
builder.append_dir_all(".", dir)
|
||||||
|
.with_context(|| format!("failed to tar {}", dir.display()))?;
|
||||||
|
builder.finish().with_context(|| "failed to finalize git tar")?;
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::backup;
|
||||||
|
use relicario_core::{ItemId, AttachmentId};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let target = if target.is_absolute() {
|
||||||
|
target
|
||||||
|
} else {
|
||||||
|
std::env::current_dir()?.join(&target)
|
||||||
|
};
|
||||||
|
|
||||||
|
if target.join(".relicario").exists() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&target)
|
||||||
|
.with_context(|| format!("failed to create target {}", target.display()))?;
|
||||||
|
|
||||||
|
// Read input file.
|
||||||
|
let bytes = fs::read(&input)
|
||||||
|
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||||
|
|
||||||
|
// Backup passphrase prompt.
|
||||||
|
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
|
};
|
||||||
|
|
||||||
|
let unpacked = backup::unpack_backup(&bytes, &passphrase)
|
||||||
|
.map_err(|e| match e {
|
||||||
|
relicario_core::RelicarioError::Decrypt =>
|
||||||
|
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
|
||||||
|
other => anyhow::anyhow!(other),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Write vault layout.
|
||||||
|
let relicario_dir = target.join(".relicario");
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::create_dir_all(target.join("items"))?;
|
||||||
|
fs::create_dir_all(target.join("attachments"))?;
|
||||||
|
|
||||||
|
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
|
||||||
|
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
|
||||||
|
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
|
||||||
|
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
|
||||||
|
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
||||||
|
|
||||||
|
for item in &unpacked.items {
|
||||||
|
let item_id = ItemId(item.id.clone());
|
||||||
|
if !item_id.is_valid() {
|
||||||
|
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
|
||||||
|
}
|
||||||
|
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
||||||
|
}
|
||||||
|
for a in &unpacked.attachments {
|
||||||
|
let item_id = ItemId(a.item_id.clone());
|
||||||
|
let att_id = AttachmentId(a.attachment_id.clone());
|
||||||
|
if !item_id.is_valid() || !att_id.is_valid() {
|
||||||
|
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
|
||||||
|
}
|
||||||
|
let dir = target.join("attachments").join(&a.item_id);
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference image (if present).
|
||||||
|
if let Some(jpg) = &unpacked.reference_jpg {
|
||||||
|
let path = target.join("reference.jpg");
|
||||||
|
fs::write(&path, jpg)
|
||||||
|
.with_context(|| format!("failed to write reference image {}", path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .git/ history.
|
||||||
|
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||||
|
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||||
|
let cap = std::cmp::min(
|
||||||
|
(tar_bytes.len() as u64).saturating_mul(100),
|
||||||
|
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||||
|
);
|
||||||
|
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||||
|
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||||
|
let git_dir = target.join(".git");
|
||||||
|
for (rel_path, body) in entries {
|
||||||
|
let dest = git_dir.join(&rel_path);
|
||||||
|
// Paranoid OS-level check even after textual validation in core.
|
||||||
|
if !dest.starts_with(&git_dir) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||||
|
rel_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| {
|
||||||
|
format!("create parent {}", parent.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::write(&dest, &body).with_context(|| {
|
||||||
|
format!("write {}", dest.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No history bundled — start a fresh git repo.
|
||||||
|
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
|
||||||
|
|
||||||
|
// .gitignore — exclude reference image if present.
|
||||||
|
if target.join("reference.jpg").exists() {
|
||||||
|
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
|
||||||
|
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||||
|
let msg = format!("restore from backup {now_iso}");
|
||||||
|
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Restored vault to {}. Unlock with your passphrase + reference image.",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
255
crates/relicario-cli/src/commands/device.rs
Normal file
255
crates/relicario-cli/src/commands/device.rs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
//! `relicario device {add, revoke, list}` — device key management.
|
||||||
|
//!
|
||||||
|
//! Note: command bodies live here as `crate::commands::device`. Local key
|
||||||
|
//! storage and git-signing config live separately in `crate::device`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::DeviceAction;
|
||||||
|
|
||||||
|
/// Build a `GiteaClient` from flags or environment variables.
|
||||||
|
fn load_gitea_client(
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
owner: Option<String>,
|
||||||
|
repo: Option<String>,
|
||||||
|
) -> Result<crate::gitea::GiteaClient> {
|
||||||
|
let url = gitea_url
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
|
||||||
|
))?;
|
||||||
|
let token = gitea_token
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
|
||||||
|
))?;
|
||||||
|
let owner = owner
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
|
||||||
|
))?;
|
||||||
|
let repo = repo
|
||||||
|
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
|
||||||
|
))?;
|
||||||
|
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
|
||||||
|
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
let relicario_dir = root.join(".relicario");
|
||||||
|
let devices_path = relicario_dir.join("devices.json");
|
||||||
|
|
||||||
|
match action {
|
||||||
|
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
|
||||||
|
// Guard: don't overwrite an already-registered device name.
|
||||||
|
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if existing.iter().any(|d| d.name == name) {
|
||||||
|
anyhow::bail!("a device named '{}' is already registered", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Generating signing keypair...");
|
||||||
|
let (signing_priv, signing_pub) = generate_keypair()
|
||||||
|
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
|
||||||
|
|
||||||
|
eprintln!("Generating deploy keypair...");
|
||||||
|
let (deploy_priv, deploy_pub) = generate_keypair()
|
||||||
|
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
|
||||||
|
|
||||||
|
// Optionally register deploy key with Gitea.
|
||||||
|
let gitea_key_id: u64 = if no_gitea {
|
||||||
|
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
|
||||||
|
let key_title = format!("relicario-{}", name);
|
||||||
|
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
|
||||||
|
client.create_deploy_key(&key_title, &deploy_pub)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store keys locally with proper permissions.
|
||||||
|
crate::device::store_device_keys(
|
||||||
|
&name,
|
||||||
|
&signing_priv,
|
||||||
|
&signing_pub,
|
||||||
|
&deploy_priv,
|
||||||
|
&deploy_pub,
|
||||||
|
gitea_key_id,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Mark as current device.
|
||||||
|
crate::device::set_current_device(&name)?;
|
||||||
|
|
||||||
|
// Configure git signing + SSH deploy key in the vault repo.
|
||||||
|
crate::device::configure_git_signing(&root, &name)?;
|
||||||
|
|
||||||
|
// Update devices.json.
|
||||||
|
let current_name = name.clone();
|
||||||
|
let mut devices = existing;
|
||||||
|
devices.push(DeviceEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
public_key: signing_pub.clone(),
|
||||||
|
added_at: relicario_core::now_unix(),
|
||||||
|
added_by: current_name,
|
||||||
|
});
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||||
|
|
||||||
|
// Commit the update.
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["add", ".relicario/devices.json"],
|
||||||
|
&format!("device register \"{name}\": git add .relicario/devices.json"),
|
||||||
|
)?;
|
||||||
|
let msg = format!("device: register {}", name);
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["commit", "-m", &msg],
|
||||||
|
&format!("device register \"{name}\": git commit"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Device '{}' registered.", name);
|
||||||
|
eprintln!("Signing public key:");
|
||||||
|
eprintln!(" {}", signing_pub);
|
||||||
|
if gitea_key_id != 0 {
|
||||||
|
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAction::Revoke { name } => {
|
||||||
|
// Guard: refuse to revoke the currently active device (would lock
|
||||||
|
// the user out). They must add another device first.
|
||||||
|
if let Some(current) = crate::device::current_device()? {
|
||||||
|
if current == name {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot revoke the current device '{}' — you would lose \
|
||||||
|
push access. Register another device first.",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load devices.json.
|
||||||
|
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let device = devices
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.name == name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Remove from devices.json.
|
||||||
|
devices.retain(|d| d.name != name);
|
||||||
|
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||||
|
|
||||||
|
// Append to revoked.json.
|
||||||
|
let revoked_path = relicario_dir.join("revoked.json");
|
||||||
|
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let revoked_by = crate::device::current_device()?
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
revoked.push(RevokedEntry {
|
||||||
|
name: name.clone(),
|
||||||
|
public_key: device.public_key.clone(),
|
||||||
|
revoked_at: relicario_core::now_unix(),
|
||||||
|
revoked_by,
|
||||||
|
});
|
||||||
|
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
|
||||||
|
|
||||||
|
// Delete deploy key from Gitea (best-effort — don't fail if it
|
||||||
|
// was already deleted or the config is missing).
|
||||||
|
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
|
||||||
|
if key_id != 0 {
|
||||||
|
// Build client from env vars only (no flags in revoke).
|
||||||
|
match load_gitea_client(None, None, None, None) {
|
||||||
|
Ok(client) => {
|
||||||
|
if let Err(e) = client.delete_deploy_key(key_id) {
|
||||||
|
eprintln!(
|
||||||
|
"warning: failed to delete Gitea deploy key {}: {}",
|
||||||
|
key_id, e
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("Deleted Gitea deploy key {}.", key_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"warning: Gitea env vars not set — deploy key {} \
|
||||||
|
not deleted from Gitea.",
|
||||||
|
key_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit devices.json + revoked.json (always both — revoked.json
|
||||||
|
// was just written above so it is guaranteed to exist).
|
||||||
|
let add_args = [
|
||||||
|
"add",
|
||||||
|
".relicario/devices.json",
|
||||||
|
".relicario/revoked.json",
|
||||||
|
];
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&add_args,
|
||||||
|
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
|
||||||
|
)?;
|
||||||
|
let msg = format!("device: revoke {}", name);
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["commit", "-m", &msg],
|
||||||
|
&format!("device revoke \"{name}\": git commit"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Device '{}' revoked.", name);
|
||||||
|
eprintln!("Revoked signing key: {}", device.public_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceAction::List => {
|
||||||
|
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let current = crate::device::current_device()?.unwrap_or_default();
|
||||||
|
|
||||||
|
if devices.is_empty() {
|
||||||
|
println!("No registered devices.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||||
|
println!("{}", "-".repeat(72));
|
||||||
|
for d in &devices {
|
||||||
|
let marker = if d.name == current { " *" } else { "" };
|
||||||
|
let added = crate::helpers::iso8601(d.added_at);
|
||||||
|
// Show only the first 40 chars of the public key line for readability.
|
||||||
|
let key_prefix: String = d.public_key.chars().take(40).collect();
|
||||||
|
println!("{:<20} {:<20} {}{}",
|
||||||
|
d.name, added, key_prefix, marker);
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
println!("\n* = current device");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
crates/relicario-cli/src/commands/edit.rs
Normal file
171
crates/relicario-cli/src/commands/edit.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//! `relicario edit <query>` — interactive per-type field editing with history capture.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::parse::base32_decode_lenient;
|
||||||
|
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||||
|
|
||||||
|
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
|
||||||
|
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||||
|
item.title, item.id.as_str());
|
||||||
|
|
||||||
|
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
||||||
|
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
|
||||||
|
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let history = &mut item.field_history;
|
||||||
|
match &mut item.core {
|
||||||
|
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||||
|
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||||
|
ItemCore::Identity(i) => edit_identity(i)?,
|
||||||
|
ItemCore::Card(c) => edit_card(c, history)?,
|
||||||
|
ItemCore::Key(k) => edit_key(k, history)?,
|
||||||
|
ItemCore::Document(_) => edit_document_message(),
|
||||||
|
ItemCore::Totp(t) => edit_totp(t, history)?,
|
||||||
|
}
|
||||||
|
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
||||||
|
// that touch history-tracked fields take the item's field_history map so
|
||||||
|
// they can record the prior value alongside the change.
|
||||||
|
|
||||||
|
type FieldHistory = std::collections::HashMap<
|
||||||
|
relicario_core::FieldId,
|
||||||
|
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn edit_login(
|
||||||
|
l: &mut relicario_core::item_types::LoginCore,
|
||||||
|
history: &mut FieldHistory,
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||||
|
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||||
|
}
|
||||||
|
if prompt_yesno("Change password?")? {
|
||||||
|
let old = l.password.clone();
|
||||||
|
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||||
|
if let Some(old_pw) = old {
|
||||||
|
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = totp_qr {
|
||||||
|
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||||
|
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||||
|
l.totp = Some(TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret_bytes),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
});
|
||||||
|
eprintln!("TOTP secret set from QR image.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if prompt_yesno("Edit body?")? {
|
||||||
|
let old = n.body.clone();
|
||||||
|
eprintln!("Enter new body; end with Ctrl-D:");
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
n.body = Zeroizing::new(s);
|
||||||
|
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||||
|
if prompt_yesno("Change card number?")? {
|
||||||
|
let old = c.number.clone();
|
||||||
|
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||||
|
if let Some(o) = old {
|
||||||
|
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if prompt_yesno("Replace key material?")? {
|
||||||
|
eprintln!("Paste new key material; end with Ctrl-D:");
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
let old = k.key_material.clone();
|
||||||
|
k.key_material = Zeroizing::new(s);
|
||||||
|
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_document_message() {
|
||||||
|
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||||
|
if prompt_yesno("Change TOTP secret?")? {
|
||||||
|
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||||
|
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||||
|
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||||
|
t.config.secret = Zeroizing::new(new_bytes);
|
||||||
|
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_history(
|
||||||
|
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
|
||||||
|
synthetic_key: &str,
|
||||||
|
old_value: zeroize::Zeroizing<String>,
|
||||||
|
) {
|
||||||
|
use relicario_core::item::FieldHistoryEntry;
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||||
|
// custom-field UUIDs can't collide).
|
||||||
|
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||||
|
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||||
|
value: old_value,
|
||||||
|
replaced_at: now_unix(),
|
||||||
|
});
|
||||||
|
}
|
||||||
68
crates/relicario-cli/src/commands/generate.rs
Normal file
68
crates/relicario-cli/src/commands/generate.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//! `relicario generate` — emit a fresh password or BIP39 passphrase.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_generate(
|
||||||
|
length: Option<u32>,
|
||||||
|
bip39: bool,
|
||||||
|
words: Option<u32>,
|
||||||
|
symbols: Option<String>,
|
||||||
|
separator: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::{
|
||||||
|
generate_passphrase, generate_password, Capitalization, CharClasses,
|
||||||
|
GeneratorRequest, SymbolCharset,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
|
||||||
|
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
|
||||||
|
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
Some(vault.load_settings()?.generator_defaults)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
|
||||||
|
// vault default is in (Random when no vault).
|
||||||
|
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
|
||||||
|
|
||||||
|
let output = if use_bip39 {
|
||||||
|
let (def_words, def_sep, def_cap) = match &vault_defaults {
|
||||||
|
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
|
||||||
|
(*word_count, separator.clone(), *capitalization)
|
||||||
|
}
|
||||||
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||||
|
};
|
||||||
|
generate_passphrase(&GeneratorRequest::Bip39 {
|
||||||
|
word_count: words.unwrap_or(def_words),
|
||||||
|
separator: separator.unwrap_or(def_sep),
|
||||||
|
capitalization: def_cap,
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
let (def_length, def_classes, def_charset) = match &vault_defaults {
|
||||||
|
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
|
||||||
|
(*length, *classes, symbol_charset.clone())
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
20,
|
||||||
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
SymbolCharset::SafeOnly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let symbol_charset = match symbols.as_deref() {
|
||||||
|
None => def_charset,
|
||||||
|
Some("safe") => SymbolCharset::SafeOnly,
|
||||||
|
Some("extended") => SymbolCharset::Extended,
|
||||||
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||||
|
};
|
||||||
|
generate_password(&GeneratorRequest::Random {
|
||||||
|
length: length.unwrap_or(def_length),
|
||||||
|
classes: def_classes,
|
||||||
|
symbol_charset,
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", output.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
107
crates/relicario-cli/src/commands/get.rs
Normal file
107
crates/relicario-cli/src/commands/get.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//! `relicario get` — print a single item, masking secrets unless `--show`.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
println!("ID: {}", item.id.as_str());
|
||||||
|
println!("Title: {}", item.title);
|
||||||
|
println!("Type: {:?}", item.r#type);
|
||||||
|
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||||
|
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||||
|
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||||
|
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||||
|
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||||
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||||
|
if let Some(t) = &l.totp {
|
||||||
|
if show {
|
||||||
|
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||||
|
} else {
|
||||||
|
println!("TOTP: **** (use --show to reveal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.password.clone()
|
||||||
|
}
|
||||||
|
ItemCore::SecureNote(n) => {
|
||||||
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||||
|
else { println!("Body: ********"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Identity(i) => {
|
||||||
|
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||||
|
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||||
|
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||||
|
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Card(c) => {
|
||||||
|
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||||
|
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||||
|
println!("Kind: {:?}", c.kind);
|
||||||
|
c.number.clone()
|
||||||
|
}
|
||||||
|
ItemCore::Key(k) => {
|
||||||
|
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||||
|
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||||
|
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||||
|
Some(k.key_material.clone())
|
||||||
|
}
|
||||||
|
ItemCore::Document(d) => {
|
||||||
|
println!("Filename: {}", d.filename);
|
||||||
|
println!("MIME: {}", d.mime_type);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
ItemCore::Totp(t) => {
|
||||||
|
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||||
|
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||||
|
println!("Period: {}s", t.config.period_seconds);
|
||||||
|
println!("Digits: {}", t.config.digits);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(secret) = primary_secret {
|
||||||
|
if show {
|
||||||
|
println!("Secret: {}", secret.as_str());
|
||||||
|
} else {
|
||||||
|
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||||
|
}
|
||||||
|
if copy {
|
||||||
|
copy_to_clipboard_then_clear(&secret)?;
|
||||||
|
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||||
|
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||||
|
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||||
|
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||||
|
// and then rewrites the clipboard with empty string. Even if the user
|
||||||
|
// copies something else in the interim, we still overwrite once.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||||
|
if let Ok(mut cb) = Clipboard::new() {
|
||||||
|
let _ = cb.set_text(String::new());
|
||||||
|
drop(cleared_copy); // zeroize the detached copy
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
88
crates/relicario-cli/src/commands/import.rs
Normal file
88
crates/relicario-cli/src/commands/import.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! `relicario import` — currently only LastPass CSV is supported.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::ImportAction;
|
||||||
|
|
||||||
|
pub fn cmd_import(action: ImportAction) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv;
|
||||||
|
|
||||||
|
let csv_bytes = fs::read(&csv_path)
|
||||||
|
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
|
||||||
|
|
||||||
|
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
|
||||||
|
|
||||||
|
if items.is_empty() {
|
||||||
|
// Print all warnings so the user sees why nothing imported.
|
||||||
|
for w in &warnings {
|
||||||
|
print_warning(w);
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"imported 0 items from {} — see warnings above",
|
||||||
|
csv_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let total = items.len();
|
||||||
|
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
|
||||||
|
|
||||||
|
for (idx, item) in items.iter().enumerate() {
|
||||||
|
vault.save_item(item)?;
|
||||||
|
manifest.upsert(item);
|
||||||
|
written_paths.push(format!("items/{}.enc", item.id.as_str()));
|
||||||
|
|
||||||
|
let n = idx + 1;
|
||||||
|
if n % 50 == 0 || n == total {
|
||||||
|
eprintln!("[{n}/{total}] importing...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
written_paths.push("manifest.enc".into());
|
||||||
|
|
||||||
|
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||||
|
let csv_filename = csv_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("lastpass.csv");
|
||||||
|
super::commit_paths(
|
||||||
|
&vault,
|
||||||
|
&format!("import: {} items from LastPass ({})", total, csv_filename),
|
||||||
|
&path_refs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for w in &warnings {
|
||||||
|
print_warning(w);
|
||||||
|
}
|
||||||
|
// Counts only true skips, not partial imports. Coupled by convention to
|
||||||
|
// the parser's warning message strings: skip messages end in "— skipped",
|
||||||
|
// partial-import messages say "imported without TOTP" / "imported without URL".
|
||||||
|
// If a future warning uses the word "skipped" in any other sense, this filter
|
||||||
|
// will need to switch to an enum tag (see ImportWarning::message).
|
||||||
|
eprintln!(
|
||||||
|
"Imported {}, skipped {} (see warnings above)",
|
||||||
|
total,
|
||||||
|
warnings.iter().filter(|w| w.message.contains("skipped")).count()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
|
||||||
|
let prefix = match &w.title {
|
||||||
|
Some(t) => format!("row {} ({}):", w.row, t),
|
||||||
|
None => format!("row {}:", w.row),
|
||||||
|
};
|
||||||
|
eprintln!("warning: {prefix} {}", w.message);
|
||||||
|
}
|
||||||
98
crates/relicario-cli/src/commands/init.rs
Normal file
98
crates/relicario-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! `relicario init` — bootstrap a fresh vault in the current directory.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use relicario_core::{
|
||||||
|
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
|
||||||
|
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let root = std::env::current_dir()?;
|
||||||
|
let relicario_dir = root.join(".relicario");
|
||||||
|
if relicario_dir.exists() {
|
||||||
|
anyhow::bail!(".relicario/ already exists in {}", root.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passphrase with strength gate (audit H3).
|
||||||
|
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||||
|
// TTY prompt so integration tests can run without a real TTY.
|
||||||
|
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||||
|
};
|
||||||
|
let confirm = if crate::test_passphrase_override().is_some() {
|
||||||
|
passphrase.clone()
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
|
};
|
||||||
|
if passphrase.as_str() != confirm.as_str() {
|
||||||
|
anyhow::bail!("passphrases do not match");
|
||||||
|
}
|
||||||
|
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||||
|
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image secret: 32 random bytes, embedded in the carrier.
|
||||||
|
let image_secret = {
|
||||||
|
let mut buf = Zeroizing::new([0u8; 32]);
|
||||||
|
OsRng.fill_bytes(buf.as_mut_slice());
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
let carrier = fs::read(&image)
|
||||||
|
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||||
|
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||||
|
fs::write(&output, &stego)
|
||||||
|
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||||
|
|
||||||
|
// Vault salt + KDF params.
|
||||||
|
let mut salt = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
|
||||||
|
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||||
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||||
|
|
||||||
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
|
fs::create_dir_all(root.join("items"))?;
|
||||||
|
fs::create_dir_all(root.join("attachments"))?;
|
||||||
|
fs::write(relicario_dir.join("salt"), salt)?;
|
||||||
|
fs::write(
|
||||||
|
relicario_dir.join("params.json"),
|
||||||
|
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?,
|
||||||
|
)?;
|
||||||
|
let manifest = Manifest::new();
|
||||||
|
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||||
|
let settings = VaultSettings::default();
|
||||||
|
fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;
|
||||||
|
|
||||||
|
// .gitignore excludes the reference image.
|
||||||
|
let fname = output.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
|
||||||
|
.to_string_lossy();
|
||||||
|
let gitignore = format!("{fname}\n");
|
||||||
|
fs::write(root.join(".gitignore"), gitignore)?;
|
||||||
|
|
||||||
|
// git init + initial commit via hardened wrapper.
|
||||||
|
crate::helpers::git_run(&root, &["init"], "init: git init")?;
|
||||||
|
let _ = crate::helpers::git_command(&root, &[
|
||||||
|
"add", ".gitignore", ".relicario/params.json",
|
||||||
|
".relicario/salt", "manifest.enc", "settings.enc",
|
||||||
|
]).status()?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
&root,
|
||||||
|
&["commit", "-m", "init: new Relicario vault (format v2)"],
|
||||||
|
"init: git commit",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Vault initialized at {}", root.display());
|
||||||
|
eprintln!("Reference image: {}", output.display());
|
||||||
|
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
103
crates/relicario-cli/src/commands/list.rs
Normal file
103
crates/relicario-cli/src/commands/list.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! `relicario list` and `relicario history` — both read-only browse paths.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_list(
|
||||||
|
type_filter: Option<String>,
|
||||||
|
group_filter: Option<String>,
|
||||||
|
tag_filter: Option<String>,
|
||||||
|
trashed: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
use relicario_core::ItemType;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
|
||||||
|
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||||
|
None => None,
|
||||||
|
Some("login") => Some(ItemType::Login),
|
||||||
|
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
|
||||||
|
Some("identity") => Some(ItemType::Identity),
|
||||||
|
Some("card") => Some(ItemType::Card),
|
||||||
|
Some("key") => Some(ItemType::Key),
|
||||||
|
Some("document") => Some(ItemType::Document),
|
||||||
|
Some("totp") => Some(ItemType::Totp),
|
||||||
|
Some(other) => anyhow::bail!("unknown type filter: {other}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut entries: Vec<_> = manifest.items.values()
|
||||||
|
.filter(|e| {
|
||||||
|
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
|
||||||
|
})
|
||||||
|
.filter(|e| match parsed_type {
|
||||||
|
Some(t) => e.r#type == t,
|
||||||
|
None => true,
|
||||||
|
})
|
||||||
|
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||||
|
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
eprintln!("(no items match)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||||
|
for e in entries {
|
||||||
|
let fav = if e.favorite { " *" } else { "" };
|
||||||
|
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
println!("History for {} ({})", item.title, item.id.as_str());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Filter and sort the field-id keys so output is deterministic.
|
||||||
|
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||||
|
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
let mut printed_any = false;
|
||||||
|
for fid in keys {
|
||||||
|
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||||
|
if let Some(filter) = &field {
|
||||||
|
if display_name != filter && fid.0 != *filter { continue; }
|
||||||
|
}
|
||||||
|
let entries = &item.field_history[fid];
|
||||||
|
if entries.is_empty() { continue; }
|
||||||
|
printed_any = true;
|
||||||
|
|
||||||
|
println!("{display_name} ({} {})",
|
||||||
|
entries.len(),
|
||||||
|
if entries.len() == 1 { "entry" } else { "entries" });
|
||||||
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||||
|
if show {
|
||||||
|
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||||
|
} else {
|
||||||
|
println!(" [{i}] {ts} ********");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !printed_any {
|
||||||
|
if field.is_some() {
|
||||||
|
println!("no history for the requested field");
|
||||||
|
} else {
|
||||||
|
println!("no history captured for this item");
|
||||||
|
}
|
||||||
|
} else if !show {
|
||||||
|
println!("(use --show to reveal values)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
60
crates/relicario-cli/src/commands/mod.rs
Normal file
60
crates/relicario-cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//! Per-command modules — one file per top-level subcommand.
|
||||||
|
//!
|
||||||
|
//! `main.rs` holds the clap surface (argument enums) and the dispatch
|
||||||
|
//! `match`; the actual command bodies live here. Helpers shared between
|
||||||
|
//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in
|
||||||
|
//! this file as `pub(crate)` so siblings can pull them in via
|
||||||
|
//! `use crate::commands::*`.
|
||||||
|
|
||||||
|
pub mod add;
|
||||||
|
pub mod attach;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod device;
|
||||||
|
pub mod edit;
|
||||||
|
pub mod generate;
|
||||||
|
pub mod get;
|
||||||
|
pub mod import;
|
||||||
|
pub mod init;
|
||||||
|
pub mod list;
|
||||||
|
pub mod rate;
|
||||||
|
pub mod recovery_qr;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod status;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod trash;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub(crate) fn commit_paths(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
message: &str,
|
||||||
|
paths: &[&str],
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut args: Vec<&str> = vec!["add"];
|
||||||
|
args.extend_from_slice(paths);
|
||||||
|
crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["commit", "-m", message],
|
||||||
|
&format!("commit \"{message}\": git commit"),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_query<'a>(
|
||||||
|
manifest: &'a relicario_core::Manifest,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<&'a relicario_core::ManifestEntry> {
|
||||||
|
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
let hits: Vec<_> = manifest.search(query);
|
||||||
|
match hits.len() {
|
||||||
|
0 => anyhow::bail!("no item matches `{query}`"),
|
||||||
|
1 => Ok(hits[0]),
|
||||||
|
_ => {
|
||||||
|
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||||
|
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/relicario-cli/src/commands/rate.rs
Normal file
28
crates/relicario-cli/src/commands/rate.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//! `relicario rate` — score a passphrase via zxcvbn.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_rate(passphrase: String) -> Result<()> {
|
||||||
|
let pw: String = if passphrase == "-" {
|
||||||
|
use std::io::BufRead;
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut line = String::new();
|
||||||
|
stdin.lock().read_line(&mut line)?;
|
||||||
|
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
||||||
|
} else {
|
||||||
|
passphrase
|
||||||
|
};
|
||||||
|
let est = relicario_core::generators::rate_passphrase(&pw);
|
||||||
|
let label = match est.score {
|
||||||
|
0 => "very weak",
|
||||||
|
1 => "weak",
|
||||||
|
2 => "fair",
|
||||||
|
3 => "good",
|
||||||
|
4 => "strong",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
println!("score: {}/4 ({})", est.score, label);
|
||||||
|
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
||||||
|
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
use crate::RecoveryQrCmd;
|
||||||
|
|
||||||
|
pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
|
||||||
|
match cmd {
|
||||||
|
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
|
||||||
|
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_recovery_qr_generate() -> Result<()> {
|
||||||
|
use relicario_core::{generate_recovery_qr, imgsecret};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
let image_path = crate::session::get_image_path()?;
|
||||||
|
let image_bytes = std::fs::read(&image_path)
|
||||||
|
.with_context(|| format!("read reference image {}", image_path.display()))?;
|
||||||
|
let image_secret = imgsecret::extract(&image_bytes)
|
||||||
|
.context("extract image secret")?;
|
||||||
|
|
||||||
|
let passphrase = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Enter vault passphrase: ")
|
||||||
|
.context("read passphrase")?
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
use qrcode::{EcLevel, QrCode, render::unicode};
|
||||||
|
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
|
||||||
|
.expect("valid payload");
|
||||||
|
let image = code
|
||||||
|
.render::<unicode::Dense1x2>()
|
||||||
|
.dark_color(unicode::Dense1x2::Dark)
|
||||||
|
.light_color(unicode::Dense1x2::Light)
|
||||||
|
.build();
|
||||||
|
println!("{image}");
|
||||||
|
println!("Recovery QR generated. Print or photograph this code and store it securely.");
|
||||||
|
println!("The QR has NOT been saved to disk.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_recovery_qr_unwrap() -> Result<()> {
|
||||||
|
use relicario_core::unwrap_recovery_qr;
|
||||||
|
use std::io::BufRead;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
println!("Paste the base64 recovery QR payload and press Enter:");
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let payload_b64 = stdin.lock().lines().next()
|
||||||
|
.context("no input")??;
|
||||||
|
let payload_b64 = payload_b64.trim().to_owned();
|
||||||
|
|
||||||
|
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
|
||||||
|
|
||||||
|
let passphrase = Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Enter passphrase: ")
|
||||||
|
.context("read passphrase")?
|
||||||
|
);
|
||||||
|
|
||||||
|
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
println!("image_secret: {}", hex::encode(secret.as_ref()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
98
crates/relicario-cli/src/commands/settings.rs
Normal file
98
crates/relicario-cli/src/commands/settings.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::SettingsAction;
|
||||||
|
|
||||||
|
pub fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||||
|
use relicario_core::{
|
||||||
|
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||||
|
SymbolCharset, TrashRetention,
|
||||||
|
};
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut settings = vault.load_settings()?;
|
||||||
|
|
||||||
|
match action {
|
||||||
|
SettingsAction::Show => {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
SettingsAction::TrashRetention { days, forever } => {
|
||||||
|
settings.trash_retention = match (days, forever) {
|
||||||
|
(Some(d), false) => TrashRetention::Days(d),
|
||||||
|
(None, true) => TrashRetention::Forever,
|
||||||
|
_ => anyhow::bail!("specify exactly one of --days or --forever"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SettingsAction::HistoryRetention { last_n, days, forever } => {
|
||||||
|
settings.field_history_retention = match (last_n, days, forever) {
|
||||||
|
(Some(n), None, false) => HistoryRetention::LastN(n),
|
||||||
|
(None, Some(d), false) => HistoryRetention::Days(d),
|
||||||
|
(None, None, true) => HistoryRetention::Forever,
|
||||||
|
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
SettingsAction::AttachmentCap {
|
||||||
|
per_attachment_max_bytes, per_item_max_count,
|
||||||
|
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
|
||||||
|
} => {
|
||||||
|
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
|
||||||
|
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
|
||||||
|
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
|
||||||
|
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
|
||||||
|
}
|
||||||
|
SettingsAction::GeneratorDefaults {
|
||||||
|
random, bip39, length, words, symbols, separator,
|
||||||
|
} => {
|
||||||
|
// Decide target mode: explicit flag wins, else preserve current.
|
||||||
|
let target_bip39 = if random { false }
|
||||||
|
else if bip39 { true }
|
||||||
|
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
|
||||||
|
|
||||||
|
// Pull existing fields where compatible, else seed with sensible
|
||||||
|
// defaults (kept in sync with `GeneratorRequest::default()`).
|
||||||
|
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
|
||||||
|
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||||
|
(*length, *classes, symbol_charset.clone())
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
20,
|
||||||
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
SymbolCharset::SafeOnly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
|
||||||
|
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||||
|
(*word_count, separator.clone(), *capitalization)
|
||||||
|
}
|
||||||
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.generator_defaults = if target_bip39 {
|
||||||
|
GeneratorRequest::Bip39 {
|
||||||
|
word_count: words.unwrap_or(cur_words),
|
||||||
|
separator: separator.unwrap_or(cur_sep),
|
||||||
|
capitalization: cur_cap,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let charset = match symbols.as_deref() {
|
||||||
|
None => cur_charset,
|
||||||
|
Some("safe") => SymbolCharset::SafeOnly,
|
||||||
|
Some("extended") => SymbolCharset::Extended,
|
||||||
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||||
|
};
|
||||||
|
GeneratorRequest::Random {
|
||||||
|
length: length.unwrap_or(cur_length),
|
||||||
|
classes: cur_classes,
|
||||||
|
symbol_charset: charset,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.save_settings(&settings)?;
|
||||||
|
super::commit_paths(&vault, "settings: update", &["settings.enc"])?;
|
||||||
|
eprintln!("Settings updated.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
52
crates/relicario-cli/src/commands/status.rs
Normal file
52
crates/relicario-cli/src/commands/status.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! `relicario status` — vault-level summary (counts, last commit, last backup).
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_status() -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let root = vault.root().to_path_buf();
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let total_items = manifest.items.len();
|
||||||
|
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||||
|
let active_items = total_items - trashed_items;
|
||||||
|
|
||||||
|
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||||
|
.flat_map(|e| e.attachment_summaries.iter())
|
||||||
|
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||||
|
|
||||||
|
let last_commit = crate::helpers::git_command(&root, &[
|
||||||
|
"log", "-1", "--pretty=format:%h %s",
|
||||||
|
]).output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "(no commits)".into());
|
||||||
|
|
||||||
|
// Last backup age (read from marker written by cmd_backup_export).
|
||||||
|
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
||||||
|
let last_backup_str = if last_backup_path.exists() {
|
||||||
|
let line = std::fs::read_to_string(&last_backup_path)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
||||||
|
match chrono::DateTime::parse_from_rfc3339(&line) {
|
||||||
|
Ok(then) => {
|
||||||
|
let now = relicario_core::now_unix();
|
||||||
|
let age = now - then.timestamp();
|
||||||
|
crate::helpers::humanize_age(age.max(0))
|
||||||
|
}
|
||||||
|
Err(_) => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"never".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Vault: {}", root.display());
|
||||||
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
|
println!("Last commit: {last_commit}");
|
||||||
|
println!("Last export: {last_backup_str}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
11
crates/relicario-cli/src/commands/sync.rs
Normal file
11
crates/relicario-cli/src/commands/sync.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! `relicario sync` — pull --rebase + push.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn cmd_sync() -> Result<()> {
|
||||||
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
|
||||||
|
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
|
||||||
|
eprintln!("Sync complete.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
149
crates/relicario-cli/src/commands/trash.rs
Normal file
149
crates/relicario-cli/src/commands/trash.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent),
|
||||||
|
//! `trash list` / `trash empty`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::TrashAction;
|
||||||
|
|
||||||
|
pub fn cmd_rm(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
item.soft_delete();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Moved to trash: {}", item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_restore(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
item.restore();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||||
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
|
eprintln!("Restored: {}", item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
|
||||||
|
/// the manifest in memory. Returns the relative paths the caller must stage
|
||||||
|
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
|
||||||
|
/// batches them.
|
||||||
|
pub(super) fn purge_item_filesystem(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
manifest: &mut relicario_core::Manifest,
|
||||||
|
id: &relicario_core::ItemId,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
use std::{fs, io::ErrorKind};
|
||||||
|
|
||||||
|
let item_rel = format!("items/{}.enc", id.as_str());
|
||||||
|
let att_rel = format!("attachments/{}", id.as_str());
|
||||||
|
|
||||||
|
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
|
||||||
|
match r {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ignore_missing(fs::remove_file(vault.item_path(id)))?;
|
||||||
|
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
|
||||||
|
manifest.remove(id);
|
||||||
|
|
||||||
|
eprintln!("Purged: {title}");
|
||||||
|
Ok(vec![item_rel, att_rel])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_purge(query: String) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let title = entry.title.clone();
|
||||||
|
let _ = entry;
|
||||||
|
|
||||||
|
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||||
|
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["add", "manifest.enc"],
|
||||||
|
&format!("{purge_ctx}: git add manifest.enc"),
|
||||||
|
)?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
||||||
|
&format!("{purge_ctx}: git commit"),
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_trash(action: TrashAction) -> Result<()> {
|
||||||
|
match action {
|
||||||
|
TrashAction::List => super::list::cmd_list(None, None, None, true),
|
||||||
|
TrashAction::Empty => cmd_trash_empty(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cmd_trash_empty() -> Result<()> {
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let settings = vault.load_settings()?;
|
||||||
|
let now = now_unix();
|
||||||
|
|
||||||
|
let purgeable: Vec<_> = manifest.items.values()
|
||||||
|
.filter(|e| match e.trashed_at {
|
||||||
|
Some(t) => settings.trash_retention.should_purge(t, now),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.map(|e| (e.id.clone(), e.title.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if purgeable.is_empty() {
|
||||||
|
eprintln!("nothing past retention window");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_paths: Vec<String> = Vec::new();
|
||||||
|
let purged_count = purgeable.len();
|
||||||
|
for (id, title) in purgeable {
|
||||||
|
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||||
|
all_paths.append(&mut paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["add", "manifest.enc"],
|
||||||
|
"trash empty: git add manifest.enc",
|
||||||
|
)?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||||
|
"trash empty: git commit",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
169
crates/relicario-cli/src/device.rs
Normal file
169
crates/relicario-cli/src/device.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
//! Local device key storage and git signing configuration.
|
||||||
|
//!
|
||||||
|
//! Keys live under `~/.config/relicario/devices/<device-name>/`:
|
||||||
|
//! signing.key — ed25519 private key (OpenSSH, 0600)
|
||||||
|
//! signing.pub — ed25519 public key (OpenSSH single line)
|
||||||
|
//! deploy.key — ed25519 private key for git push (OpenSSH, 0600)
|
||||||
|
//! deploy.pub — ed25519 public key registered as Gitea deploy key
|
||||||
|
//! gitea_key_id — numeric Gitea deploy key ID for later revocation
|
||||||
|
//!
|
||||||
|
//! The file `~/.config/relicario/devices/current` holds the active device name
|
||||||
|
//! (one plain-text line).
|
||||||
|
|
||||||
|
use std::fs::{self, Permissions};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
/// `~/.config/relicario/devices/`
|
||||||
|
pub fn devices_dir() -> Result<PathBuf> {
|
||||||
|
let config = dirs::config_dir()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no config directory available"))?;
|
||||||
|
Ok(config.join("relicario").join("devices"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `~/.config/relicario/devices/<name>/`
|
||||||
|
pub fn device_dir(name: &str) -> Result<PathBuf> {
|
||||||
|
Ok(devices_dir()?.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current device name from `devices/current`, or `None` if not set.
|
||||||
|
pub fn current_device() -> Result<Option<String>> {
|
||||||
|
let path = devices_dir()?.join("current");
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let name = fs::read_to_string(&path)
|
||||||
|
.context("read current device")?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the active device name to `devices/current`.
|
||||||
|
pub fn set_current_device(name: &str) -> Result<()> {
|
||||||
|
let dir = devices_dir()?;
|
||||||
|
fs::create_dir_all(&dir).context("create devices dir")?;
|
||||||
|
fs::write(dir.join("current"), format!("{name}\n"))
|
||||||
|
.context("write current device")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store all keys for a device, applying restrictive permissions on private
|
||||||
|
/// key files on Unix.
|
||||||
|
pub fn store_device_keys(
|
||||||
|
name: &str,
|
||||||
|
signing_private: &str,
|
||||||
|
signing_public: &str,
|
||||||
|
deploy_private: &str,
|
||||||
|
deploy_public: &str,
|
||||||
|
gitea_key_id: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let dir = device_dir(name)?;
|
||||||
|
fs::create_dir_all(&dir).context("create device dir")?;
|
||||||
|
|
||||||
|
fs::write(dir.join("signing.key"), signing_private)
|
||||||
|
.context("write signing.key")?;
|
||||||
|
fs::write(dir.join("signing.pub"), signing_public)
|
||||||
|
.context("write signing.pub")?;
|
||||||
|
fs::write(dir.join("deploy.key"), deploy_private)
|
||||||
|
.context("write deploy.key")?;
|
||||||
|
fs::write(dir.join("deploy.pub"), deploy_public)
|
||||||
|
.context("write deploy.pub")?;
|
||||||
|
fs::write(dir.join("gitea_key_id"), gitea_key_id.to_string())
|
||||||
|
.context("write gitea_key_id")?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(dir.join("signing.key"), Permissions::from_mode(0o600))
|
||||||
|
.context("chmod signing.key")?;
|
||||||
|
fs::set_permissions(dir.join("deploy.key"), Permissions::from_mode(0o600))
|
||||||
|
.context("chmod deploy.key")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the signing private key for a device.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
|
let path = device_dir(name)?.join("signing.key");
|
||||||
|
let key = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read signing key for device '{name}'"))?;
|
||||||
|
Ok(Zeroizing::new(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the deploy private key for a device.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
|
let path = device_dir(name)?.join("deploy.key");
|
||||||
|
let key = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read deploy key for device '{name}'"))?;
|
||||||
|
Ok(Zeroizing::new(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the Gitea deploy key ID for a device.
|
||||||
|
pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
||||||
|
let path = device_dir(name)?.join("gitea_key_id");
|
||||||
|
let id_str = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read Gitea key ID for device '{name}'"))?;
|
||||||
|
id_str.trim().parse().context("parse Gitea key ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the local key directory for a device.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||||
|
let dir = device_dir(name)?;
|
||||||
|
if dir.exists() {
|
||||||
|
fs::remove_dir_all(&dir)
|
||||||
|
.with_context(|| format!("delete device dir for '{name}'"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure git in `vault_root` to:
|
||||||
|
/// - sign commits with the device's signing key (SSH format)
|
||||||
|
/// - push via SSH using the device's deploy key
|
||||||
|
pub fn configure_git_signing(vault_root: &std::path::Path, name: &str) -> Result<()> {
|
||||||
|
let dir = device_dir(name)?;
|
||||||
|
let signing_key = dir.join("signing.key");
|
||||||
|
let deploy_key = dir.join("deploy.key");
|
||||||
|
|
||||||
|
// gpg.format = ssh so git uses SSH-format signing
|
||||||
|
crate::helpers::git_command(vault_root, &["config", "gpg.format", "ssh"])
|
||||||
|
.status()
|
||||||
|
.context("git config gpg.format")?;
|
||||||
|
|
||||||
|
// user.signingkey = path to the private key file
|
||||||
|
crate::helpers::git_command(
|
||||||
|
vault_root,
|
||||||
|
&["config", "user.signingkey", &signing_key.to_string_lossy()],
|
||||||
|
)
|
||||||
|
.status()
|
||||||
|
.context("git config user.signingkey")?;
|
||||||
|
|
||||||
|
// commit.gpgsign = true
|
||||||
|
crate::helpers::git_command(vault_root, &["config", "commit.gpgsign", "true"])
|
||||||
|
.status()
|
||||||
|
.context("git config commit.gpgsign")?;
|
||||||
|
|
||||||
|
// core.sshCommand — use only the deploy key for push
|
||||||
|
let ssh_cmd = format!(
|
||||||
|
"ssh -i {} -o IdentitiesOnly=yes",
|
||||||
|
deploy_key.display()
|
||||||
|
);
|
||||||
|
crate::helpers::git_command(
|
||||||
|
vault_root,
|
||||||
|
&["config", "core.sshCommand", &ssh_cmd],
|
||||||
|
)
|
||||||
|
.status()
|
||||||
|
.context("git config core.sshCommand")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
117
crates/relicario-cli/src/gitea.rs
Normal file
117
crates/relicario-cli/src/gitea.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! Gitea API client for deploy key management.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GiteaClient {
|
||||||
|
api_url: String,
|
||||||
|
token: String,
|
||||||
|
owner: String,
|
||||||
|
repo: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CreateKeyRequest<'a> {
|
||||||
|
title: &'a str,
|
||||||
|
key: &'a str,
|
||||||
|
read_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeployKey {
|
||||||
|
pub id: u64,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub title: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaClient {
|
||||||
|
pub fn new(api_url: &str, token: &str, owner: &str, repo: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
api_url: api_url.trim_end_matches('/').to_string(),
|
||||||
|
token: token.to_string(),
|
||||||
|
owner: owner.to_string(),
|
||||||
|
repo: repo.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a deploy key, returning its ID.
|
||||||
|
pub fn create_deploy_key(&self, title: &str, public_key: &str) -> Result<u64> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/keys",
|
||||||
|
self.api_url, self.owner, self.repo
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&CreateKeyRequest {
|
||||||
|
title,
|
||||||
|
key: public_key,
|
||||||
|
read_only: false,
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.context("Gitea API request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let key: DeployKey = resp.json().context("parse deploy key response")?;
|
||||||
|
Ok(key.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a deploy key by ID.
|
||||||
|
pub fn delete_deploy_key(&self, key_id: u64) -> Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/keys/{}",
|
||||||
|
self.api_url, self.owner, self.repo, key_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.delete(&url)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.send()
|
||||||
|
.context("Gitea API request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() && resp.status().as_u16() != 404 {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all deploy keys.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/repos/{}/{}/keys",
|
||||||
|
self.api_url, self.owner, self.repo
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("token {}", self.token))
|
||||||
|
.send()
|
||||||
|
.context("Gitea API request failed")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().unwrap_or_default();
|
||||||
|
anyhow::bail!("Gitea API error {}: {}", status, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keys: Vec<DeployKey> = resp.json().context("parse deploy keys response")?;
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
316
crates/relicario-cli/src/helpers.rs
Normal file
316
crates/relicario-cli/src/helpers.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
|
||||||
|
//! timestamp formatting. Kept in their own module so every command handler
|
||||||
|
//! stays terse.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use chrono::DateTime;
|
||||||
|
|
||||||
|
/// Walk up from `start` looking for a directory containing `.relicario/`.
|
||||||
|
/// Returns the vault root (the directory that contains `.relicario/`).
|
||||||
|
/// Audit L8: refuses to operate outside an initialized vault.
|
||||||
|
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
|
||||||
|
let mut cur = start.to_path_buf();
|
||||||
|
loop {
|
||||||
|
if cur.join(".relicario").is_dir() {
|
||||||
|
return Ok(cur);
|
||||||
|
}
|
||||||
|
if !cur.pop() {
|
||||||
|
bail!(
|
||||||
|
"no .relicario/ directory found in {} or any parent — \
|
||||||
|
run `relicario init` first",
|
||||||
|
start.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience wrapper that starts the search from `std::env::current_dir()`.
|
||||||
|
pub fn vault_dir() -> Result<PathBuf> {
|
||||||
|
let cwd = std::env::current_dir().context("failed to get current directory")?;
|
||||||
|
find_vault_dir_from(&cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the `.relicario/` configuration directory within the vault.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn relicario_dir() -> Result<PathBuf> {
|
||||||
|
Ok(vault_dir()?.join(".relicario"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
|
||||||
|
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
|
||||||
|
/// GPG passphrase prompts (which would hold the master key alive), or entering
|
||||||
|
/// $EDITOR during rebase conflict markers.
|
||||||
|
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.current_dir(repo);
|
||||||
|
cmd.args([
|
||||||
|
"-c", "core.hooksPath=/dev/null",
|
||||||
|
"-c", "commit.gpgsign=false",
|
||||||
|
"-c", "core.editor=true",
|
||||||
|
]);
|
||||||
|
cmd.args(args);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
|
||||||
|
/// capturing stdout/stderr and reproducing them on failure so the caller
|
||||||
|
/// sees git's exact diagnostic instead of just a verb.
|
||||||
|
///
|
||||||
|
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
|
||||||
|
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
|
||||||
|
/// identifiable from the error alone.
|
||||||
|
///
|
||||||
|
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
|
||||||
|
/// (so live progress disappears during long-running fetches/pushes) but the
|
||||||
|
/// captured chunk is replayed verbatim on failure. The win is that
|
||||||
|
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
|
||||||
|
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
|
||||||
|
/// instead of one-line "git X failed" bails. Use `git_command` directly when
|
||||||
|
/// live streaming is required.
|
||||||
|
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||||
|
let output = git_command(repo, args)
|
||||||
|
.output()
|
||||||
|
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||||||
|
}
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
bail!("{context}: git failed ({})", output.status);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||||||
|
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||||||
|
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||||||
|
/// disk, and we want the rm to succeed regardless.
|
||||||
|
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||||||
|
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||||||
|
args.extend(paths.iter().map(String::as_str));
|
||||||
|
git_run(repo, &args, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||||
|
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||||
|
/// a numeric string.
|
||||||
|
pub fn iso8601(unix_seconds: i64) -> String {
|
||||||
|
DateTime::from_timestamp(unix_seconds, 0)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||||
|
.unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a duration (in seconds) as a coarse human-readable string:
|
||||||
|
/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago".
|
||||||
|
pub fn humanize_age(seconds: i64) -> String {
|
||||||
|
if seconds < 60 { return "just now".to_string(); }
|
||||||
|
if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); }
|
||||||
|
if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); }
|
||||||
|
if seconds < 86_400 * 30 {
|
||||||
|
let d = seconds / 86_400;
|
||||||
|
return format!("{d} day{} ago", plural(d));
|
||||||
|
}
|
||||||
|
if seconds < 86_400 * 365 {
|
||||||
|
let m = seconds / (86_400 * 30);
|
||||||
|
return format!("{m} month{} ago", plural(m));
|
||||||
|
}
|
||||||
|
let y = seconds / (86_400 * 365);
|
||||||
|
format!("{y} year{} ago", plural(y))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
||||||
|
|
||||||
|
/// Path to the plaintext `groups.cache` file used by shell completion to
|
||||||
|
/// enumerate `--group <TAB>` candidates without unlocking the vault.
|
||||||
|
///
|
||||||
|
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||||
|
/// vault directory. This is intentional — the file feeds shell completion,
|
||||||
|
/// which cannot prompt for a passphrase. In debug builds, set
|
||||||
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
|
||||||
|
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||||
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all non-empty group names from the manifest and write them to the
|
||||||
|
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||||
|
/// candidates without prompting for the vault passphrase.
|
||||||
|
///
|
||||||
|
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||||
|
/// not a correctness problem.
|
||||||
|
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||||
|
let mut set = std::collections::BTreeSet::<String>::new();
|
||||||
|
for entry in manifest.items.values() {
|
||||||
|
if let Some(g) = entry.group.as_ref() {
|
||||||
|
if !g.is_empty() {
|
||||||
|
set.insert(g.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = write_groups_cache(vault_dir, &set);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
|
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||||
|
/// suppresses the write (developer debugging tool). In release builds the env
|
||||||
|
/// var is ignored.
|
||||||
|
pub fn write_groups_cache(
|
||||||
|
vault_dir: &Path,
|
||||||
|
groups: &std::collections::BTreeSet<String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = groups_cache_path(vault_dir);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut body = String::new();
|
||||||
|
for g in groups {
|
||||||
|
body.push_str(g);
|
||||||
|
body.push('\n');
|
||||||
|
}
|
||||||
|
std::fs::write(path, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a string for use in a git commit message subject line.
|
||||||
|
///
|
||||||
|
/// Removes all Unicode control characters (U+0000–U+001F, U+007F, and higher
|
||||||
|
/// control planes) so that newlines and escape sequences cannot corrupt `git
|
||||||
|
/// log` output. Truncates to 50 characters so the subject line stays within
|
||||||
|
/// the conventional limit.
|
||||||
|
///
|
||||||
|
/// Audit I1: item titles are user-supplied and may contain arbitrary bytes.
|
||||||
|
pub fn sanitize_for_commit(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(50)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||||||
|
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||||||
|
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||||||
|
let img = image::open(path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
|
||||||
|
.to_luma8();
|
||||||
|
let mut prepared = rqrr::PreparedImage::prepare(img);
|
||||||
|
let grids = prepared.detect_grids();
|
||||||
|
let grid = grids
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
|
||||||
|
let (_meta, content) = grid
|
||||||
|
.decode()
|
||||||
|
.map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
|
||||||
|
if !content.starts_with("otpauth://") {
|
||||||
|
return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
|
||||||
|
}
|
||||||
|
let parsed =
|
||||||
|
url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
|
||||||
|
let secret = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "secret")
|
||||||
|
.map(|(_, v)| v.to_string())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
|
||||||
|
Ok(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vault_dir_finds_marker_in_cwd() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||||||
|
let found = find_vault_dir_from(tmp.path()).unwrap();
|
||||||
|
assert_eq!(found, tmp.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vault_dir_finds_marker_in_parent() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
|
||||||
|
let subdir = tmp.path().join("sub/nested");
|
||||||
|
std::fs::create_dir_all(&subdir).unwrap();
|
||||||
|
let found = find_vault_dir_from(&subdir).unwrap();
|
||||||
|
assert_eq!(found, tmp.path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vault_dir_errors_when_missing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let err = find_vault_dir_from(tmp.path()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains(".relicario"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iso8601_formats_fixed_timestamp() {
|
||||||
|
// 2026-04-19T00:00:00Z = 1776556800
|
||||||
|
assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_for_commit_strips_control_chars() {
|
||||||
|
assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2");
|
||||||
|
assert_eq!(sanitize_for_commit("a\tb"), "ab");
|
||||||
|
assert_eq!(sanitize_for_commit("normal"), "normal");
|
||||||
|
assert_eq!(sanitize_for_commit("cr\r\nline"), "crline");
|
||||||
|
// ESC (U+001B) is control and gets stripped; bracket sequences are printable
|
||||||
|
assert_eq!(sanitize_for_commit("\x1b[31mred\x1b[0m"), "[31mred[0m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_for_commit_truncates_to_50() {
|
||||||
|
let long = "a".repeat(60);
|
||||||
|
assert_eq!(sanitize_for_commit(&long).len(), 50);
|
||||||
|
assert_eq!(sanitize_for_commit(&long), "a".repeat(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_for_commit_allows_unicode() {
|
||||||
|
assert_eq!(sanitize_for_commit("cafe\u{0301}"), "cafe\u{0301}");
|
||||||
|
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_run_bails_with_context_on_failure() {
|
||||||
|
// Empty tempdir — `git status` will fail with "not a git repository".
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
|
||||||
|
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn git_run_succeeds_for_a_zero_exit_command() {
|
||||||
|
// `git --version` always succeeds and is independent of cwd.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
git_run(tmp.path(), &["--version"], "version probe")
|
||||||
|
.expect("git --version should succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn humanize_age_buckets() {
|
||||||
|
assert_eq!(humanize_age(0), "just now");
|
||||||
|
assert_eq!(humanize_age(59), "just now");
|
||||||
|
assert_eq!(humanize_age(60), "1 minute ago");
|
||||||
|
assert_eq!(humanize_age(120), "2 minutes ago");
|
||||||
|
assert_eq!(humanize_age(3_599), "59 minutes ago");
|
||||||
|
assert_eq!(humanize_age(3_600), "1 hour ago");
|
||||||
|
assert_eq!(humanize_age(7_200), "2 hours ago");
|
||||||
|
assert_eq!(humanize_age(86_400), "1 day ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 2), "2 days ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 30), "1 month ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 60), "2 months ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 365), "1 year ago");
|
||||||
|
assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago");
|
||||||
|
}
|
||||||
|
}
|
||||||
491
crates/relicario-cli/src/main.rs
Normal file
491
crates/relicario-cli/src/main.rs
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
//! Relicario CLI — the platform layer for the Relicario password manager.
|
||||||
|
//!
|
||||||
|
//! See module docs for the unlock flow and vault layout.
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod device;
|
||||||
|
mod gitea;
|
||||||
|
mod helpers;
|
||||||
|
mod parse;
|
||||||
|
mod prompt;
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
|
use clap_complete::{generate, Shell};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "relicario",
|
||||||
|
version,
|
||||||
|
about = "Relicario — git-backed password manager with reference-image two-factor unlock"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Initialize a new vault in the current directory.
|
||||||
|
Init {
|
||||||
|
/// Carrier JPEG to embed the secret into.
|
||||||
|
#[arg(long)]
|
||||||
|
image: PathBuf,
|
||||||
|
/// Output path for the reference image (gitignored).
|
||||||
|
#[arg(long, default_value = "reference.jpg")]
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Add a new item. Type-specific flags populate the core; missing fields
|
||||||
|
/// are prompted for interactively.
|
||||||
|
Add {
|
||||||
|
#[command(subcommand)]
|
||||||
|
kind: AddKind,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Print an item. Secrets are masked by default; pass --show to reveal.
|
||||||
|
Get {
|
||||||
|
/// Item id or case-insensitive title substring.
|
||||||
|
query: String,
|
||||||
|
/// Print secret field values in plaintext.
|
||||||
|
#[arg(long)]
|
||||||
|
show: bool,
|
||||||
|
/// Copy the primary secret (Login.password, Card.number, etc.) to clipboard.
|
||||||
|
#[arg(long)]
|
||||||
|
copy: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// List items.
|
||||||
|
List {
|
||||||
|
#[arg(long)]
|
||||||
|
r#type: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
group: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
tag: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
trashed: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Edit an item interactively.
|
||||||
|
Edit {
|
||||||
|
query: String,
|
||||||
|
/// Decode an `otpauth://` QR image to set the TOTP secret (login items only).
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
totp_qr: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// View captured field history for an item. Values are masked by
|
||||||
|
/// default; pass `--show` to reveal them.
|
||||||
|
History {
|
||||||
|
query: String,
|
||||||
|
#[arg(long)]
|
||||||
|
show: bool,
|
||||||
|
/// Filter to a single field (matches against the synthetic key,
|
||||||
|
/// e.g. `login_password`, `card_number`, `totp_secret`).
|
||||||
|
#[arg(long)]
|
||||||
|
field: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Soft-delete an item (moves to trash; reversible via `restore`).
|
||||||
|
Rm { query: String },
|
||||||
|
|
||||||
|
/// Restore a soft-deleted item.
|
||||||
|
Restore { query: String },
|
||||||
|
|
||||||
|
/// Permanently purge an item (and its attachments).
|
||||||
|
Purge { query: String },
|
||||||
|
|
||||||
|
/// Trash operations.
|
||||||
|
Trash {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: TrashAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Backup operations: pack and unpack `.relbak` archives.
|
||||||
|
Backup {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: BackupAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Import items from another password manager into the unlocked vault.
|
||||||
|
Import {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: ImportAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Attach a file to an item.
|
||||||
|
Attach { query: String, file: PathBuf },
|
||||||
|
|
||||||
|
/// List attachments on an item.
|
||||||
|
Attachments { query: String },
|
||||||
|
|
||||||
|
/// Extract an attachment to disk.
|
||||||
|
Extract {
|
||||||
|
query: String,
|
||||||
|
aid: String,
|
||||||
|
#[arg(long)]
|
||||||
|
out: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove an individual attachment from an item (deletes the encrypted
|
||||||
|
/// blob and updates the item + manifest). Use `purge` to drop the entire
|
||||||
|
/// item and all its attachments at once.
|
||||||
|
Detach { query: String, aid: String },
|
||||||
|
|
||||||
|
/// Generate a password or passphrase. When run inside an initialized
|
||||||
|
/// vault, falls back to `settings generator-defaults` for unspecified
|
||||||
|
/// flags; outside a vault, uses built-in defaults (length 20, safe
|
||||||
|
/// symbol set, 5 BIP39 words, space separator).
|
||||||
|
Generate {
|
||||||
|
#[arg(long)]
|
||||||
|
length: Option<u32>,
|
||||||
|
#[arg(long)]
|
||||||
|
bip39: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
words: Option<u32>,
|
||||||
|
#[arg(long)]
|
||||||
|
symbols: Option<String>,
|
||||||
|
/// Separator for BIP39 words.
|
||||||
|
#[arg(long)]
|
||||||
|
separator: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// View or change vault settings.
|
||||||
|
Settings {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: SettingsAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Sync with the git remote (pull --rebase + push).
|
||||||
|
Sync,
|
||||||
|
|
||||||
|
/// Print a summary of the vault: items, attachments, last commit.
|
||||||
|
Status,
|
||||||
|
|
||||||
|
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
||||||
|
Lock,
|
||||||
|
|
||||||
|
/// Emit a shell completion script for the given shell.
|
||||||
|
///
|
||||||
|
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||||
|
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||||
|
/// which the CLI refreshes on every manifest read. In debug builds, set
|
||||||
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||||
|
/// will fall back to no value enumeration).
|
||||||
|
///
|
||||||
|
/// Pipe stdout to your shell's completion location (e.g.
|
||||||
|
/// `relicario completions bash > /etc/bash_completion.d/relicario`).
|
||||||
|
Completions {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: Shell,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Rate a passphrase with zxcvbn — prints score (0-4) and estimated
|
||||||
|
/// guesses. Informational only; does not gate vault operations.
|
||||||
|
///
|
||||||
|
/// Pass `-` as the argument to read one line from stdin instead, which
|
||||||
|
/// keeps the passphrase out of shell history.
|
||||||
|
Rate {
|
||||||
|
/// Passphrase to score, or `-` to read from stdin.
|
||||||
|
passphrase: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Manage registered devices (signing keys + deploy keys).
|
||||||
|
Device {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: DeviceAction,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Recovery QR operations — generate or unwrap the 2FA recovery code.
|
||||||
|
RecoveryQr {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: RecoveryQrCmd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub(crate) enum AddKind {
|
||||||
|
Login {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] username: Option<String>,
|
||||||
|
#[arg(long)] url: Option<String>,
|
||||||
|
/// Prompt for password (vs reading from stdin or --password).
|
||||||
|
#[arg(long)] password_prompt: bool,
|
||||||
|
#[arg(long)] password: Option<String>,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
#[arg(long)] favorite: bool,
|
||||||
|
/// Decode an `otpauth://` QR image to fill the TOTP secret.
|
||||||
|
#[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
SecureNote {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] body_prompt: bool,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
Identity {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] full_name: Option<String>,
|
||||||
|
#[arg(long)] email: Option<String>,
|
||||||
|
#[arg(long)] phone: Option<String>,
|
||||||
|
#[arg(long)] date_of_birth: Option<String>,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
Card {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] holder: Option<String>,
|
||||||
|
#[arg(long)] expiry: Option<String>, // MM/YYYY
|
||||||
|
#[arg(long, default_value = "credit")] kind: String,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
Key {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] label: Option<String>,
|
||||||
|
#[arg(long)] algorithm: Option<String>,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
Document {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] file: PathBuf,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
Totp {
|
||||||
|
#[arg(long)] title: Option<String>,
|
||||||
|
#[arg(long)] issuer: Option<String>,
|
||||||
|
#[arg(long)] label: Option<String>,
|
||||||
|
#[arg(long)] secret: Option<String>, // base32
|
||||||
|
#[arg(long, default_value = "30")] period: u32,
|
||||||
|
#[arg(long, default_value = "6")] digits: u8,
|
||||||
|
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||||
|
#[arg(long)] group: Option<String>,
|
||||||
|
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub(crate) enum TrashAction {
|
||||||
|
/// List trashed items.
|
||||||
|
List,
|
||||||
|
/// Purge every trashed item past its retention window.
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub(crate) enum SettingsAction {
|
||||||
|
/// Show current settings as JSON.
|
||||||
|
Show,
|
||||||
|
/// Set trash retention (e.g., --days 30 or --forever).
|
||||||
|
TrashRetention {
|
||||||
|
#[arg(long)] days: Option<u32>,
|
||||||
|
#[arg(long)] forever: bool,
|
||||||
|
},
|
||||||
|
/// Set field history retention.
|
||||||
|
HistoryRetention {
|
||||||
|
#[arg(long)] last_n: Option<u32>,
|
||||||
|
#[arg(long)] days: Option<u32>,
|
||||||
|
#[arg(long)] forever: bool,
|
||||||
|
},
|
||||||
|
/// Set per-attachment max size in bytes.
|
||||||
|
AttachmentCap {
|
||||||
|
#[arg(long)] per_attachment_max_bytes: Option<u64>,
|
||||||
|
#[arg(long)] per_item_max_count: Option<u32>,
|
||||||
|
#[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
|
||||||
|
#[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
|
||||||
|
},
|
||||||
|
/// Update the default password / passphrase generator settings used by
|
||||||
|
/// `relicario generate` when run inside this vault. Pass `--bip39` or
|
||||||
|
/// `--random` to switch mode; per-attribute flags update fields of the
|
||||||
|
/// chosen mode.
|
||||||
|
GeneratorDefaults {
|
||||||
|
/// Switch the default mode to random-character password.
|
||||||
|
#[arg(long, conflicts_with = "bip39")]
|
||||||
|
random: bool,
|
||||||
|
/// Switch the default mode to BIP39 passphrase.
|
||||||
|
#[arg(long, conflicts_with = "random")]
|
||||||
|
bip39: bool,
|
||||||
|
/// Random mode: total password length.
|
||||||
|
#[arg(long)] length: Option<u32>,
|
||||||
|
/// BIP39 mode: number of words.
|
||||||
|
#[arg(long)] words: Option<u32>,
|
||||||
|
/// Random mode: symbol charset (`safe`, `extended`, or a custom literal).
|
||||||
|
#[arg(long)] symbols: Option<String>,
|
||||||
|
/// BIP39 mode: word separator.
|
||||||
|
#[arg(long)] separator: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub(crate) enum BackupAction {
|
||||||
|
/// Pack the local vault into a single encrypted `.relbak` file.
|
||||||
|
/// Backup passphrase is independent of the vault passphrase.
|
||||||
|
Export {
|
||||||
|
/// Output `.relbak` path.
|
||||||
|
out: PathBuf,
|
||||||
|
/// Bundle the reference JPEG into the encrypted envelope.
|
||||||
|
#[arg(long)]
|
||||||
|
include_image: bool,
|
||||||
|
/// Override the reference image path (defaults to the vault's
|
||||||
|
/// `reference.jpg` or `RELICARIO_IMAGE`).
|
||||||
|
#[arg(long)]
|
||||||
|
image: Option<PathBuf>,
|
||||||
|
/// Skip bundling `.git/` history.
|
||||||
|
#[arg(long)]
|
||||||
|
no_history: bool,
|
||||||
|
},
|
||||||
|
/// Unpack a `.relbak` file into a fresh vault directory.
|
||||||
|
Restore {
|
||||||
|
/// Input `.relbak` path.
|
||||||
|
input: PathBuf,
|
||||||
|
/// Target directory (must NOT already contain `.relicario/`).
|
||||||
|
/// Defaults to the current directory.
|
||||||
|
#[arg(default_value = ".")]
|
||||||
|
target: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub(crate) enum ImportAction {
|
||||||
|
/// Import a LastPass CSV export into the unlocked vault.
|
||||||
|
/// Each row creates a new item with a freshly-minted ID; title
|
||||||
|
/// collisions are kept (no dedup). Failed rows are skipped and
|
||||||
|
/// reported on stderr.
|
||||||
|
Lastpass {
|
||||||
|
/// Path to the LastPass-format CSV export.
|
||||||
|
csv: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub(crate) enum DeviceAction {
|
||||||
|
/// Register this machine as a new device.
|
||||||
|
///
|
||||||
|
/// Generates two ed25519 keypairs: one for signing commits, one for push
|
||||||
|
/// access (deploy key). The deploy public key is registered via the Gitea
|
||||||
|
/// API. Both private keys are stored locally in
|
||||||
|
/// `~/.config/relicario/devices/<name>/`. The vault's `.relicario/devices.json`
|
||||||
|
/// is updated and committed.
|
||||||
|
///
|
||||||
|
/// Required environment variables (or flags):
|
||||||
|
/// RELICARIO_GITEA_URL — e.g. https://git.example.com
|
||||||
|
/// RELICARIO_GITEA_TOKEN — personal access token with repo write access
|
||||||
|
/// RELICARIO_GITEA_OWNER — repository owner
|
||||||
|
/// RELICARIO_GITEA_REPO — repository name
|
||||||
|
Add {
|
||||||
|
/// Human-readable name for this device (e.g. "laptop-2026").
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
/// Gitea API base URL (overrides RELICARIO_GITEA_URL).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
/// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
/// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
|
||||||
|
#[arg(long)]
|
||||||
|
owner: Option<String>,
|
||||||
|
/// Gitea repository name (overrides RELICARIO_GITEA_REPO).
|
||||||
|
#[arg(long)]
|
||||||
|
repo: Option<String>,
|
||||||
|
/// Skip Gitea API registration (useful when the remote is not Gitea).
|
||||||
|
#[arg(long)]
|
||||||
|
no_gitea: bool,
|
||||||
|
},
|
||||||
|
/// Revoke a registered device.
|
||||||
|
///
|
||||||
|
/// Removes the device from `devices.json`, adds it to `revoked.json`,
|
||||||
|
/// deletes the deploy key from Gitea, and commits the change.
|
||||||
|
Revoke {
|
||||||
|
/// Name of the device to revoke.
|
||||||
|
#[arg(long)]
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// List registered devices.
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand)]
|
||||||
|
pub(crate) enum RecoveryQrCmd {
|
||||||
|
/// Generate a recovery QR code and display it as ASCII art in the terminal.
|
||||||
|
Generate,
|
||||||
|
/// Unwrap a recovery QR payload (base64) to recover the image_secret as hex.
|
||||||
|
Unwrap,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Commands::Init { image, output } => commands::init::cmd_init(image, output),
|
||||||
|
Commands::Add { kind } => commands::add::cmd_add(kind),
|
||||||
|
Commands::Get { query, show, copy } => commands::get::cmd_get(query, show, copy),
|
||||||
|
Commands::List { r#type, group, tag, trashed } => commands::list::cmd_list(r#type, group, tag, trashed),
|
||||||
|
Commands::Edit { query, totp_qr } => commands::edit::cmd_edit(query, totp_qr),
|
||||||
|
Commands::History { query, show, field } => commands::list::cmd_history(query, show, field),
|
||||||
|
Commands::Rm { query } => commands::trash::cmd_rm(query),
|
||||||
|
Commands::Restore { query } => commands::trash::cmd_restore(query),
|
||||||
|
Commands::Purge { query } => commands::trash::cmd_purge(query),
|
||||||
|
Commands::Trash { action } => commands::trash::cmd_trash(action),
|
||||||
|
Commands::Backup { action } => commands::backup::cmd_backup(action),
|
||||||
|
Commands::Import { action } => commands::import::cmd_import(action),
|
||||||
|
Commands::Attach { query, file } => commands::attach::cmd_attach(query, file),
|
||||||
|
Commands::Attachments { query } => commands::attach::cmd_attachments(query),
|
||||||
|
Commands::Extract { query, aid, out } => commands::attach::cmd_extract(query, aid, out),
|
||||||
|
Commands::Detach { query, aid } => commands::attach::cmd_detach(query, aid),
|
||||||
|
Commands::Generate { length, bip39, words, symbols, separator } => {
|
||||||
|
commands::generate::cmd_generate(length, bip39, words, symbols, separator)
|
||||||
|
}
|
||||||
|
Commands::Settings { action } => commands::settings::cmd_settings(action),
|
||||||
|
Commands::Sync => commands::sync::cmd_sync(),
|
||||||
|
Commands::Status => commands::status::cmd_status(),
|
||||||
|
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||||
|
Commands::Completions { shell } => {
|
||||||
|
let mut cmd = Cli::command();
|
||||||
|
generate(shell, &mut cmd, "relicario", &mut std::io::stdout());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Commands::Rate { passphrase } => commands::rate::cmd_rate(passphrase),
|
||||||
|
Commands::Device { action } => commands::device::cmd_device(action),
|
||||||
|
Commands::RecoveryQr { cmd } => commands::recovery_qr::cmd_recovery_qr(cmd),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test item secret override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub(crate) fn test_item_secret_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub(crate) fn test_item_secret_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check for test backup passphrase override (debug builds only; stripped from release).
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub(crate) fn test_backup_passphrase_override() -> Option<String> {
|
||||||
|
std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
|
||||||
|
}
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
pub(crate) fn test_backup_passphrase_override() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
47
crates/relicario-cli/src/parse.rs
Normal file
47
crates/relicario-cli/src/parse.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
|
||||||
|
//!
|
||||||
|
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
|
||||||
|
//! turns this file into a thin re-export shim. They live here for now so
|
||||||
|
//! the Phase 1 relocation stays mechanical.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||||
|
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||||
|
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||||
|
let month: u8 = m_str.parse().context("invalid month")?;
|
||||||
|
let year: u16 = if y_str.len() == 2 {
|
||||||
|
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
|
||||||
|
} else {
|
||||||
|
y_str.parse().context("invalid year")?
|
||||||
|
};
|
||||||
|
Ok(relicario_core::MonthYear { month, year })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn guess_mime(filename: &str) -> String {
|
||||||
|
let lower = filename.to_ascii_lowercase();
|
||||||
|
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"txt" => "text/plain",
|
||||||
|
"json" => "application/json",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
||||||
|
let cleaned: String = s.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
.trim_end_matches('=')
|
||||||
|
.to_string();
|
||||||
|
let padded = {
|
||||||
|
let rem = cleaned.len() % 8;
|
||||||
|
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
|
||||||
|
};
|
||||||
|
data_encoding::BASE32.decode(padded.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
|
||||||
|
}
|
||||||
65
crates/relicario-cli/src/prompt.rs
Normal file
65
crates/relicario-cli/src/prompt.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
//! Interactive prompt helpers for the CLI.
|
||||||
|
//!
|
||||||
|
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
|
||||||
|
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||||
|
//! used by the edit handlers to keep current values when the user hits enter
|
||||||
|
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
|
//! so integration tests (which don't have a TTY) can inject secrets.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
|
/// unavailable in assert_cmd-spawned children).
|
||||||
|
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
||||||
|
if let Some(s) = crate::test_item_secret_override() {
|
||||||
|
return Ok(s);
|
||||||
|
}
|
||||||
|
rpassword::prompt_password(label).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||||
|
eprint!("{label}: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||||
|
Ok(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||||
|
eprint!("{label} (leave blank to skip): ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
|
eprint!("{label} [{current}]: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
|
||||||
|
let display = current.unwrap_or("(none)");
|
||||||
|
eprint!("{label} [{display}]: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
let trimmed = s.trim().to_string();
|
||||||
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
||||||
|
eprint!("{label} [y/N] ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::stdin().read_line(&mut s)?;
|
||||||
|
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
||||||
|
}
|
||||||
267
crates/relicario-cli/src/session.rs
Normal file
267
crates/relicario-cli/src/session.rs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
//! Unlocked-vault session: the shape every vault-mutating command works with.
|
||||||
|
//!
|
||||||
|
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
|
||||||
|
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||||
|
derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
|
||||||
|
imgsecret, Item, ItemId, KdfParams, Manifest, VaultSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::helpers::vault_dir;
|
||||||
|
|
||||||
|
/// A vault whose master key has been derived and is held in memory.
|
||||||
|
/// The key is wiped via `Zeroize` when this struct drops.
|
||||||
|
pub struct UnlockedVault {
|
||||||
|
root: PathBuf,
|
||||||
|
master_key: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UnlockedVault {
|
||||||
|
pub fn root(&self) -> &Path { &self.root }
|
||||||
|
pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.master_key }
|
||||||
|
|
||||||
|
/// Full interactive unlock flow: locate vault, prompt passphrase, locate
|
||||||
|
/// reference image, derive master key.
|
||||||
|
pub fn unlock_interactive() -> Result<Self> {
|
||||||
|
let root = vault_dir()?;
|
||||||
|
let salt = read_salt(&root)?;
|
||||||
|
let params = read_params(&root)?;
|
||||||
|
let image_path = get_image_path()?;
|
||||||
|
let image_bytes = fs::read(&image_path)
|
||||||
|
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||||
|
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
||||||
|
|
||||||
|
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||||
|
Zeroizing::new(p)
|
||||||
|
} else {
|
||||||
|
Zeroizing::new(
|
||||||
|
rpassword::prompt_password("Passphrase: ")
|
||||||
|
.context("failed to read passphrase")?
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let master_key = derive_master_key(
|
||||||
|
passphrase.as_bytes(),
|
||||||
|
&image_secret,
|
||||||
|
&salt,
|
||||||
|
¶ms,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(Self { root, master_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
|
||||||
|
pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
|
||||||
|
pub fn item_path(&self, id: &ItemId) -> PathBuf {
|
||||||
|
self.root.join("items").join(format!("{}.enc", id.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_manifest(&self) -> Result<Manifest> {
|
||||||
|
let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
|
||||||
|
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the manifest and refresh the plaintext groups.cache. This is the
|
||||||
|
/// canonical "I just mutated the manifest" funnel — every command that
|
||||||
|
/// changes the manifest goes through this method, so cache freshness is
|
||||||
|
/// a compile-time invariant rather than a discipline rule.
|
||||||
|
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
|
||||||
|
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||||
|
atomic_write(&self.manifest_path(), &bytes)?;
|
||||||
|
crate::helpers::refresh_groups_cache(&self.root, manifest);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||||
|
let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
|
||||||
|
Ok(decrypt_settings(&bytes, &self.master_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
|
||||||
|
let bytes = encrypt_settings(settings, &self.master_key)?;
|
||||||
|
atomic_write(&self.settings_path(), &bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_item(&self, id: &ItemId) -> Result<Item> {
|
||||||
|
let bytes = fs::read(self.item_path(id))
|
||||||
|
.with_context(|| format!("failed to read item {}", id.as_str()))?;
|
||||||
|
Ok(decrypt_item(&bytes, &self.master_key)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_item(&self, item: &Item) -> Result<()> {
|
||||||
|
let path = self.item_path(&item.id);
|
||||||
|
if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
|
||||||
|
let bytes = encrypt_item(item, &self.master_key)?;
|
||||||
|
atomic_write(&path, &bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||||
|
let data = fs::read(root.join(".relicario").join("salt"))
|
||||||
|
.context("failed to read .relicario/salt")?;
|
||||||
|
if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
|
||||||
|
let mut salt = [0u8; 32];
|
||||||
|
salt.copy_from_slice(&data);
|
||||||
|
Ok(salt)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
pub(crate) struct ParamsFile {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub kdf: ParamsKdf,
|
||||||
|
pub aead: String,
|
||||||
|
pub salt_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) struct ParamsKdf {
|
||||||
|
pub algorithm: String,
|
||||||
|
pub argon2_m: u32,
|
||||||
|
pub argon2_t: u32,
|
||||||
|
pub argon2_p: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParamsFile {
|
||||||
|
pub fn for_new_vault(params: &KdfParams) -> Self {
|
||||||
|
Self {
|
||||||
|
format_version: 2,
|
||||||
|
kdf: ParamsKdf {
|
||||||
|
algorithm: "argon2id-v0x13".into(),
|
||||||
|
argon2_m: params.argon2_m,
|
||||||
|
argon2_t: params.argon2_t,
|
||||||
|
argon2_p: params.argon2_p,
|
||||||
|
},
|
||||||
|
aead: "xchacha20poly1305".into(),
|
||||||
|
salt_path: ".relicario/salt".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_kdf_params(&self) -> KdfParams {
|
||||||
|
KdfParams {
|
||||||
|
argon2_m: self.kdf.argon2_m,
|
||||||
|
argon2_t: self.kdf.argon2_t,
|
||||||
|
argon2_p: self.kdf.argon2_p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||||
|
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
|
.context("failed to read .relicario/params.json")?;
|
||||||
|
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||||
|
Ok(pf.to_kdf_params())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||||
|
pub fn get_image_path() -> Result<PathBuf> {
|
||||||
|
if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
|
||||||
|
return Ok(PathBuf::from(path));
|
||||||
|
}
|
||||||
|
// Also accept <vault_root>/reference.jpg as a convention.
|
||||||
|
if let Ok(root) = vault_dir() {
|
||||||
|
let default = root.join("reference.jpg");
|
||||||
|
if default.exists() { return Ok(default); }
|
||||||
|
}
|
||||||
|
eprint!("Reference image path: ");
|
||||||
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() { bail!("no reference image path provided"); }
|
||||||
|
Ok(PathBuf::from(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
|
||||||
|
/// vault file consistent if we crash mid-write.
|
||||||
|
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||||
|
let mut tmp = path.as_os_str().to_owned();
|
||||||
|
tmp.push(".tmp");
|
||||||
|
let tmp = PathBuf::from(tmp);
|
||||||
|
fs::write(&tmp, data).with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||||
|
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FIXTURE: &str = r#"{
|
||||||
|
"format_version": 2,
|
||||||
|
"kdf": {
|
||||||
|
"algorithm": "argon2id-v0x13",
|
||||||
|
"argon2_m": 65536,
|
||||||
|
"argon2_t": 3,
|
||||||
|
"argon2_p": 4
|
||||||
|
},
|
||||||
|
"aead": "xchacha20poly1305",
|
||||||
|
"salt_path": ".relicario/salt"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_file_round_trips_current_layout() {
|
||||||
|
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
|
||||||
|
assert_eq!(pf.format_version, 2);
|
||||||
|
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
|
||||||
|
assert_eq!(pf.kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(pf.kdf.argon2_t, 3);
|
||||||
|
assert_eq!(pf.kdf.argon2_p, 4);
|
||||||
|
assert_eq!(pf.aead, "xchacha20poly1305");
|
||||||
|
assert_eq!(pf.salt_path, ".relicario/salt");
|
||||||
|
|
||||||
|
let kdf = pf.to_kdf_params();
|
||||||
|
assert_eq!(kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(kdf.argon2_t, 3);
|
||||||
|
assert_eq!(kdf.argon2_p, 4);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&pf).expect("re-serialize");
|
||||||
|
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
|
||||||
|
assert_eq!(pf2.format_version, 2);
|
||||||
|
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
|
||||||
|
assert_eq!(pf2.kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(pf2.kdf.argon2_t, 3);
|
||||||
|
assert_eq!(pf2.kdf.argon2_p, 4);
|
||||||
|
assert_eq!(pf2.aead, "xchacha20poly1305");
|
||||||
|
assert_eq!(pf2.salt_path, ".relicario/salt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_new_vault_produces_expected_shape() {
|
||||||
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
let pf = ParamsFile::for_new_vault(¶ms);
|
||||||
|
let v = serde_json::to_value(&pf).expect("to_value");
|
||||||
|
assert_eq!(v["format_version"], 2);
|
||||||
|
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
|
||||||
|
assert_eq!(v["kdf"]["argon2_m"], 65536);
|
||||||
|
assert_eq!(v["kdf"]["argon2_t"], 3);
|
||||||
|
assert_eq!(v["kdf"]["argon2_p"], 4);
|
||||||
|
assert_eq!(v["aead"], "xchacha20poly1305");
|
||||||
|
assert_eq!(v["salt_path"], ".relicario/salt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn after_manifest_change_writes_manifest_and_groups_cache() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
std::fs::create_dir_all(root.join(".relicario")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("items")).unwrap();
|
||||||
|
let vault = UnlockedVault {
|
||||||
|
root: root.clone(),
|
||||||
|
master_key: Zeroizing::new([0u8; 32]),
|
||||||
|
};
|
||||||
|
let manifest = Manifest::new();
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest).unwrap();
|
||||||
|
assert!(root.join("manifest.enc").exists());
|
||||||
|
assert!(root.join(".relicario/groups.cache").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
106
crates/relicario-cli/tests/attachments.rs
Normal file
106
crates/relicario-cli/tests/attachments.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::TestVault;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_list_extract_round_trip() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
let payload_path = v.path().join("payload.txt");
|
||||||
|
std::fs::write(&payload_path, b"attached-bytes").unwrap();
|
||||||
|
|
||||||
|
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
|
||||||
|
assert!(attach.status.success(), "attach failed: {:?}", attach);
|
||||||
|
|
||||||
|
let list = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout = String::from_utf8(list.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("payload.txt"), "missing payload: {stdout}");
|
||||||
|
|
||||||
|
let aid = stdout.lines()
|
||||||
|
.find(|l| l.contains("payload.txt"))
|
||||||
|
.and_then(|l| l.split_whitespace().next())
|
||||||
|
.expect("aid token");
|
||||||
|
|
||||||
|
let out_path = v.path().join("extracted.txt");
|
||||||
|
let ex = v.run(&["extract", "thing", aid, "--out", out_path.to_str().unwrap()]);
|
||||||
|
assert!(ex.status.success(), "extract failed: {:?}", ex);
|
||||||
|
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_removes_attachment_and_blob() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
let payload_path = v.path().join("payload.txt");
|
||||||
|
std::fs::write(&payload_path, b"attached-bytes").unwrap();
|
||||||
|
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
|
||||||
|
assert!(attach.status.success());
|
||||||
|
|
||||||
|
let list = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout = String::from_utf8(list.stdout).unwrap();
|
||||||
|
let aid = stdout.lines()
|
||||||
|
.find(|l| l.contains("payload.txt"))
|
||||||
|
.and_then(|l| l.split_whitespace().next())
|
||||||
|
.expect("aid token")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Detach removes the attachment from the item AND deletes the blob.
|
||||||
|
let out = v.run(&["detach", "thing", &aid]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"detach failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Item no longer lists the attachment.
|
||||||
|
let list2 = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout2 = String::from_utf8(list2.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout2.contains("payload.txt"),
|
||||||
|
"attachment still listed after detach: {stdout2}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypted blob file is gone.
|
||||||
|
let blob_path = v.path()
|
||||||
|
.join("attachments")
|
||||||
|
.join("");
|
||||||
|
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||||
|
.unwrap().next().unwrap().unwrap().path();
|
||||||
|
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||||
|
assert!(!blob.exists(), "blob still on disk: {}", blob.display());
|
||||||
|
let _ = blob_path; // keep the variable to avoid an unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_refuses_unknown_aid() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
let out = v.run(&["detach", "thing", "deadbeef"]);
|
||||||
|
assert!(!out.status.success(), "expected failure: {:?}", out);
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&out.stderr).to_lowercase().contains("no attachment"),
|
||||||
|
"expected 'no attachment' error in stderr"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_rejects_over_cap() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
v.run(&["settings", "attachment-cap", "--per-attachment-max-bytes", "10"]);
|
||||||
|
|
||||||
|
let big = v.path().join("big.bin");
|
||||||
|
std::fs::write(&big, vec![0u8; 100]).unwrap();
|
||||||
|
let out = v.run(&["attach", "thing", big.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "expected failure; got {:?}", out);
|
||||||
|
assert!(String::from_utf8(out.stderr).unwrap().to_lowercase().contains("attachment"));
|
||||||
|
}
|
||||||
142
crates/relicario-cli/tests/backup.rs
Normal file
142
crates/relicario-cli/tests/backup.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
mod common;
|
||||||
|
use common::TestVault;
|
||||||
|
use std::process::Command;
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
|
||||||
|
const BACKUP_PASS: &str = "strong-backup-pass-test-2026";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_then_restore_round_trip() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
v.run(&["add", "login", "--title", "GitHub", "--username", "alice", "--password", "p"]);
|
||||||
|
v.run(&["add", "login", "--title", "Email", "--username", "bob", "--password", "q"]);
|
||||||
|
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
let out = v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap()],
|
||||||
|
BACKUP_PASS,
|
||||||
|
);
|
||||||
|
assert!(out.status.success(), "export failed: {:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
assert!(backup_path.exists());
|
||||||
|
assert!(v.path().join(".relicario/last_backup").exists());
|
||||||
|
|
||||||
|
// Restore into a fresh dir.
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "restore failed: {:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Vault should be unlockable in the restore dir using the same passphrase + image.
|
||||||
|
// Since the original vault didn't include the image, we copy it in manually
|
||||||
|
// (the standard restore-without-image flow expects the user to keep their
|
||||||
|
// reference image separately).
|
||||||
|
std::fs::copy(&v.reference_image, restore_dir.path().join("reference.jpg")).unwrap();
|
||||||
|
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_IMAGE", restore_dir.path().join("reference.jpg"))
|
||||||
|
.args(["list"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("GitHub"));
|
||||||
|
assert!(stdout.contains("Email"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_refuses_non_empty_target() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS);
|
||||||
|
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(v.path()) // already has a .relicario/
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let err = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(err.contains("already contains a Relicario vault"), "stderr: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_with_include_image_round_trips_the_image() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap(), "--include-image"],
|
||||||
|
BACKUP_PASS,
|
||||||
|
);
|
||||||
|
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
assert!(restore_dir.path().join("reference.jpg").exists(),
|
||||||
|
"image should be restored when --include-image was used");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_with_no_history_skips_git_dir() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap(), "--no-history"],
|
||||||
|
BACKUP_PASS,
|
||||||
|
);
|
||||||
|
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS)
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// .git/ should exist but contain only the "restore from backup ..." commit.
|
||||||
|
assert!(restore_dir.path().join(".git").is_dir());
|
||||||
|
let out = std::process::Command::new("git")
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.args(["log", "--oneline"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let log = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(log.lines().count(), 1, "expected one commit, got: {log}");
|
||||||
|
assert!(log.contains("restore from backup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_backup_passphrase_fails() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("vault.relbak");
|
||||||
|
v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS);
|
||||||
|
|
||||||
|
let restore_dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(restore_dir.path())
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", "definitely-wrong")
|
||||||
|
.args(["backup", "restore", backup_path.to_str().unwrap(), "."])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let err = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(err.contains("wrong backup passphrase"), "stderr: {err}");
|
||||||
|
}
|
||||||
203
crates/relicario-cli/tests/basic_flows.rs
Normal file
203
crates/relicario-cli/tests/basic_flows.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
use common::TestVault;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_creates_expected_layout() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
assert!(v.path().join(".relicario/salt").exists());
|
||||||
|
assert!(v.path().join(".relicario/params.json").exists());
|
||||||
|
// devices.json removed — device key system was security theater
|
||||||
|
assert!(!v.path().join(".relicario/devices.json").exists());
|
||||||
|
assert!(v.path().join("manifest.enc").exists());
|
||||||
|
assert!(v.path().join("settings.enc").exists());
|
||||||
|
assert!(v.path().join("reference.jpg").exists());
|
||||||
|
assert!(v.path().join(".gitignore").exists());
|
||||||
|
assert!(v.path().join(".git").is_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_params_json_is_format_v2() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let s = std::fs::read_to_string(v.path().join(".relicario/params.json")).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||||
|
assert_eq!(parsed["format_version"], 2);
|
||||||
|
assert_eq!(parsed["kdf"]["algorithm"], "argon2id-v0x13");
|
||||||
|
assert_eq!(parsed["aead"], "xchacha20poly1305");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_login_then_list_shows_it() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run(&[
|
||||||
|
"add",
|
||||||
|
"login",
|
||||||
|
"--title",
|
||||||
|
"GitHub",
|
||||||
|
"--username",
|
||||||
|
"alice",
|
||||||
|
"--url",
|
||||||
|
"https://github.com",
|
||||||
|
"--password",
|
||||||
|
"hunter2",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add failed: {:?}", out);
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_masks_by_default_shows_with_flag() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&[
|
||||||
|
"add",
|
||||||
|
"login",
|
||||||
|
"--title",
|
||||||
|
"gmail",
|
||||||
|
"--username",
|
||||||
|
"u",
|
||||||
|
"--password",
|
||||||
|
"super-secret",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let masked = v.run(&["get", "gmail"]);
|
||||||
|
let stdout = String::from_utf8(masked.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("********"), "expected masked: {stdout}");
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("super-secret"),
|
||||||
|
"leaked plaintext: {stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let shown = v.run(&["get", "gmail", "--show"]);
|
||||||
|
let stdout = String::from_utf8(shown.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("super-secret"), "expected plaintext: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rm_restore_purge_cycle() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&[
|
||||||
|
"add",
|
||||||
|
"login",
|
||||||
|
"--title",
|
||||||
|
"target",
|
||||||
|
"--username",
|
||||||
|
"u",
|
||||||
|
"--password",
|
||||||
|
"p",
|
||||||
|
]);
|
||||||
|
|
||||||
|
let rm = v.run(&["rm", "target"]);
|
||||||
|
assert!(rm.status.success());
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||||
|
|
||||||
|
let out = v.run(&["list", "--trashed"]);
|
||||||
|
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||||
|
|
||||||
|
let restore = v.run(&["restore", "target"]);
|
||||||
|
assert!(restore.status.success());
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
assert!(String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||||
|
|
||||||
|
let purge = v.run(&["purge", "target"]);
|
||||||
|
assert!(purge.status.success());
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_empty_batches_into_one_commit() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Add 3 items.
|
||||||
|
for title in ["alpha", "bravo", "charlie"] {
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", title,
|
||||||
|
"--username", "u",
|
||||||
|
"--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add {title} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete all 3.
|
||||||
|
for title in ["alpha", "bravo", "charlie"] {
|
||||||
|
let out = v.run(&["rm", title]);
|
||||||
|
assert!(out.status.success(), "rm {title} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set retention to 0 days so the recently-trashed items become purgeable
|
||||||
|
// (should_purge: now - trashed_at > 0 * 86400 = 0).
|
||||||
|
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
|
||||||
|
assert!(out.status.success(), "settings trash-retention failed");
|
||||||
|
|
||||||
|
// should_purge uses strict > on (now - trashed_at), so equal-second
|
||||||
|
// timestamps don't qualify.
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
|
// Count commits before.
|
||||||
|
let before = std::process::Command::new("git")
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.current_dir(v.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
// Run trash empty.
|
||||||
|
let out = v.run(&["trash", "empty"]);
|
||||||
|
assert!(out.status.success(), "trash empty failed: stderr={}",
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Count commits after.
|
||||||
|
let after = std::process::Command::new("git")
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.current_dir(v.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
after_count - before_count, 1,
|
||||||
|
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The remaining `list --trashed` should be empty.
|
||||||
|
let out = v.run(&["list", "--trashed"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
|
||||||
|
"items still in trashed list: stdout={stdout} stderr={stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_random_and_bip39() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let out = std::process::Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.args(["generate", "--length", "32"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success());
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().len(),
|
||||||
|
32
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = std::process::Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.args(["generate", "--bip39", "--words", "5"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success());
|
||||||
|
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||||
|
}
|
||||||
132
crates/relicario-cli/tests/common/mod.rs
Normal file
132
crates/relicario-cli/tests/common/mod.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! Shared helpers for CLI integration tests.
|
||||||
|
//!
|
||||||
|
//! `TestVault::init()` spins up a fresh vault in a `TempDir` using
|
||||||
|
//! `RELICARIO_TEST_PASSPHRASE` as the escape hatch (bypasses TTY prompts).
|
||||||
|
//! Every `run()` / `run_with_input()` call sets both `RELICARIO_IMAGE` and
|
||||||
|
//! `RELICARIO_TEST_PASSPHRASE`, so vault-mutating commands unlock without
|
||||||
|
//! interactive input.
|
||||||
|
//!
|
||||||
|
//! Note for Task 23 implementers: commands that prompt for a *new item
|
||||||
|
//! password* (i.e. `edit` when changing a Login password) also use
|
||||||
|
//! `rpassword`. Plumb `RELICARIO_TEST_ITEM_PASSWORD` through `cmd_edit` in
|
||||||
|
//! main.rs, or use an item type / edit path that avoids the rpassword call.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
pub struct TestVault {
|
||||||
|
pub dir: TempDir,
|
||||||
|
pub reference_image: PathBuf,
|
||||||
|
pub passphrase: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestVault {
|
||||||
|
pub fn init() -> Self {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
let carrier = make_test_jpeg(400, 300);
|
||||||
|
let carrier_path = dir.path().join("carrier.jpg");
|
||||||
|
std::fs::write(&carrier_path, &carrier).unwrap();
|
||||||
|
|
||||||
|
let passphrase = "correct horse battery staple 2026".to_string();
|
||||||
|
let ref_path = dir.path().join("reference.jpg");
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(dir.path())
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &passphrase)
|
||||||
|
.args([
|
||||||
|
"init",
|
||||||
|
"--image",
|
||||||
|
carrier_path.to_str().unwrap(),
|
||||||
|
"--output",
|
||||||
|
ref_path.to_str().unwrap(),
|
||||||
|
])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let out = cmd.output().unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"init failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
reference_image: ref_path,
|
||||||
|
passphrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
self.dir.path()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&self, args: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(self.dir.path())
|
||||||
|
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
cmd.output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(self.dir.path())
|
||||||
|
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||||
|
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", backup_pass)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
cmd.output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(self.dir.path())
|
||||||
|
.env("RELICARIO_IMAGE", &self.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &self.passphrase)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = cmd.spawn().unwrap();
|
||||||
|
{
|
||||||
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
|
for line in extra {
|
||||||
|
writeln!(stdin, "{line}").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_test_jpeg(w: u32, h: u32) -> Vec<u8> {
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
use image::{ExtendedColorType, ImageBuffer, ImageEncoder, Rgb};
|
||||||
|
|
||||||
|
let img = ImageBuffer::from_fn(w, h, |x, y| {
|
||||||
|
Rgb([
|
||||||
|
((x * 7 + y * 13) % 256) as u8,
|
||||||
|
((x * 11 + y * 3) % 256) as u8,
|
||||||
|
((x * 5 + y * 17) % 256) as u8,
|
||||||
|
])
|
||||||
|
});
|
||||||
|
let mut out = Vec::new();
|
||||||
|
JpegEncoder::new_with_quality(&mut out, 92)
|
||||||
|
.write_image(img.as_raw(), w, h, ExtendedColorType::Rgb8)
|
||||||
|
.unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
191
crates/relicario-cli/tests/edit_and_history.rs
Normal file
191
crates/relicario-cli/tests/edit_and_history.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::TestVault;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_password_captures_history() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
|
||||||
|
// edit: accept defaults on title/group/tags/username/url, then change pw.
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success(), "edit failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Verify the edit commit exists in git log.
|
||||||
|
let log = std::process::Command::new("git")
|
||||||
|
.current_dir(v.path()).args(["log", "--oneline"])
|
||||||
|
.output().unwrap();
|
||||||
|
let log_str = String::from_utf8(log.stdout).unwrap();
|
||||||
|
assert!(log_str.contains("edit: bank"), "missing edit commit: {log_str}");
|
||||||
|
|
||||||
|
// And the item file has been re-written (there's a single items/<id>.enc).
|
||||||
|
let items_dir = v.path().join("items");
|
||||||
|
let entries: Vec<_> = std::fs::read_dir(&items_dir).unwrap()
|
||||||
|
.map(|e| e.unwrap().path()).collect();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the interactive `edit` flow end-to-end:
|
||||||
|
/// 1. passphrase via env var.
|
||||||
|
/// 2. blank lines for title, group, tags, username, url.
|
||||||
|
/// 3. "y" for "Change password?"
|
||||||
|
/// 4. new password via RELICARIO_TEST_ITEM_SECRET env var.
|
||||||
|
fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::process::Output {
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_TEST_ITEM_SECRET", new_pw)
|
||||||
|
.args(["edit", query])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = cmd.spawn().unwrap();
|
||||||
|
{
|
||||||
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
|
// title, group, tags, username, url (keep defaults), then yes-to-change-pw.
|
||||||
|
for line in ["", "", "", "", "", "y"] {
|
||||||
|
writeln!(stdin, "{line}").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_lists_per_field_entries() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success(), "edit failed: {:?}", out);
|
||||||
|
|
||||||
|
// `history <query>` should list the captured field and a count.
|
||||||
|
let out = v.run(&["history", "bank"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"history failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("login_password"),
|
||||||
|
"expected login_password key, got: {stdout}"
|
||||||
|
);
|
||||||
|
// Default (no --show) hides values.
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("first-pw"),
|
||||||
|
"values should be masked without --show: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("****"),
|
||||||
|
"expected masked value indicator: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_show_reveals_prior_values() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success());
|
||||||
|
|
||||||
|
let out = v.run(&["history", "bank", "--show"]);
|
||||||
|
assert!(out.status.success(), "history --show failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("first-pw"),
|
||||||
|
"expected old value 'first-pw' in --show output: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_reports_empty_when_nothing_changed() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "untouched",
|
||||||
|
"--username", "u", "--password", "pw"]);
|
||||||
|
|
||||||
|
let out = v.run(&["history", "untouched"]);
|
||||||
|
assert!(out.status.success(), "history failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.to_lowercase().contains("no history"),
|
||||||
|
"expected 'no history' message, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_totp_rotates_secret_and_captures_history() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&[
|
||||||
|
"add", "totp",
|
||||||
|
"--title", "github",
|
||||||
|
"--issuer", "github.com",
|
||||||
|
"--label", "alice",
|
||||||
|
"--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Edit: change issuer, label, then rotate the secret to a new base32 value.
|
||||||
|
let out = run_edit_totp(&v, "github", "github-new.com", "alice@new", "NB2W45DFOIZA");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"edit failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the issuer and label changes persisted by reading the item back.
|
||||||
|
let out = v.run(&["get", "github"]);
|
||||||
|
assert!(out.status.success(), "get failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("github-new.com"),
|
||||||
|
"expected new issuer in get output, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice@new"),
|
||||||
|
"expected new label in get output, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the interactive `edit` flow for a TOTP item with secret rotation.
|
||||||
|
/// Stdin order: Title, Group, Tags (all blank to keep), Issuer, Label,
|
||||||
|
/// then "y" to "Change TOTP secret?" The new secret comes from
|
||||||
|
/// RELICARIO_TEST_ITEM_SECRET.
|
||||||
|
fn run_edit_totp(
|
||||||
|
v: &TestVault,
|
||||||
|
query: &str,
|
||||||
|
new_issuer: &str,
|
||||||
|
new_label: &str,
|
||||||
|
new_secret_b32: &str,
|
||||||
|
) -> std::process::Output {
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_TEST_ITEM_SECRET", new_secret_b32)
|
||||||
|
.args(["edit", query])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = cmd.spawn().unwrap();
|
||||||
|
{
|
||||||
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
|
for line in ["", "", "", new_issuer, new_label, "y"] {
|
||||||
|
writeln!(stdin, "{line}").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
17
crates/relicario-cli/tests/fixtures/lastpass-sample.csv
vendored
Normal file
17
crates/relicario-cli/tests/fixtures/lastpass-sample.csv
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
url,username,password,totp,extra,name,grouping,fav
|
||||||
|
https://github.com/login,alice@example.com,hunter2-strong,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,One-time URL: https://github.com/recover,GitHub,Work,1
|
||||||
|
https://gmail.com,bob@example.com,p@ssw0rd-2026,,,Gmail,Personal,
|
||||||
|
https://news.ycombinator.com,charlie,hn-secret,,,Hacker News,,
|
||||||
|
https://aws.console,d-user,aws-pass,!!!not-base32!!!,,AWS,Work,
|
||||||
|
http://sn,,,,Wifi password: hunter2hunter2,Home Wifi,Personal,
|
||||||
|
http://sn,,,,"NoteType:Credit Card
|
||||||
|
Number:4111111111111111
|
||||||
|
Expiry:01/2030
|
||||||
|
CVV:123",Visa Card,Personal,
|
||||||
|
https://日本語.example,user,pass,,,日本語サイト,,
|
||||||
|
not-a-real-url,user,pass,,,Bad URL,,
|
||||||
|
,,,,,,,
|
||||||
|
https://x,user,,,,No Password,,
|
||||||
|
https://example.com,user,p,,"multi
|
||||||
|
line
|
||||||
|
notes",Multiline,,
|
||||||
|
127
crates/relicario-cli/tests/import_lastpass.rs
Normal file
127
crates/relicario-cli/tests/import_lastpass.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
mod common;
|
||||||
|
use common::TestVault;
|
||||||
|
|
||||||
|
const FIXTURE: &str = "tests/fixtures/lastpass-sample.csv";
|
||||||
|
|
||||||
|
fn fixture_path() -> std::path::PathBuf {
|
||||||
|
// Manifest dir = crates/relicario-cli; the fixture is relative to it.
|
||||||
|
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_logins_secure_notes_and_warns_on_skipped() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"import failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
// 9 items expected (see fixture comment).
|
||||||
|
assert!(stderr.contains("Imported 9"), "stderr: {stderr}");
|
||||||
|
assert!(stderr.contains("skipped 2"), "stderr: {stderr}");
|
||||||
|
|
||||||
|
// Each warning surfaces.
|
||||||
|
assert!(stderr.contains("invalid base32 TOTP"), "TOTP warning missing");
|
||||||
|
assert!(stderr.contains("invalid URL"), "URL warning missing");
|
||||||
|
assert!(stderr.contains("missing `name`"), "name-missing warning missing");
|
||||||
|
assert!(stderr.contains("missing `password`"), "password-missing warning missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_after_import_shows_imported_titles() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("GitHub"));
|
||||||
|
assert!(stdout.contains("Gmail"));
|
||||||
|
assert!(stdout.contains("Home Wifi"));
|
||||||
|
assert!(stdout.contains("Visa Card"));
|
||||||
|
assert!(stdout.contains("日本語サイト"));
|
||||||
|
// Skipped rows must NOT appear.
|
||||||
|
assert!(!stdout.contains("No Password"),
|
||||||
|
"row with no password should have been skipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_creates_a_single_git_commit() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Count commits before.
|
||||||
|
let before = std::process::Command::new("git")
|
||||||
|
.arg("-C").arg(v.path())
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.output().unwrap();
|
||||||
|
let before_n: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
|
||||||
|
let after = std::process::Command::new("git")
|
||||||
|
.arg("-C").arg(v.path())
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.output().unwrap();
|
||||||
|
let after_n: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(after_n, before_n + 1, "expected exactly one new commit");
|
||||||
|
|
||||||
|
// Commit message includes the count + "LastPass".
|
||||||
|
let log = std::process::Command::new("git")
|
||||||
|
.arg("-C").arg(v.path())
|
||||||
|
.args(["log", "-1", "--pretty=%s"])
|
||||||
|
.output().unwrap();
|
||||||
|
let subject = String::from_utf8(log.stdout).unwrap();
|
||||||
|
assert!(subject.contains("9 items"));
|
||||||
|
assert!(subject.contains("LastPass"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_with_zero_items_exits_nonzero() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Header-only CSV with one bad row → 0 items.
|
||||||
|
let bad_csv = v.path().join("empty.csv");
|
||||||
|
std::fs::write(
|
||||||
|
&bad_csv,
|
||||||
|
"url,username,password,totp,extra,name,grouping,fav\n,,,,,,,\n",
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success(), "expected non-zero exit on zero items");
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(stderr.contains("imported 0 items"), "stderr: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_rejects_unrecognized_header() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let bad_csv = v.path().join("wrong.csv");
|
||||||
|
std::fs::write(&bad_csv, "name,url,user,pass\nA,https://x,u,p\n").unwrap();
|
||||||
|
|
||||||
|
let out = v.run(&["import", "lastpass", bad_csv.to_str().unwrap()]);
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
stderr.contains("LastPass") || stderr.contains("expected"),
|
||||||
|
"stderr: {stderr}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imported_items_keep_unique_ids_across_runs() {
|
||||||
|
// Decision D12: two imports of the same CSV must not collide.
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
v.run(&["import", "lastpass", fixture_path().to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
// Each title imported twice — count occurrences of "GitHub" must be 2.
|
||||||
|
let github_count = stdout.matches("GitHub").count();
|
||||||
|
assert_eq!(github_count, 2, "stdout: {stdout}");
|
||||||
|
}
|
||||||
158
crates/relicario-cli/tests/settings.rs
Normal file
158
crates/relicario-cli/tests/settings.rs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::TestVault;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_roundtrip_trash_retention() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run(&["settings", "show"]);
|
||||||
|
assert!(String::from_utf8(out.stdout).unwrap().contains("trash_retention"));
|
||||||
|
|
||||||
|
let out = v.run(&["settings", "trash-retention", "--days", "60"]);
|
||||||
|
assert!(out.status.success(), "set failed: {:?}", out);
|
||||||
|
let out = v.run(&["settings", "show"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("60"), "expected 60: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_rejects_conflicting_retention_flags() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
||||||
|
assert!(!out.status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_uses_vault_default_length() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Default vault settings: GeneratorRequest::Random { length: 20, ... }.
|
||||||
|
let out = v.run(&["generate"]);
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
20,
|
||||||
|
"expected 20 chars at default, got {pw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the vault default length to 32.
|
||||||
|
let out = v.run(&["settings", "generator-defaults", "--length", "32"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"set generator-defaults failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `generate` (no flags) should now produce 32 chars.
|
||||||
|
let out = v.run(&["generate"]);
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
32,
|
||||||
|
"expected 32 chars after update, got {pw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit flag overrides the vault default.
|
||||||
|
let out = v.run(&["generate", "--length", "8"]);
|
||||||
|
assert!(out.status.success());
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
8,
|
||||||
|
"explicit flag should override vault default, got {pw:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_reports_item_and_attachment_counts() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "active",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
v.run(&["add", "login", "--title", "to-trash",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
v.run(&["rm", "to-trash"]);
|
||||||
|
|
||||||
|
let payload = v.path().join("payload.txt");
|
||||||
|
std::fs::write(&payload, b"hello-world").unwrap();
|
||||||
|
v.run(&["attach", "active", payload.to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"status failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let lower = stdout.to_lowercase();
|
||||||
|
|
||||||
|
// 1 active + 1 trashed = 2 items total.
|
||||||
|
assert!(lower.contains("items"), "missing items section: {stdout}");
|
||||||
|
assert!(stdout.contains('2') || stdout.contains("2 ")
|
||||||
|
|| lower.contains("active: 1") || lower.contains("1 active"),
|
||||||
|
"expected item counts in output: {stdout}");
|
||||||
|
assert!(lower.contains("trash"), "missing trash count: {stdout}");
|
||||||
|
|
||||||
|
// 1 attachment, 11 bytes.
|
||||||
|
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
||||||
|
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
||||||
|
|
||||||
|
// device count line removed — device key system was security theater (audit B1).
|
||||||
|
|
||||||
|
// Last-commit line.
|
||||||
|
assert!(
|
||||||
|
lower.contains("last commit") || lower.contains("commit"),
|
||||||
|
"missing last-commit info: {stdout}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_shows_last_backup_line() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
assert!(out.status.success());
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("Last export:"), "missing last export line: {stdout}");
|
||||||
|
assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_shows_recent_backup_after_export() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
let backup_path = v.path().join("v.relbak");
|
||||||
|
v.run_with_backup_pass(
|
||||||
|
&["backup", "export", backup_path.to_str().unwrap()],
|
||||||
|
"test-backup-pass-2026",
|
||||||
|
);
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.contains("Last export:"), "{stdout}");
|
||||||
|
assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_works_outside_vault() {
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.args(["generate", "--length", "12"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"no-vault generate failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(pw.trim().chars().count(), 12);
|
||||||
|
}
|
||||||
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use assert_cmd::Command;
|
||||||
|
use predicates::prelude::PredicateBooleanExt;
|
||||||
|
use predicates::str::contains;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_bash_emits_script() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["completions", "bash"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("_relicario"))
|
||||||
|
.stdout(contains("complete -F"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_zsh_emits_script() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["completions", "zsh"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("#compdef relicario"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_fish_emits_script() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["completions", "fish"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("complete -c relicario"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_command_refreshes_groups_cache() {
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", "T",
|
||||||
|
"--username", "u",
|
||||||
|
"--group", "work",
|
||||||
|
"--password", "hunter2",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add failed: {:?}", out);
|
||||||
|
|
||||||
|
let out = v.run(&["list"]);
|
||||||
|
assert!(out.status.success(), "list failed: {:?}", out);
|
||||||
|
|
||||||
|
let cache_path = v.path().join(".relicario/groups.cache");
|
||||||
|
let cache = std::fs::read_to_string(&cache_path)
|
||||||
|
.unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display()));
|
||||||
|
assert!(
|
||||||
|
cache.lines().any(|l| l == "work"),
|
||||||
|
"expected 'work' in groups.cache, got: {cache:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_groups_cache_env_var_suppresses_write() {
|
||||||
|
use std::process::{Command as StdCommand, Stdio};
|
||||||
|
use assert_cmd::cargo::CommandCargoExt as _;
|
||||||
|
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
// Add with the env var set so no cache is created by add either.
|
||||||
|
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||||
|
.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||||
|
.args([
|
||||||
|
"add", "login",
|
||||||
|
"--title", "T2",
|
||||||
|
"--username", "u",
|
||||||
|
"--group", "personal",
|
||||||
|
"--password", "hunter2",
|
||||||
|
])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "add failed: {:?}", out);
|
||||||
|
|
||||||
|
// Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written.
|
||||||
|
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||||
|
.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||||
|
.args(["list"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "list failed: {:?}", out);
|
||||||
|
|
||||||
|
let cache_path = v.path().join(".relicario/groups.cache");
|
||||||
|
assert!(
|
||||||
|
!cache_path.exists(),
|
||||||
|
"groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_strong_passphrase_prints_score_and_guesses() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("score:"))
|
||||||
|
.stdout(contains("guesses:"))
|
||||||
|
.stdout(contains("strong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_weak_passphrase_exits_zero_with_weak_label() {
|
||||||
|
// `rate` is informational — does NOT exit nonzero on weak input.
|
||||||
|
// The hard gate lives at `init` (Plan 2B Task 10).
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["rate", "password"])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("very weak").or(contains("weak")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_reads_from_stdin_when_arg_is_dash() {
|
||||||
|
Command::cargo_bin("relicario").unwrap()
|
||||||
|
.args(["rate", "-"])
|
||||||
|
.write_stdin("correcthorsebatterystaple\n")
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("score:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_test_qr(uri: &str, dest: &std::path::Path) {
|
||||||
|
use image::{ImageBuffer, Luma};
|
||||||
|
let code = qrcode::QrCode::new(uri).expect("QR encode failed");
|
||||||
|
let img: ImageBuffer<Luma<u8>, Vec<u8>> = code
|
||||||
|
.render::<Luma<u8>>()
|
||||||
|
.module_dimensions(8, 8)
|
||||||
|
.build();
|
||||||
|
img.save(dest).expect("save QR PNG");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_login_totp_qr_decodes_otpauth_uri() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let qr_path = tmp.path().join("test.png");
|
||||||
|
make_test_qr(
|
||||||
|
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||||
|
&qr_path,
|
||||||
|
);
|
||||||
|
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", "TotpTest",
|
||||||
|
"--password", "hunter2",
|
||||||
|
"--totp-qr", qr_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
let out = v.run(&["get", "TotpTest", "--show"]);
|
||||||
|
assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
// BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip.
|
||||||
|
// The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes,
|
||||||
|
// then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars.
|
||||||
|
assert!(
|
||||||
|
stdout.contains("JBSWY3DPEHPK3PXP"),
|
||||||
|
"expected TOTP secret in get output, got:\n{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_login_totp_qr_errors_on_non_otpauth_qr() {
|
||||||
|
use tempfile::TempDir;
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let qr_path = tmp.path().join("nottotp.png");
|
||||||
|
make_test_qr("https://example.com", &qr_path);
|
||||||
|
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", "BadQR",
|
||||||
|
"--password", "hunter2",
|
||||||
|
"--totp-qr", qr_path.to_str().unwrap(),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
!out.status.success(),
|
||||||
|
"expected nonzero exit for non-otpauth QR, but command succeeded"
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("not a TOTP URI"),
|
||||||
|
"expected 'not a TOTP URI' in stderr, got:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
59
crates/relicario-cli/tests/vault_detection.rs
Normal file
59
crates/relicario-cli/tests/vault_detection.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::process::Command;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_refuses_without_vault_marker() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
// No .relicario/ in dir — list should bail with a friendly error.
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
let out = cmd.current_dir(dir.path())
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", "foo")
|
||||||
|
.arg("list")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(stderr.contains(".relicario"), "expected marker hint: {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_finds_vault_in_parent_dir() {
|
||||||
|
let v = common::TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "parent-test",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
// Create a nested subdir and run `list` from inside it.
|
||||||
|
let nested = v.path().join("a/b/c");
|
||||||
|
std::fs::create_dir_all(&nested).unwrap();
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(&nested)
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.arg("list");
|
||||||
|
let out = cmd.output().unwrap();
|
||||||
|
assert!(out.status.success(), "list from nested dir failed: {:?}", out);
|
||||||
|
assert!(String::from_utf8(out.stdout).unwrap().contains("parent-test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_vault_is_rejected_with_clear_error() {
|
||||||
|
// Synthesize an on-disk v1 vault: .idfoto/ dir with old params.json.
|
||||||
|
// Since vault_dir detection uses .relicario/, the pre-rename dir name is
|
||||||
|
// naturally rejected without any compat shim. Confirm that.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join(".idfoto")).unwrap();
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
let out = cmd.current_dir(dir.path())
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", "foo")
|
||||||
|
.arg("list")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!out.status.success());
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(stderr.contains(".relicario"), "expected relicario marker demand: {stderr}");
|
||||||
|
}
|
||||||
514
crates/relicario-core/ARCHITECTURE.md
Normal file
514
crates/relicario-core/ARCHITECTURE.md
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
# Architecture: relicario-core
|
||||||
|
|
||||||
|
## What this crate is for
|
||||||
|
|
||||||
|
`relicario-core` is the platform-agnostic cryptographic and data-model heart of the
|
||||||
|
relicario password manager. It is strictly **bytes-in / bytes-out**: every public
|
||||||
|
function takes byte slices or owned typed structs and returns byte vectors or typed
|
||||||
|
structs. The crate performs no filesystem I/O, no network I/O, no git operations,
|
||||||
|
and no time-of-day reads beyond `chrono::Utc::now()` for timestamping items
|
||||||
|
(`time.rs:6`). This boundary is what lets the same compiled artifact serve the
|
||||||
|
native CLI (`relicario-cli`), a `wasm32-unknown-unknown` build embedded in the
|
||||||
|
Chrome MV3 / Firefox WebExtension popup (`relicario-wasm`), and (eventually) ARM
|
||||||
|
mobile builds — without conditional compilation. Anything that touches a
|
||||||
|
`Path`, opens a socket, or shells out belongs in `relicario-cli` or the
|
||||||
|
extension layer, never here. The historical rationale is in
|
||||||
|
`docs/superpowers/specs/2026-04-11-relicario-design.md` (sections "Crypto
|
||||||
|
Pipeline" and "Crate Layout").
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
- **`lib.rs`** — Public API surface. Re-exports the symbols that callers actually
|
||||||
|
need (`encrypt_item`, `derive_master_key`, `Item`, `ItemCore`, etc.). The
|
||||||
|
module list here is the contract; everything else is internal.
|
||||||
|
- **`error.rs`** — `RelicarioError` (a `thiserror`-derived enum) plus the crate
|
||||||
|
alias `Result<T> = std::result::Result<T, RelicarioError>`. One error type
|
||||||
|
for the whole crate so FFI / WASM bindings and CLI handlers each have a single
|
||||||
|
exhaustive `match` to maintain. `Decrypt` is intentionally opaque (no inner
|
||||||
|
detail string) — see "Cross-cutting concerns".
|
||||||
|
- **`crypto.rs`** — KDF (`derive_master_key`, Argon2id with NFC-normalized,
|
||||||
|
length-prefixed inputs) and AEAD (`encrypt`, `decrypt`, XChaCha20-Poly1305
|
||||||
|
with `VERSION_BYTE = 0x02`). Owns the on-disk ciphertext layout. The KDF
|
||||||
|
parameters (`KdfParams`) are an owned struct that callers persist however
|
||||||
|
they like (CLI puts them in `.relicario/params.json`); the crate has no
|
||||||
|
opinion about storage.
|
||||||
|
- **`ids.rs`** — `ItemId`, `FieldId` (random 64-bit hex from `OsRng`,
|
||||||
|
`ids.rs:26-32`, `ids.rs:38-49`) and content-addressed `AttachmentId`
|
||||||
|
(first 8 bytes of `SHA-256(plaintext)`, `ids.rs:51-57`). Three separate
|
||||||
|
newtypes rather than `String` so misuses can't compile.
|
||||||
|
- **`time.rs`** — `now_unix()` and `MonthYear` (the validated 1..=12 / 2000..=2099
|
||||||
|
card-expiry type). Trivially small; broken out only because every other module
|
||||||
|
needs `now_unix()` and `MonthYear` is used by both `item.rs` and
|
||||||
|
`item_types/card.rs`.
|
||||||
|
- **`item_types/mod.rs`** — `ItemType` enum (snake-case wire tag) and `ItemCore`
|
||||||
|
(internally tagged `#[serde(tag = "type")]` enum), with one variant per item
|
||||||
|
type. The "extension via match exhaustiveness" pattern is documented at
|
||||||
|
`item_types/mod.rs:1-7`: adding an item type is a `cargo check` walk through
|
||||||
|
every match arm. Re-exports each per-type core.
|
||||||
|
- **`item_types/login.rs`** — `LoginCore` (username, password as
|
||||||
|
`Zeroizing<String>`, optional `Url`, optional `TotpConfig`).
|
||||||
|
- **`item_types/secure_note.rs`** — `SecureNoteCore` (single `Zeroizing<String>`
|
||||||
|
body).
|
||||||
|
- **`item_types/identity.rs`** — `IdentityCore` (full name, address, phone,
|
||||||
|
email, DOB; all optional, none `Zeroizing` — they're personal data, not
|
||||||
|
secret material).
|
||||||
|
- **`item_types/card.rs`** — `CardCore` plus `CardKind` (Credit/Debit/Gift/
|
||||||
|
Loyalty/Other). `number`, `cvv`, `pin` are `Zeroizing`; `holder` is plain
|
||||||
|
`String`.
|
||||||
|
- **`item_types/key.rs`** — `KeyCore`: opaque `Zeroizing<String>` `key_material`
|
||||||
|
with optional label / public key / algorithm. Used for SSH keys, GPG keys,
|
||||||
|
arbitrary blobs.
|
||||||
|
- **`item_types/document.rs`** — `DocumentCore`: filename + mime + a single
|
||||||
|
`AttachmentId` pointing at the primary blob. The body lives in the
|
||||||
|
attachment store, not the item.
|
||||||
|
- **`item_types/totp.rs`** — `TotpCore`, `TotpConfig`, `TotpAlgorithm`
|
||||||
|
(Sha1/Sha256/Sha512), `TotpKind` (Totp / Hotp{counter} / Steam), and the
|
||||||
|
`compute_totp_code()` function. Includes the Steam Mobile Authenticator
|
||||||
|
5-character alphabet and its conversion (`item_types/totp.rs:103-110`).
|
||||||
|
The same `TotpConfig` is reused as a sub-struct of `LoginCore` (so a Login
|
||||||
|
item can carry its own TOTP without spawning a separate item).
|
||||||
|
- **`item.rs`** — The `Item` envelope. Holds the parallel `FieldKind` /
|
||||||
|
`FieldValue` enums (kept parallel so callers can ask the kind without
|
||||||
|
inspecting the value, `item.rs:1-6`), `Field`, `Section`, `FieldHistoryEntry`,
|
||||||
|
and the `Item` struct itself with its `set_field_value` / `soft_delete` /
|
||||||
|
`restore` / `prune_history` mutators. Custom-fields and field-history live
|
||||||
|
here, not in the per-type cores.
|
||||||
|
- **`attachment.rs`** — `AttachmentRef` (full record carried on `Item`),
|
||||||
|
`AttachmentSummary` (compact form carried in `Manifest`),
|
||||||
|
`EncryptedAttachment`, and the `encrypt_attachment` / `decrypt_attachment`
|
||||||
|
helpers. The size cap is enforced **before** any crypto work (`attachment.rs:69-74`).
|
||||||
|
- **`manifest.rs`** — The browse-without-decrypt index: `Manifest`,
|
||||||
|
`ManifestEntry`, `MANIFEST_SCHEMA_VERSION = 2`. `upsert(&item)` rebuilds the
|
||||||
|
entry from the item — there is no path for the manifest to drift from the
|
||||||
|
source-of-truth item file. Includes case-insensitive title/tag search
|
||||||
|
(`manifest.rs:59-68`) and Login icon-hint derivation (host of the URL,
|
||||||
|
`manifest.rs:93-99`).
|
||||||
|
- **`settings.rs`** — `VaultSettings` and its sub-types: `TrashRetention`,
|
||||||
|
`HistoryRetention`, `GeneratorRequest` (`Random` or `Bip39`),
|
||||||
|
`AttachmentCaps`, plus the `autofill_origin_acks` map for the extension's
|
||||||
|
TOFU prompt.
|
||||||
|
- **`generators.rs`** — Random-password and BIP-39 passphrase generation, both
|
||||||
|
driven by `GeneratorRequest` from `settings.rs`. zxcvbn-backed
|
||||||
|
`rate_passphrase` and the `validate_passphrase_strength` gate that rejects
|
||||||
|
any score < 3.
|
||||||
|
- **`vault.rs`** — Typed wrappers around `crypto::{encrypt, decrypt}`:
|
||||||
|
`encrypt_item`/`decrypt_item`, `encrypt_manifest`/`decrypt_manifest`,
|
||||||
|
`encrypt_settings`/`decrypt_settings`. Each does
|
||||||
|
`serde_json::to_vec → encrypt` (or the inverse). The plaintext `Vec<u8>` is
|
||||||
|
wrapped in `Zeroizing` between serde and the cipher
|
||||||
|
(`vault.rs:18-19`, `vault.rs:24-26`).
|
||||||
|
- **`imgsecret.rs`** — Self-contained DCT-based steganography for the second
|
||||||
|
auth factor. Owns its own `YChannel`, `EmbedRegion`, 8×8 DCT/IDCT,
|
||||||
|
Quantization Index Modulation, and crop-recovery extractor. No other module
|
||||||
|
imports it; it is consumed only via the public re-export from `lib.rs`.
|
||||||
|
|
||||||
|
## Invariants & contracts
|
||||||
|
|
||||||
|
- **No filesystem, no network, no git, no spawn.** Verified by inspecting
|
||||||
|
imports; the only I/O-shaped types in use are in-memory `Cursor<&[u8]>`
|
||||||
|
for image decoding (`imgsecret.rs:243`).
|
||||||
|
- **No `unsafe`.** Confirmed by `grep` over `src/`. The crate compiles to WASM
|
||||||
|
unmodified for that reason.
|
||||||
|
- **No `async`.** All operations are pure compute on byte slices. Async lives
|
||||||
|
in `relicario-cli` (process spawning) and in the extension's service worker
|
||||||
|
(message channels), not here.
|
||||||
|
- **`VERSION_BYTE = 0x02`** (`crypto.rs:59`). Every blob produced by
|
||||||
|
`encrypt()` starts with this byte; `decrypt()` rejects any other value with
|
||||||
|
`RelicarioError::UnsupportedFormatVersion { found, expected }`
|
||||||
|
(`crypto.rs:127-132`). v1 blobs (the pre-rewrite format) are explicitly
|
||||||
|
tested for rejection (`tests/format_v2.rs:28-42`).
|
||||||
|
- **AEAD blob layout** is fixed at `version(1) || nonce(24) || ciphertext+tag(≥16)`
|
||||||
|
(`crypto.rs:18-32`). Minimum valid blob length is 41 bytes
|
||||||
|
(`crypto.rs:118-124`).
|
||||||
|
- **Nonces are always fresh from `OsRng`** (`crypto.rs:87-89`). There is no
|
||||||
|
caller-supplied nonce path. With 192 bits of randomness, collision risk is
|
||||||
|
negligible across the lifetime of any vault.
|
||||||
|
- **`MANIFEST_SCHEMA_VERSION = 2`** (`manifest.rs:12`). v1 manifests (which
|
||||||
|
predate typed items) are not handled here and are rejected at the JSON-parse
|
||||||
|
step.
|
||||||
|
- **KDF input is length-prefixed.** `derive_master_key` builds the password
|
||||||
|
buffer as `u64_be(len(passphrase)) || passphrase || u64_be(32) || image_secret`
|
||||||
|
(`crypto.rs:229-236`). This eliminates the (`"abc"`, `0x44…`) vs (`"abcD"`,
|
||||||
|
`…`) collision, and is exercised in
|
||||||
|
`crypto.rs:352-368` and `tests/format_v2.rs:44-54`.
|
||||||
|
- **Passphrases are NFC-normalized before hashing.** Bytes that aren't valid
|
||||||
|
UTF-8 pass through unchanged (`crypto.rs:223-227`). This keeps "café"
|
||||||
|
(precomposed) and "café" (combining acute) from producing different keys
|
||||||
|
(`crypto.rs:370-385`).
|
||||||
|
- **Master key only ever lives in `Zeroizing<[u8; 32]>`.** Returned that way
|
||||||
|
by `derive_master_key` (`crypto.rs:212`) and accepted that way by
|
||||||
|
`encrypt_item` / `encrypt_attachment` / friends. No public function in
|
||||||
|
`vault.rs` or `attachment.rs` accepts a raw `[u8; 32]`.
|
||||||
|
- **Plaintext is wrapped in `Zeroizing` between serde and the cipher.** See
|
||||||
|
`vault.rs:18-19`, `vault.rs:24-26`, `vault.rs:31-32`, `vault.rs:37-38`,
|
||||||
|
`vault.rs:44-45`, `vault.rs:50-51`. The serde JSON intermediate buffer is the
|
||||||
|
most exposed point, so it is wiped on drop.
|
||||||
|
- **`AttachmentId` is content-addressed** to the first 8 bytes (= 16 hex chars)
|
||||||
|
of `SHA-256(plaintext)` (`ids.rs:51-57`). Identical plaintexts deduplicate
|
||||||
|
in git automatically — proven in `tests/attachments.rs:28-35`. The 64-bit
|
||||||
|
prefix is used (rather than the full digest) to keep filenames short; the
|
||||||
|
collision space is still adequate for the expected vault size.
|
||||||
|
- **`ItemId` and `FieldId` are 16 hex chars** = 64 bits of `OsRng` entropy
|
||||||
|
(`ids.rs:25-32`, `ids.rs:38-49`). The audit (M8) bumped them from the
|
||||||
|
original 8-char / 32-bit format.
|
||||||
|
- **Field kind/value discriminants must agree.** `Field::new` derives `kind`
|
||||||
|
from `value` (`item.rs:85-94`); `Field::validate` (called after deserialize)
|
||||||
|
rejects any mismatch (`item.rs:97-107`). `set_field_value` further refuses
|
||||||
|
to change a field's kind (`item.rs:184-189`).
|
||||||
|
- **Field-history capture is restricted to three kinds:** `Password`,
|
||||||
|
`Concealed`, `Totp` (`item.rs:68-71`). Any other kind's update silently
|
||||||
|
skips history. The TOTP secret is base32-encoded for the history entry
|
||||||
|
(`item.rs:245-249`) so a user reading their history sees a recognizable
|
||||||
|
string.
|
||||||
|
- **History captures the *previous* value, not the new one** (`item.rs:190-197`):
|
||||||
|
`set_field_value` serializes `field.value` *before* assigning the new value.
|
||||||
|
- **`hidden_by_default` is set automatically** when the field's kind is
|
||||||
|
`Password` or `Concealed` (`item.rs:92`). The extension and CLI both honor
|
||||||
|
this hint when rendering.
|
||||||
|
- **Attachment cap is checked before encryption** (`attachment.rs:69-74`).
|
||||||
|
An oversize blob fails with `RelicarioError::AttachmentTooLarge { size, max }`
|
||||||
|
without ever calling `encrypt`. The CLI/extension are expected to read the
|
||||||
|
cap from `VaultSettings::attachment_caps`.
|
||||||
|
- **`Item::soft_delete` does not erase data.** It sets `trashed_at` and bumps
|
||||||
|
`modified` (`item.rs:205-208`). Purging is the caller's responsibility,
|
||||||
|
driven by `TrashRetention::should_purge` (`settings.rs:38-44`).
|
||||||
|
- **`prune_history` is idempotent and explicit.** Items keep all history until
|
||||||
|
the caller invokes it with a `HistoryRetention` policy (`item.rs:219-237`).
|
||||||
|
Last-N drops oldest first; Days drops anything older than `now - days·86400`.
|
||||||
|
- **`item_type()` is the single source of truth** for the type tag stored on
|
||||||
|
`Item`. `Item::new` derives `r#type` from the supplied `ItemCore`
|
||||||
|
(`item.rs:159-164`). Manual construction can violate this — the JSON
|
||||||
|
round-trip does not re-validate beyond serde's tag matching.
|
||||||
|
- **Reserved serde key:** no `*Core` may have a JSON-serialized field named
|
||||||
|
`"type"` — that name is reserved for serde's discriminator on `ItemCore`
|
||||||
|
(`item_types/mod.rs:38-40`). Use `"kind"` instead (see `CardKind`,
|
||||||
|
`TotpKind`).
|
||||||
|
- **`MAX_DIMENSION = 10_000`** for imgsecret (`imgsecret.rs:71`). Enforced via
|
||||||
|
a header-only peek (`imgsecret.rs:127-176`) at the entry of both `embed` and
|
||||||
|
`extract` so an attacker-supplied 32000×32000 JPEG is rejected without
|
||||||
|
decoding pixels (audit M3).
|
||||||
|
- **`MIN_DIMENSION = 100`** plus a "must hold ≥5 redundant copies" floor
|
||||||
|
(`imgsecret.rs:66`, `imgsecret.rs:78`, `imgsecret.rs:682-689`). Smaller
|
||||||
|
carriers are rejected with `ImageTooSmall`.
|
||||||
|
- **Strength gate is `score >= 3`** (`generators.rs:124-130`). Vault-creation
|
||||||
|
callers must invoke `validate_passphrase_strength` themselves; the crate
|
||||||
|
does not internally call it inside `derive_master_key` (since that path is
|
||||||
|
also used to derive the key for *unlock*, not just create).
|
||||||
|
- **`SymbolCharset::Custom` must be ASCII-only** (`generators.rs:46-52`).
|
||||||
|
Non-ASCII custom charsets are rejected with `RelicarioError::Format`.
|
||||||
|
|
||||||
|
## Key flows
|
||||||
|
|
||||||
|
### Vault unlock — key derivation
|
||||||
|
|
||||||
|
1. Caller obtains `passphrase: &[u8]` (UTF-8) and `image_secret: &[u8; 32]`
|
||||||
|
(typically from `imgsecret::extract` over the user's reference JPEG).
|
||||||
|
2. Caller loads `salt: [u8; 32]` and `KdfParams` from out-of-band storage
|
||||||
|
(CLI: `.relicario/salt` and `.relicario/params.json`).
|
||||||
|
3. `derive_master_key(passphrase, &image_secret, &salt, ¶ms)` —
|
||||||
|
`crypto.rs:207-244`:
|
||||||
|
- NFC-normalize the passphrase if it parses as UTF-8 (`crypto.rs:223-227`).
|
||||||
|
- Build the length-prefixed password buffer in a `Zeroizing<Vec<u8>>`
|
||||||
|
(`crypto.rs:229-236`).
|
||||||
|
- Run `Argon2id` with `Algorithm::Argon2id`, `Version::V0x13`,
|
||||||
|
output length 32 (`crypto.rs:213-221`, `crypto.rs:238-241`).
|
||||||
|
4. Returns `Zeroizing<[u8; 32]>` — automatically wiped on drop.
|
||||||
|
|
||||||
|
A wrong passphrase or wrong image produces a *different* derived key. The crate
|
||||||
|
cannot tell them apart at this stage; the caller learns "wrong factor" only
|
||||||
|
when subsequent `decrypt_*` returns `RelicarioError::Decrypt`.
|
||||||
|
|
||||||
|
### Item write
|
||||||
|
|
||||||
|
1. Caller mutates an `Item` (e.g. `item.set_field_value(&fid, new_value)` —
|
||||||
|
`item.rs:181-203`). `set_field_value` captures previous value into
|
||||||
|
`field_history` if the kind is history-tracked, then bumps `modified`.
|
||||||
|
2. Caller calls `encrypt_item(&item, &master_key)` — `vault.rs:16-20`:
|
||||||
|
`serde_json::to_vec(item)` → wrap in `Zeroizing` → `crypto::encrypt`.
|
||||||
|
3. Caller calls `manifest.upsert(&item)` (`manifest.rs:45-48`) to refresh the
|
||||||
|
browse-index entry; then `encrypt_manifest(&manifest, &master_key)`
|
||||||
|
(`vault.rs:29-33`).
|
||||||
|
4. The two ciphertext blobs are returned to the caller, who writes them to disk
|
||||||
|
(or commits them, or sends them over a sync channel).
|
||||||
|
|
||||||
|
### Item read (browse-without-decrypt path)
|
||||||
|
|
||||||
|
1. Caller calls `decrypt_manifest(&manifest_blob, &master_key)`
|
||||||
|
(`vault.rs:35-40`). One AEAD decryption gets the entire searchable index.
|
||||||
|
2. `Manifest::search(query)` does a case-insensitive substring match over title
|
||||||
|
and tags (`manifest.rs:59-68`). `manifest.items.values()` gives every
|
||||||
|
`ManifestEntry` with `title`, `tags`, `favorite`, `group`, `icon_hint`,
|
||||||
|
`modified`, `trashed_at`, and `attachment_summaries` — enough to render a
|
||||||
|
list UI without touching any item file.
|
||||||
|
3. When the user picks an entry, the caller reads `entries/<id>.enc` and calls
|
||||||
|
`decrypt_item(&blob, &master_key)` (`vault.rs:22-27`) to get the full
|
||||||
|
`Item` including secret fields and `field_history`.
|
||||||
|
|
||||||
|
### Attachment encryption
|
||||||
|
|
||||||
|
1. Caller has `plaintext: &[u8]`, the `master_key`, and the active
|
||||||
|
`VaultSettings::attachment_caps.per_attachment_max_bytes`.
|
||||||
|
2. `encrypt_attachment(plaintext, &master_key, max_bytes)` —
|
||||||
|
`attachment.rs:64-78`:
|
||||||
|
- If `plaintext.len() > max_bytes`, return `AttachmentTooLarge` *immediately*
|
||||||
|
before any crypto.
|
||||||
|
- `AttachmentId::from_plaintext(plaintext)` (SHA-256, `ids.rs:51-57`).
|
||||||
|
- `crypto::encrypt(master_key, plaintext)`.
|
||||||
|
3. Returns `EncryptedAttachment { id, bytes }`. The caller persists `bytes` at
|
||||||
|
`attachments/<id>.enc` and adds an `AttachmentRef { id, filename, mime_type,
|
||||||
|
size, created }` (`attachment.rs:11-20`) to the owning `Item`. On
|
||||||
|
`Manifest::upsert`, an `AttachmentSummary` (no `created` field) is derived
|
||||||
|
automatically (`manifest.rs:87`).
|
||||||
|
|
||||||
|
### Field-history capture
|
||||||
|
|
||||||
|
1. Triggered exclusively by `Item::set_field_value` (`item.rs:181-203`). Direct
|
||||||
|
mutation of `field.value` bypasses history — the type system does not
|
||||||
|
prevent this.
|
||||||
|
2. The check `field.value.is_history_tracked()` runs *on the existing value*
|
||||||
|
(`item.rs:190`), so adding the *first* password value to a previously-empty
|
||||||
|
field does not create a history entry; updating an already-set password
|
||||||
|
does.
|
||||||
|
3. The previous value is serialized via `serialize_history_value`
|
||||||
|
(`item.rs:241-253`):
|
||||||
|
- `Password(p)` and `Concealed(c)` clone the inner string into a fresh
|
||||||
|
`Zeroizing<String>`.
|
||||||
|
- `Totp(cfg)` base32-encodes the raw secret bytes
|
||||||
|
(`item.rs:245-249`, `item.rs:256-275`).
|
||||||
|
- Any other kind would error (`item.rs:250`), but is unreachable because
|
||||||
|
`is_history_tracked` already gated the call.
|
||||||
|
4. Pruning is *not* automatic. Callers (CLI commit hook, extension save handler)
|
||||||
|
call `item.prune_history(&settings.field_history_retention, now_unix())`
|
||||||
|
when they want to enforce the policy.
|
||||||
|
|
||||||
|
### imgsecret embed
|
||||||
|
|
||||||
|
1. Caller passes a JPEG byte slice and a 32-byte secret to
|
||||||
|
`imgsecret::embed(carrier_jpeg, &secret)` (`imgsecret.rs:666-726`).
|
||||||
|
2. `enforce_dimension_cap` walks JPEG markers (`imgsecret.rs:127-161`) to read
|
||||||
|
the SOF dimensions; rejects > 10_000 × 10_000 before any pixel decode.
|
||||||
|
3. `extract_y_channel` decodes via `image::ImageReader` and converts each pixel
|
||||||
|
to BT.601 luminance (`imgsecret.rs:242-265`).
|
||||||
|
4. `central_region` picks the inner 70% of the image as the embed region; the
|
||||||
|
15% margin per side is the "crumple zone" for crops
|
||||||
|
(`imgsecret.rs:268-293`).
|
||||||
|
5. `compute_embed_positions` / `select_embed_blocks` lay out
|
||||||
|
`num_copies × BLOCKS_PER_COPY` 8×8 blocks evenly across the region, with
|
||||||
|
`num_copies` = `min(50, total_blocks / 22)` (`imgsecret.rs:530-575`).
|
||||||
|
6. For each block: 2D DCT (`dct2_8x8`, `imgsecret.rs:393-412`) → embed 12 bits
|
||||||
|
into the 12 mid-frequency coefficients listed in `EMBED_POSITIONS`
|
||||||
|
(zig-zag positions 6–17, `imgsecret.rs:105-118`) via QIM with
|
||||||
|
`QUANT_STEP = 50.0` (`imgsecret.rs:462-467`) → 2D inverse DCT → write
|
||||||
|
back into Y.
|
||||||
|
7. `reconstruct_jpeg` (`imgsecret.rs:590-640`) re-derives Cb/Cr per pixel from
|
||||||
|
the original RGB (so chrominance is preserved), combines with the modified
|
||||||
|
Y, and re-encodes at JPEG quality 92.
|
||||||
|
|
||||||
|
### imgsecret extract (with crop recovery)
|
||||||
|
|
||||||
|
1. `extract(jpeg_bytes)` enforces the dimension cap, then delegates to
|
||||||
|
`extract_with_crop_recovery` (`imgsecret.rs:738-741`,
|
||||||
|
`imgsecret.rs:849-899`).
|
||||||
|
2. **Try 1** — assume uncropped: `try_extract_with_layout(&y, w, h, 0, 0)`.
|
||||||
|
This is the hot path; for a freshly embedded image it always succeeds.
|
||||||
|
3. **Try 2** — width-only crop, block-aligned: iterate `orig_w` from current
|
||||||
|
width up to `1.20 × current_w` in 8-px steps, with `dx = 0`
|
||||||
|
(assume right-edge crop).
|
||||||
|
4. **Try 3** — height-only crop, block-aligned: same strategy on the vertical
|
||||||
|
axis.
|
||||||
|
5. **Try 4** — width crops at non-block-aligned 1-px steps, skipping any
|
||||||
|
already covered in Try 2.
|
||||||
|
6. `try_extract_with_layout` (`imgsecret.rs:754-834`) tallies QIM votes for
|
||||||
|
each of the 256 bit positions across all `num_copies` copies. Each bit
|
||||||
|
must reach **≥60% confidence** (`imgsecret.rs:824`); below that, the
|
||||||
|
whole extraction fails with `ExtractionFailed` (no partial result is
|
||||||
|
ever returned).
|
||||||
|
7. The 60% threshold is per-bit, not aggregate — a single unconfident bit
|
||||||
|
aborts the whole try. This makes false-positive extractions from
|
||||||
|
never-embedded images vanishingly unlikely.
|
||||||
|
|
||||||
|
## Cross-cutting concerns
|
||||||
|
|
||||||
|
- **Error model.** `RelicarioError` (`error.rs:15-89`) is a single
|
||||||
|
`thiserror`-derived enum. `Decrypt` is the deliberately-opaque "wrong key
|
||||||
|
or tampered ciphertext" variant (audit M4 — `error.rs:28-30`,
|
||||||
|
`tests/integration.rs:99-111`): the message is just `"decryption failed"`
|
||||||
|
with no inner string, and it does not distinguish wrong-passphrase from
|
||||||
|
wrong-image-secret from corrupted ciphertext. `Format` is the
|
||||||
|
"input bytes don't make sense" variant (e.g. blob too short, schema
|
||||||
|
mismatch). `UnsupportedFormatVersion` is the structured "wrong version
|
||||||
|
byte" variant — separate from `Format` because callers want to react to
|
||||||
|
it differently (offer migration, etc.).
|
||||||
|
- **Where secrets live.** Every secret type wraps `Zeroizing<...>`:
|
||||||
|
- The derived master key: `Zeroizing<[u8; 32]>` (`crypto.rs:212`).
|
||||||
|
- Field values: `FieldValue::Password(Zeroizing<String>)` and
|
||||||
|
`FieldValue::Concealed(Zeroizing<String>)` (`item.rs:39-40`).
|
||||||
|
- `FieldHistoryEntry::value`: `Zeroizing<String>` (`item.rs:127`).
|
||||||
|
- Per-type cores: `LoginCore::password`, `CardCore::{number,cvv,pin}`,
|
||||||
|
`KeyCore::key_material`, `SecureNoteCore::body`, `TotpConfig::secret`
|
||||||
|
(a `Zeroizing<Vec<u8>>` of the raw HMAC key).
|
||||||
|
- Decrypted attachment plaintext: `Zeroizing<Vec<u8>>`
|
||||||
|
(`attachment.rs:88-92`).
|
||||||
|
- Argon2id input buffer (`crypto.rs:232`) and JSON serialization buffers in
|
||||||
|
`vault.rs` are wrapped in `Zeroizing` to wipe the intermediate plaintext.
|
||||||
|
- **Format versioning.** Three independent version channels exist, each
|
||||||
|
gating something different:
|
||||||
|
- `crypto::VERSION_BYTE = 0x02` (`crypto.rs:59`) — gates the AEAD blob
|
||||||
|
layout. Bumped if the nonce length, header layout, or cipher changes.
|
||||||
|
A v1 blob is rejected with a typed
|
||||||
|
`UnsupportedFormatVersion { found: 0x01, expected: 0x02 }`.
|
||||||
|
- `manifest::MANIFEST_SCHEMA_VERSION = 2` (`manifest.rs:12`) — gates the
|
||||||
|
JSON-level shape of the manifest. v1 manifests had a different layout
|
||||||
|
and would fail to parse against the current `Manifest` struct.
|
||||||
|
- The `.relbak` import/export format defined in
|
||||||
|
`docs/superpowers/specs/2026-04-27-relicario-import-export-design.md`
|
||||||
|
will introduce a third version channel for backups; that surface lives
|
||||||
|
outside this crate.
|
||||||
|
- **KDF parameter handling.** `KdfParams` (`crypto.rs:156-168`) is just a
|
||||||
|
serializable struct. The crate has no opinion about where it is stored,
|
||||||
|
how it is rotated, or who increments it. `Default` gives the production
|
||||||
|
values (`m=65536`, `t=3`, `p=4` — `crypto.rs:175-183`) calibrated for
|
||||||
|
~0.5–1 s on a modern desktop. Tests universally use the fast triplet
|
||||||
|
`(m=256, t=1, p=1)` defined as a `fn fast_params()` near the top of every
|
||||||
|
test file.
|
||||||
|
- **NFC normalization is the only Unicode op.** All passphrase canonicalization
|
||||||
|
happens in one place (`crypto.rs:223-227`). Item titles, field labels,
|
||||||
|
tags, etc. are stored verbatim — only the passphrase fed to the KDF is
|
||||||
|
normalized.
|
||||||
|
- **No per-entry subkeys.** Every encrypted blob (item, manifest, settings,
|
||||||
|
attachment) is encrypted with the *same* master key. The design rationale
|
||||||
|
is in `docs/superpowers/specs/2026-04-11-relicario-design.md` lines 66:
|
||||||
|
per-entry subkey derivation would add complexity for no real-world benefit
|
||||||
|
given the expected family-vault size.
|
||||||
|
- **CSPRNG is `OsRng` everywhere.** `ItemId::new`, `FieldId::new`,
|
||||||
|
`derive_master_key` (no-op — the salt is caller-supplied),
|
||||||
|
`crypto::encrypt` (nonce), `generators::random_password`,
|
||||||
|
`generators::bip39_passphrase`. A single `rand::thread_rng()` call exists
|
||||||
|
inside an `imgsecret` test (`imgsecret.rs:1033`) to generate a random test
|
||||||
|
secret; production code is `OsRng` only.
|
||||||
|
- **`ed25519-dalek` is a dependency placeholder.** Listed in
|
||||||
|
`Cargo.toml:17` but unused in `src/`. It exists for the future
|
||||||
|
device-key surface (`RelicarioError::DeviceKey` is the reserved variant,
|
||||||
|
`error.rs:84-88`); device-key signing currently happens in
|
||||||
|
`relicario-cli` instead.
|
||||||
|
|
||||||
|
## Test architecture
|
||||||
|
|
||||||
|
All `tests/` files use the fast Argon2id triplet `m=256, t=1, p=1` so the
|
||||||
|
suite runs in seconds, not minutes. Test JPEGs are synthesized at runtime via
|
||||||
|
`make_test_jpeg(width, height)` (`imgsecret.rs:908-924`) — a deterministic RGB
|
||||||
|
pattern at quality 92 — so no binary fixtures live in git.
|
||||||
|
|
||||||
|
- **`tests/integration.rs`** — End-to-end vault workflows: encrypt+decrypt a
|
||||||
|
Login and a SecureNote through `Manifest`/`VaultSettings`, two-factor
|
||||||
|
independence (different passphrase or different image_secret yields
|
||||||
|
different keys), field-history surviving an encrypt/decrypt round-trip,
|
||||||
|
and the wrong-key-→-`Decrypt` opaqueness contract.
|
||||||
|
- **`tests/attachments.rs`** — Round-trip a 5 KB blob, prove identical
|
||||||
|
plaintexts produce identical `AttachmentId`s (despite different ciphertext
|
||||||
|
bytes due to fresh nonces), and exercise the cap boundary at exactly the
|
||||||
|
max byte and one over.
|
||||||
|
- **`tests/field_history.rs`** — Sequential `set_field_value` calls accumulate
|
||||||
|
history in oldest→newest order; `prune_history(LastN(3))` keeps the most
|
||||||
|
recent 3; field-history survives `encrypt_item` →`decrypt_item`.
|
||||||
|
- **`tests/format_v2.rs`** — `VERSION_BYTE == 0x02`, fresh ciphertext starts
|
||||||
|
with `0x02`, a v1-shaped blob (`[0x01][24 nonce][16 tag]`) is rejected with
|
||||||
|
the typed `UnsupportedFormatVersion`, and the length-prefix construction
|
||||||
|
prevents `("abc", 0x44…)` / `("abcD", …)` collisions.
|
||||||
|
- **`tests/generators.rs`** — Aggregates 80 × 128 = 10,240 chars from
|
||||||
|
`generate_password` to assert per-character-class proportions are within
|
||||||
|
±5 pp of the expected uniform distribution; verifies that 5-word BIP-39
|
||||||
|
passes the strength gate while common weak passwords ("password",
|
||||||
|
"12345678", "letmein", "qwertyui", "hunter2") all fail; asserts uniqueness
|
||||||
|
across 1000 default-config calls. The opening doc comment
|
||||||
|
(`tests/generators.rs:1-13`) explains why the original "10,000-char single
|
||||||
|
call" plan switched to aggregation: `generate_password` enforces
|
||||||
|
`length ≤ 128`.
|
||||||
|
|
||||||
|
In-module `#[cfg(test)] mod tests` blocks cover unit-level invariants (kind/
|
||||||
|
value mismatches, snake-case serde tags, base32 round-trips, `MonthYear`
|
||||||
|
constructor bounds, the Steam alphabet ambiguity audit). The `imgsecret`
|
||||||
|
test block additionally proves DCT round-tripping, QIM noise tolerance below
|
||||||
|
`Q/4 = 12.5`, embed→Q85-recompress→extract round-trip, embed→10%-crop→extract
|
||||||
|
round-trip, and the oversized-image-header rejection path.
|
||||||
|
|
||||||
|
## Gotchas & non-obvious decisions
|
||||||
|
|
||||||
|
- **`QUANT_STEP = 50.0` is intentionally double the academic value of 25**
|
||||||
|
(`imgsecret.rs:62`). Higher quantization steps make the watermark more robust
|
||||||
|
to JPEG recompression at Q85 and below — at the cost of more visible
|
||||||
|
artifacts in the carrier. The reference image is a personal photo, not a
|
||||||
|
publication, so the trade-off favors robustness.
|
||||||
|
- **The embed region is the *central 70%* (15% margin per side, "crumple
|
||||||
|
zone")** — `imgsecret.rs:212-218`, `imgsecret.rs:276-293`. Anything in the
|
||||||
|
outer 15% is sacrificed so that mild edge crops (e.g. social-media platform
|
||||||
|
trims) leave the embedded data intact. Tested up to 10% crop in
|
||||||
|
`imgsecret.rs:1108-1137`.
|
||||||
|
- **Per-bit majority voting with a 60% confidence floor.**
|
||||||
|
`try_extract_with_layout` tallies votes from every redundant copy and
|
||||||
|
fails the entire extraction if any single bit position is below 60%
|
||||||
|
agreement (`imgsecret.rs:824`). This is more conservative than a global
|
||||||
|
threshold and is what makes false positives from never-embedded images
|
||||||
|
essentially zero — see `extract_from_non_embedded_image_fails`
|
||||||
|
(`imgsecret.rs:1041-1045`).
|
||||||
|
- **Number of redundant copies is capped at 50** (`imgsecret.rs:536`,
|
||||||
|
`imgsecret.rs:692-693`). Beyond that, per-block visual artifacts compound
|
||||||
|
faster than the error-correction benefit grows.
|
||||||
|
- **`peek_jpeg_dimensions` walks JPEG markers manually instead of using the
|
||||||
|
`image` crate.** `imgsecret.rs:127-161`. A full `ImageReader::decode` of an
|
||||||
|
attacker-supplied 30 000 × 30 000 JPEG would allocate ~3.6 GB of pixel
|
||||||
|
buffer in the WASM service worker before failing — the manual walk reads
|
||||||
|
only the SOF segment and bails in O(marker-count) (audit M3).
|
||||||
|
- **`bip39` always generates 128 bits of entropy** (12 mnemonic words) and
|
||||||
|
truncates to `word_count` (`generators.rs:82-89`). This is because
|
||||||
|
`bip39 v2` rejects entropy below 128 bits, but we want to support 3–12 word
|
||||||
|
passphrases. Truncation preserves the per-word independence — the words
|
||||||
|
the user sees still come from a uniformly-sampled-then-truncated 12-word
|
||||||
|
draw.
|
||||||
|
- **Steam TOTP output is exactly 5 characters from a 26-glyph alphabet,
|
||||||
|
regardless of the `digits` field on `TotpConfig`** (`item_types/totp.rs:103-110`,
|
||||||
|
asserted in `item_types/totp.rs:240-253`). The alphabet
|
||||||
|
(`23456789BCDFGHJKMNPQRTVWXY`) excludes `0/O`, `1/I/L`, `S` (so `5` is
|
||||||
|
unambiguous), `A`, `E`, `U`, `Z` — all glyphs Valve considered ambiguous
|
||||||
|
in the Steam Mobile Authenticator. Verified at
|
||||||
|
`item_types/totp.rs:274-283`.
|
||||||
|
- **`ItemCore` is internally-tagged with `#[serde(tag = "type")]`** — the
|
||||||
|
outer JSON object gets a `"type"` key. This means *no* `*Core` struct may
|
||||||
|
have a field literally named `type`. The convention chosen for
|
||||||
|
type-discriminant fields *inside* a core is `kind` — see `CardKind`,
|
||||||
|
`TotpKind` (`item_types/mod.rs:38-40`).
|
||||||
|
- **The TOTP base32 in field-history strips padding.** `base32_encode`
|
||||||
|
(`item.rs:256-275`) is RFC-4648 with no `=` padding — appropriate because
|
||||||
|
the value is for human display in history, not for re-decoding.
|
||||||
|
- **`AttachmentId::from_plaintext` uses only the first 8 bytes (= 16 hex
|
||||||
|
chars) of the SHA-256 digest** (`ids.rs:51-57`). 64 bits of collision
|
||||||
|
resistance is sufficient for a personal-vault attachment count; it keeps
|
||||||
|
filenames short. If a future use case demands collision resistance against
|
||||||
|
motivated adversaries (e.g. dedup across untrusted vaults), this width is
|
||||||
|
the lever.
|
||||||
|
- **`Field::new` derives `kind` from `value`, but the public struct still
|
||||||
|
stores both** (`item.rs:73-94`). The duplication exists so callers can
|
||||||
|
match on `kind` without inspecting (and potentially decrypting / cloning)
|
||||||
|
`value`. `validate()` is the safety net that runs after deserialization.
|
||||||
|
- **`set_field_value` refuses to change a field's kind** (`item.rs:184-189`).
|
||||||
|
The intent is that fields are conceptually fixed-shape after creation;
|
||||||
|
changing a `Text` to a `Password` should be done by deleting the old field
|
||||||
|
and creating a new one (so history doesn't get confused).
|
||||||
|
- **`hidden_by_default` is *not* `Zeroize`.** It's purely a UI hint — the
|
||||||
|
rendering layer (CLI output, popup card) decides whether to mask the value
|
||||||
|
on initial display. Secrecy at rest is enforced by the `Zeroizing` wrappers
|
||||||
|
on the value itself, not this flag.
|
||||||
|
- **`Manifest::upsert` rebuilds the entry from scratch every call**
|
||||||
|
(`manifest.rs:45-48`, `manifest.rs:75-89`). There is no "patch the
|
||||||
|
existing entry" path. This means the manifest can never carry a stale
|
||||||
|
`icon_hint` or `attachment_summaries` — they are derived freshly from the
|
||||||
|
source `Item` each time.
|
||||||
|
- **The strength gate is *not* called inside `derive_master_key`.** It must
|
||||||
|
be invoked separately by the caller during *vault creation* only — not
|
||||||
|
during unlock, where calling it would let an attacker probe whether a
|
||||||
|
wrong passphrase happens to be "strong enough" before the Argon2id work
|
||||||
|
even starts. See `generators.rs:124-130`.
|
||||||
|
- **`now_unix()` is `chrono::Utc::now().timestamp()` and is the single time
|
||||||
|
source in this crate** (`time.rs:6-8`). Tests that need determinism pass an
|
||||||
|
explicit `now: i64` to `prune_history` (`item.rs:219`) and similar — they
|
||||||
|
do not stub `now_unix`.
|
||||||
36
crates/relicario-core/Cargo.toml
Normal file
36
crates/relicario-core/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[package]
|
||||||
|
name = "relicario-core"
|
||||||
|
version = "0.5.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Core library for relicario password manager"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
|
sha2 = "0.10"
|
||||||
|
sha1 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
|
|
||||||
|
# Typed-item additions
|
||||||
|
zeroize = { version = "1", features = ["zeroize_derive", "serde"] }
|
||||||
|
zxcvbn = { version = "3", default-features = false }
|
||||||
|
bip39 = { version = "2", default-features = false, features = ["std"] }
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "wasmbind"] }
|
||||||
|
hex = "0.4"
|
||||||
|
url = { version = "2", features = ["serde"] }
|
||||||
|
getrandom = "0.2"
|
||||||
|
zstd = { version = "0.13", default-features = false }
|
||||||
|
tar = { version = "0.4", default-features = false }
|
||||||
|
base64 = "0.22"
|
||||||
|
csv = "1"
|
||||||
|
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
166
crates/relicario-core/src/attachment.rs
Normal file
166
crates/relicario-core/src/attachment.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
//! Attachment refs (carried on Item) and summaries (carried in Manifest).
|
||||||
|
//!
|
||||||
|
//! Encryption helpers (`encrypt_attachment`, `decrypt_attachment`) are added
|
||||||
|
//! later in Task 22 once the crypto module is settled.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::ids::AttachmentId;
|
||||||
|
|
||||||
|
/// Reference to an attachment, carried on the Item record.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AttachmentRef {
|
||||||
|
pub id: AttachmentId,
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
/// Plaintext size in bytes.
|
||||||
|
pub size: u64,
|
||||||
|
/// Unix-seconds when this attachment was added.
|
||||||
|
pub created: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact summary of an attachment, carried in the Manifest so the popup
|
||||||
|
/// can show attachment indicators without decrypting the item file.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AttachmentSummary {
|
||||||
|
pub id: AttachmentId,
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&AttachmentRef> for AttachmentSummary {
|
||||||
|
fn from(r: &AttachmentRef) -> Self {
|
||||||
|
Self {
|
||||||
|
id: r.id.clone(),
|
||||||
|
filename: r.filename.clone(),
|
||||||
|
mime_type: r.mime_type.clone(),
|
||||||
|
size: r.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::crypto::{decrypt, encrypt};
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// Encrypted attachment with the AID derived from plaintext content.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct EncryptedAttachment {
|
||||||
|
pub id: AttachmentId,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`.
|
||||||
|
///
|
||||||
|
/// Returns [`RelicarioError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`,
|
||||||
|
/// before any crypto work is done.
|
||||||
|
///
|
||||||
|
/// ## Call-site adaptation
|
||||||
|
///
|
||||||
|
/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via
|
||||||
|
/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`).
|
||||||
|
pub fn encrypt_attachment(
|
||||||
|
plaintext: &[u8],
|
||||||
|
master_key: &Zeroizing<[u8; 32]>,
|
||||||
|
max_bytes: u64,
|
||||||
|
) -> Result<EncryptedAttachment> {
|
||||||
|
if plaintext.len() as u64 > max_bytes {
|
||||||
|
return Err(RelicarioError::AttachmentTooLarge {
|
||||||
|
size: plaintext.len() as u64,
|
||||||
|
max: max_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let id = AttachmentId::from_plaintext(plaintext);
|
||||||
|
let bytes = encrypt(master_key, plaintext)?;
|
||||||
|
Ok(EncryptedAttachment { id, bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext
|
||||||
|
/// wrapped in [`Zeroizing`] so it is wiped on drop.
|
||||||
|
///
|
||||||
|
/// ## Call-site adaptation
|
||||||
|
///
|
||||||
|
/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`.
|
||||||
|
pub fn decrypt_attachment(
|
||||||
|
encrypted: &[u8],
|
||||||
|
master_key: &Zeroizing<[u8; 32]>,
|
||||||
|
) -> Result<Zeroizing<Vec<u8>>> {
|
||||||
|
let plaintext = decrypt(master_key, encrypted)?;
|
||||||
|
Ok(Zeroizing::new(plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod crypto_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn key() -> Zeroizing<[u8; 32]> {
|
||||||
|
Zeroizing::new([0x42u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_round_trip() {
|
||||||
|
let plaintext = b"the quick brown fox jumps over the lazy dog";
|
||||||
|
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||||
|
let dec = decrypt_attachment(&enc.bytes, &key()).unwrap();
|
||||||
|
assert_eq!(dec.as_slice(), plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_matches_sha256() {
|
||||||
|
let plaintext = b"hello world";
|
||||||
|
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||||
|
assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversize_attachment_rejected() {
|
||||||
|
let plaintext = vec![0u8; 11_000_000];
|
||||||
|
let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024);
|
||||||
|
assert!(matches!(err, Err(RelicarioError::AttachmentTooLarge { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_fails_with_opaque_decrypt() {
|
||||||
|
let plaintext = b"x";
|
||||||
|
let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap();
|
||||||
|
let wrong = Zeroizing::new([0u8; 32]);
|
||||||
|
let err = decrypt_attachment(&enc.bytes, &wrong);
|
||||||
|
assert!(matches!(err, Err(RelicarioError::Decrypt)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_ref_round_trip() {
|
||||||
|
let r = AttachmentRef {
|
||||||
|
id: AttachmentId("0123456789abcdef".into()),
|
||||||
|
filename: "doc.pdf".into(),
|
||||||
|
mime_type: "application/pdf".into(),
|
||||||
|
size: 12345,
|
||||||
|
created: 1_700_000_000,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&r).unwrap();
|
||||||
|
let parsed: AttachmentRef = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.filename, "doc.pdf");
|
||||||
|
assert_eq!(parsed.size, 12345);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_summary_from_ref() {
|
||||||
|
let r = AttachmentRef {
|
||||||
|
id: AttachmentId("aabb".into()),
|
||||||
|
filename: "x.txt".into(),
|
||||||
|
mime_type: "text/plain".into(),
|
||||||
|
size: 5,
|
||||||
|
created: 0,
|
||||||
|
};
|
||||||
|
let s: AttachmentSummary = (&r).into();
|
||||||
|
assert_eq!(s.filename, "x.txt");
|
||||||
|
assert_eq!(s.id, r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
348
crates/relicario-core/src/backup.rs
Normal file
348
crates/relicario-core/src/backup.rs
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
//! Backup container — encrypted, compressed, single-file archive of a vault.
|
||||||
|
//!
|
||||||
|
//! ## Format (v1)
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [magic "RBAK" 4 bytes][version 0x01 1 byte][salt 32 bytes][nonce 24 bytes][ciphertext+tag]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! After AEAD decryption, the plaintext is zstd-compressed bytes whose
|
||||||
|
//! decompressed form is a UTF-8 JSON document — see [`Envelope`].
|
||||||
|
//!
|
||||||
|
//! The backup container key is **independent** of any vault master key.
|
||||||
|
//! The user picks a backup passphrase at export and types it at restore.
|
||||||
|
//! Argon2id parameters are pinned to v1-of-this-format (m=64MiB, t=3, p=4)
|
||||||
|
//! so a v1 reader does not need to negotiate them.
|
||||||
|
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use base64::Engine;
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
XChaCha20Poly1305, XNonce,
|
||||||
|
};
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// File-level magic. Four bytes so a `file(1)` rule can identify it.
|
||||||
|
pub const MAGIC: [u8; 4] = *b"RBAK";
|
||||||
|
|
||||||
|
/// Container format version. Bumped if the on-disk layout of the
|
||||||
|
/// salt/nonce/ciphertext header or the AEAD primitive changes.
|
||||||
|
pub const FORMAT_VERSION: u8 = 0x01;
|
||||||
|
|
||||||
|
/// JSON envelope schema version. Bumped if the JSON shape changes
|
||||||
|
/// without an underlying-format change (e.g. new optional fields whose
|
||||||
|
/// absence v1 readers can tolerate would NOT bump this; renames or
|
||||||
|
/// removals would).
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
const SALT_LEN: usize = 32;
|
||||||
|
const NONCE_LEN: usize = 24;
|
||||||
|
const TAG_LEN: usize = 16;
|
||||||
|
const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // magic + version + salt + nonce
|
||||||
|
|
||||||
|
const ARGON2_M_KIB: u32 = 65_536; // 64 MiB
|
||||||
|
const ARGON2_T: u32 = 3;
|
||||||
|
const ARGON2_P: u32 = 4;
|
||||||
|
|
||||||
|
/// Zstd compression level. 3 is the speed/size sweet spot.
|
||||||
|
const ZSTD_LEVEL: i32 = 3;
|
||||||
|
|
||||||
|
/// Inputs to [`pack_backup`]. Borrow-only — the caller retains ownership of
|
||||||
|
/// every byte slice.
|
||||||
|
pub struct BackupInput<'a> {
|
||||||
|
/// Raw 32-byte vault salt (`.relicario/salt` contents).
|
||||||
|
pub salt: &'a [u8],
|
||||||
|
/// Verbatim string contents of `.relicario/params.json`.
|
||||||
|
pub params_json: &'a str,
|
||||||
|
/// Verbatim string contents of `.relicario/devices.json`.
|
||||||
|
pub devices_json: &'a str,
|
||||||
|
/// Encrypted manifest bytes (verbatim `manifest.enc`).
|
||||||
|
pub manifest_enc: &'a [u8],
|
||||||
|
/// Encrypted vault settings bytes (verbatim `settings.enc`).
|
||||||
|
pub settings_enc: &'a [u8],
|
||||||
|
/// One entry per item file (verbatim ciphertext).
|
||||||
|
pub items: Vec<BackupItem<'a>>,
|
||||||
|
/// One entry per attachment blob (verbatim ciphertext).
|
||||||
|
pub attachments: Vec<BackupAttachment<'a>>,
|
||||||
|
/// Reference JPEG bytes — included iff caller wants to bundle the
|
||||||
|
/// second factor.
|
||||||
|
pub reference_jpg: Option<&'a [u8]>,
|
||||||
|
/// Tarred `.git/` directory — included iff caller wants the audit log.
|
||||||
|
/// The caller (CLI) does the actual tarring; core just transports the
|
||||||
|
/// opaque bytes.
|
||||||
|
pub git_archive: Option<&'a [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One vault item ciphertext, keyed by the item id (16-char hex).
|
||||||
|
pub struct BackupItem<'a> {
|
||||||
|
pub id: String,
|
||||||
|
pub ciphertext: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One attachment blob, keyed by `<item_id>/<attachment_id>` so the
|
||||||
|
/// per-item directory layout round-trips.
|
||||||
|
pub struct BackupAttachment<'a> {
|
||||||
|
pub item_id: String,
|
||||||
|
pub attachment_id: String,
|
||||||
|
pub ciphertext: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of [`unpack_backup`]. Owned bytes — the caller decides where to
|
||||||
|
/// persist them.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BackupOutput {
|
||||||
|
pub salt: [u8; 32],
|
||||||
|
pub params_json: String,
|
||||||
|
pub devices_json: String,
|
||||||
|
pub manifest_enc: Vec<u8>,
|
||||||
|
pub settings_enc: Vec<u8>,
|
||||||
|
pub items: Vec<UnpackedItem>,
|
||||||
|
pub attachments: Vec<UnpackedAttachment>,
|
||||||
|
pub reference_jpg: Option<Vec<u8>>,
|
||||||
|
pub git_archive: Option<Vec<u8>>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UnpackedItem {
|
||||||
|
pub id: String,
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct UnpackedAttachment {
|
||||||
|
pub item_id: String,
|
||||||
|
pub attachment_id: String,
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Envelope {
|
||||||
|
schema_version: u32,
|
||||||
|
created_at: i64,
|
||||||
|
vault: VaultEnvelope,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct VaultEnvelope {
|
||||||
|
/// base64-encoded 32-byte vault salt.
|
||||||
|
salt: String,
|
||||||
|
/// Verbatim params.json contents (string, not nested object — keeps
|
||||||
|
/// forward-compat with future params.json schema changes opaque to
|
||||||
|
/// the backup format).
|
||||||
|
params: String,
|
||||||
|
/// Verbatim devices.json contents (string for the same reason).
|
||||||
|
devices: String,
|
||||||
|
/// base64-encoded ciphertext of `manifest.enc`.
|
||||||
|
manifest: String,
|
||||||
|
/// base64-encoded ciphertext of `settings.enc`.
|
||||||
|
settings: String,
|
||||||
|
/// Map of `item_id` → base64-encoded item ciphertext.
|
||||||
|
items: std::collections::BTreeMap<String, String>,
|
||||||
|
/// Map of `<item_id>/<attachment_id>` → base64-encoded ciphertext.
|
||||||
|
attachments: std::collections::BTreeMap<String, String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
reference_jpg: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
git_archive: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pack a vault into the `.relbak` container.
|
||||||
|
///
|
||||||
|
/// Generates fresh 32-byte salt + 24-byte nonce via OsRng. Derives a
|
||||||
|
/// 32-byte key via Argon2id with the format-pinned parameters, then
|
||||||
|
/// XChaCha20-Poly1305 encrypts the zstd-compressed JSON envelope.
|
||||||
|
pub fn pack_backup(input: BackupInput<'_>, passphrase: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
OsRng.fill_bytes(&mut salt);
|
||||||
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
|
|
||||||
|
let key = derive_backup_key(passphrase.as_bytes(), &salt)?;
|
||||||
|
|
||||||
|
let envelope = build_envelope(input, crate::time::now_unix())?;
|
||||||
|
let json = serde_json::to_vec(&envelope)?;
|
||||||
|
|
||||||
|
let compressed = zstd::encode_all(&json[..], ZSTD_LEVEL)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("zstd compress: {e}")))?;
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new((&*key).into());
|
||||||
|
let nonce = XNonce::from(nonce_bytes);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, compressed.as_slice())
|
||||||
|
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||||
|
out.extend_from_slice(&MAGIC);
|
||||||
|
out.push(FORMAT_VERSION);
|
||||||
|
out.extend_from_slice(&salt);
|
||||||
|
out.extend_from_slice(&nonce_bytes);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack a `.relbak` container, verifying magic + version, decrypting,
|
||||||
|
/// decompressing, and parsing the JSON envelope.
|
||||||
|
pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
|
||||||
|
if data.len() < HEADER_LEN + TAG_LEN {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
"backup file truncated".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if data[0..4] != MAGIC {
|
||||||
|
return Err(RelicarioError::BackupBadMagic);
|
||||||
|
}
|
||||||
|
let version = data[4];
|
||||||
|
if version != FORMAT_VERSION {
|
||||||
|
return Err(RelicarioError::BackupUnsupportedVersion {
|
||||||
|
found: version,
|
||||||
|
expected: FORMAT_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
salt.copy_from_slice(&data[5..5 + SALT_LEN]);
|
||||||
|
let nonce_start = 5 + SALT_LEN;
|
||||||
|
let nonce_bytes: &[u8] = &data[nonce_start..nonce_start + NONCE_LEN];
|
||||||
|
let ciphertext = &data[HEADER_LEN..];
|
||||||
|
|
||||||
|
let key = derive_backup_key(passphrase.as_bytes(), &salt)?;
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new((&*key).into());
|
||||||
|
let nonce = XNonce::from_slice(nonce_bytes);
|
||||||
|
let compressed = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| RelicarioError::Decrypt)?;
|
||||||
|
|
||||||
|
let json_bytes = zstd::decode_all(compressed.as_slice())
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("zstd decompress: {e}")))?;
|
||||||
|
|
||||||
|
let env: Envelope = serde_json::from_slice(&json_bytes)?;
|
||||||
|
if env.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(RelicarioError::BackupSchemaMismatch {
|
||||||
|
found: env.schema_version,
|
||||||
|
expected: SCHEMA_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let mut salt_out = [0u8; 32];
|
||||||
|
let salt_decoded = b64
|
||||||
|
.decode(&env.vault.salt)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 salt: {e}")))?;
|
||||||
|
if salt_decoded.len() != 32 {
|
||||||
|
return Err(RelicarioError::Format(format!(
|
||||||
|
"salt length: expected 32, got {}",
|
||||||
|
salt_decoded.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
salt_out.copy_from_slice(&salt_decoded);
|
||||||
|
|
||||||
|
let manifest_enc = b64
|
||||||
|
.decode(&env.vault.manifest)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 manifest: {e}")))?;
|
||||||
|
let settings_enc = b64
|
||||||
|
.decode(&env.vault.settings)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 settings: {e}")))?;
|
||||||
|
|
||||||
|
let mut items = Vec::with_capacity(env.vault.items.len());
|
||||||
|
for (id, b64_ct) in env.vault.items {
|
||||||
|
let ct = b64
|
||||||
|
.decode(&b64_ct)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 item {id}: {e}")))?;
|
||||||
|
items.push(UnpackedItem { id, ciphertext: ct });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut attachments = Vec::with_capacity(env.vault.attachments.len());
|
||||||
|
for (combined, b64_ct) in env.vault.attachments {
|
||||||
|
let (item_id, attachment_id) = combined
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(a, b)| (a.to_string(), b.to_string()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
RelicarioError::Format(format!("bad attachment key '{combined}'"))
|
||||||
|
})?;
|
||||||
|
let ct = b64
|
||||||
|
.decode(&b64_ct)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 attachment {combined}: {e}")))?;
|
||||||
|
attachments.push(UnpackedAttachment { item_id, attachment_id, ciphertext: ct });
|
||||||
|
}
|
||||||
|
|
||||||
|
let reference_jpg = env
|
||||||
|
.vault
|
||||||
|
.reference_jpg
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 reference_jpg: {e}")))?;
|
||||||
|
let git_archive = env
|
||||||
|
.vault
|
||||||
|
.git_archive
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("base64 git_archive: {e}")))?;
|
||||||
|
|
||||||
|
Ok(BackupOutput {
|
||||||
|
salt: salt_out,
|
||||||
|
params_json: env.vault.params,
|
||||||
|
devices_json: env.vault.devices,
|
||||||
|
manifest_enc,
|
||||||
|
settings_enc,
|
||||||
|
items,
|
||||||
|
attachments,
|
||||||
|
reference_jpg,
|
||||||
|
git_archive,
|
||||||
|
created_at: env.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
|
// NFC normalize passphrase (matches derive_master_key in crypto.rs)
|
||||||
|
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
||||||
|
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
||||||
|
Err(_) => passphrase.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
|
||||||
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
|
||||||
|
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
let mut key = Zeroizing::new([0u8; 32]);
|
||||||
|
argon
|
||||||
|
.hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
|
||||||
|
.map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_envelope(input: BackupInput<'_>, created_at: i64) -> Result<Envelope> {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let mut items = std::collections::BTreeMap::new();
|
||||||
|
for it in input.items {
|
||||||
|
items.insert(it.id, b64.encode(it.ciphertext));
|
||||||
|
}
|
||||||
|
let mut attachments = std::collections::BTreeMap::new();
|
||||||
|
for a in input.attachments {
|
||||||
|
let key = format!("{}/{}", a.item_id, a.attachment_id);
|
||||||
|
attachments.insert(key, b64.encode(a.ciphertext));
|
||||||
|
}
|
||||||
|
Ok(Envelope {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
created_at,
|
||||||
|
vault: VaultEnvelope {
|
||||||
|
salt: b64.encode(input.salt),
|
||||||
|
params: input.params_json.to_string(),
|
||||||
|
devices: input.devices_json.to_string(),
|
||||||
|
manifest: b64.encode(input.manifest_enc),
|
||||||
|
settings: b64.encode(input.settings_enc),
|
||||||
|
items,
|
||||||
|
attachments,
|
||||||
|
reference_jpg: input.reference_jpg.map(|b| b64.encode(b)),
|
||||||
|
git_archive: input.git_archive.map(|b| b64.encode(b)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
437
crates/relicario-core/src/crypto.rs
Normal file
437
crates/relicario-core/src/crypto.rs
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
//! Argon2id key derivation and XChaCha20-Poly1305 authenticated encryption.
|
||||||
|
//!
|
||||||
|
//! This module implements the low-level "encrypt bytes / decrypt bytes" layer.
|
||||||
|
//! Higher-level typed wrappers (encrypt_entry, encrypt_manifest) live in [`crate::vault`].
|
||||||
|
//!
|
||||||
|
//! ## Why XChaCha20-Poly1305 over AES-GCM
|
||||||
|
//!
|
||||||
|
//! - **192-bit nonce** (vs. 96-bit for AES-GCM): eliminates nonce collision risk
|
||||||
|
//! even with random nonces across billions of encryptions. With AES-GCM's 96-bit
|
||||||
|
//! nonce, birthday-bound collisions become probable around 2^48 messages under
|
||||||
|
//! the same key -- a real concern for a long-lived vault.
|
||||||
|
//! - **Fast on WASM and ARM without AES-NI**: ChaCha20 is a pure arithmetic cipher
|
||||||
|
//! (add/rotate/XOR) with no dependency on hardware AES acceleration. AES-GCM is
|
||||||
|
//! fast *only* with AES-NI; without it, software AES is both slow and vulnerable
|
||||||
|
//! to cache-timing side channels.
|
||||||
|
//!
|
||||||
|
//! ## Binary ciphertext format
|
||||||
|
//!
|
||||||
|
//! Every encrypted blob produced by [`encrypt`] has this layout:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! [version: 1 byte] [nonce: 24 bytes] [ciphertext + Poly1305 tag: variable]
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! - **Version byte** (`0x02`): allows future format changes without ambiguity.
|
||||||
|
//! Decryption rejects any version it does not recognize.
|
||||||
|
//! - **Nonce** (24 bytes): randomly generated per encryption via [`OsRng`].
|
||||||
|
//! Stored alongside the ciphertext so the decryptor does not need out-of-band
|
||||||
|
//! nonce management.
|
||||||
|
//! - **Ciphertext + tag**: the AEAD output. The Poly1305 tag (16 bytes) is
|
||||||
|
//! appended by the cipher implementation; we do not separate it.
|
||||||
|
//!
|
||||||
|
//! ## KDF pipeline
|
||||||
|
//!
|
||||||
|
//! [`derive_master_key`] concatenates the passphrase and image_secret as a single
|
||||||
|
//! password input to Argon2id:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! password = passphrase_bytes || image_secret (32 bytes)
|
||||||
|
//! master_key = Argon2id(password, salt, params) -> 32 bytes
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Both factors contribute to the derived key -- compromising one without the
|
||||||
|
//! other is insufficient. The salt is vault-specific and stored in `.relicario/salt`.
|
||||||
|
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use chacha20poly1305::{
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
XChaCha20Poly1305, XNonce,
|
||||||
|
};
|
||||||
|
use rand::{rngs::OsRng, RngCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// Current binary format version. Increment this if the ciphertext layout changes.
|
||||||
|
pub const VERSION_BYTE: u8 = 0x02;
|
||||||
|
|
||||||
|
/// XChaCha20-Poly1305 nonce length: 192 bits = 24 bytes.
|
||||||
|
const NONCE_LEN: usize = 24;
|
||||||
|
|
||||||
|
/// Poly1305 authentication tag length: 128 bits = 16 bytes.
|
||||||
|
/// Used only for minimum-length validation during decryption.
|
||||||
|
const TAG_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// Total header size: version byte + nonce. The ciphertext (including tag)
|
||||||
|
/// follows immediately after the header.
|
||||||
|
const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce
|
||||||
|
|
||||||
|
/// Encrypt arbitrary plaintext bytes under a 256-bit key using XChaCha20-Poly1305.
|
||||||
|
///
|
||||||
|
/// Returns the binary blob in the format: `version(1) || nonce(24) || ciphertext+tag`.
|
||||||
|
/// A fresh random nonce is generated for each call via the OS CSPRNG.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RelicarioError::Encrypt`] if the underlying AEAD operation fails
|
||||||
|
/// (extremely unlikely in practice).
|
||||||
|
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let cipher = XChaCha20Poly1305::new(key.into());
|
||||||
|
|
||||||
|
// Generate a fresh random 24-byte nonce for every encryption.
|
||||||
|
// With 192 bits of randomness, nonce reuse probability is negligible
|
||||||
|
// even across billions of encryptions under the same key.
|
||||||
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
|
OsRng.fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = XNonce::from(nonce_bytes);
|
||||||
|
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(&nonce, plaintext)
|
||||||
|
.map_err(|e| RelicarioError::Encrypt(e.to_string()))?;
|
||||||
|
|
||||||
|
// Output: version(1) || nonce(24) || ciphertext+tag
|
||||||
|
let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||||
|
output.push(VERSION_BYTE);
|
||||||
|
output.extend_from_slice(&nonce_bytes);
|
||||||
|
output.extend_from_slice(&ciphertext);
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a blob produced by [`encrypt`], returning the original plaintext.
|
||||||
|
///
|
||||||
|
/// Validates the version byte and minimum blob length before attempting
|
||||||
|
/// authenticated decryption. If the key is wrong or the data has been
|
||||||
|
/// tampered with, the Poly1305 tag verification fails and [`RelicarioError::Decrypt`]
|
||||||
|
/// is returned -- with no information about which bytes were wrong (preventing
|
||||||
|
/// padding oracle / chosen-ciphertext attacks).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RelicarioError::Format`] if the data is too short or has an unknown version byte.
|
||||||
|
/// - [`RelicarioError::Decrypt`] if the AEAD tag verification fails (wrong key or
|
||||||
|
/// tampered data).
|
||||||
|
pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
// Minimum valid blob: 1 (version) + 24 (nonce) + 16 (tag) = 41 bytes.
|
||||||
|
// A zero-length plaintext produces exactly 41 bytes of output.
|
||||||
|
if data.len() < HEADER_LEN + TAG_LEN {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
"data too short to be valid ciphertext".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let found = data[0];
|
||||||
|
if found != VERSION_BYTE {
|
||||||
|
return Err(RelicarioError::UnsupportedFormatVersion {
|
||||||
|
found,
|
||||||
|
expected: VERSION_BYTE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]);
|
||||||
|
let ciphertext = &data[HEADER_LEN..];
|
||||||
|
|
||||||
|
let cipher = XChaCha20Poly1305::new(key.into());
|
||||||
|
let plaintext = cipher
|
||||||
|
.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| RelicarioError::Decrypt)?;
|
||||||
|
|
||||||
|
Ok(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tunable parameters for the Argon2id key derivation function.
|
||||||
|
///
|
||||||
|
/// These are stored in the vault's `.relicario/params.json` so that every client
|
||||||
|
/// derives the same master key from the same inputs. Making them configurable
|
||||||
|
/// lets tests use fast params (m=256, t=1, p=1) while production uses strong
|
||||||
|
/// params (m=64MiB, t=3, p=4).
|
||||||
|
///
|
||||||
|
/// The parameters follow Argon2id naming conventions:
|
||||||
|
/// - `argon2_m`: memory cost in KiB
|
||||||
|
/// - `argon2_t`: time cost (number of iterations)
|
||||||
|
/// - `argon2_p`: parallelism degree (number of lanes)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KdfParams {
|
||||||
|
/// Memory cost in KiB. Default is 65536 (64 MiB), which makes GPU/ASIC
|
||||||
|
/// brute-force attacks expensive. Tests use 256 KiB for speed.
|
||||||
|
pub argon2_m: u32,
|
||||||
|
/// Time cost (iteration count). Default is 3. Higher values increase CPU
|
||||||
|
/// time linearly. Combined with high memory cost, this makes each key
|
||||||
|
/// derivation take ~1 second on modern hardware.
|
||||||
|
pub argon2_t: u32,
|
||||||
|
/// Parallelism degree. Default is 4. Sets the number of independent lanes
|
||||||
|
/// in the Argon2id memory-hard computation.
|
||||||
|
pub argon2_p: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production-strength default parameters: 64 MiB memory, 3 iterations, 4 lanes.
|
||||||
|
///
|
||||||
|
/// These are calibrated to take roughly 0.5-1 second on a modern desktop CPU,
|
||||||
|
/// making brute-force attacks impractical while keeping interactive unlock fast
|
||||||
|
/// enough for daily use.
|
||||||
|
impl Default for KdfParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
argon2_m: 65536,
|
||||||
|
argon2_t: 3,
|
||||||
|
argon2_p: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a 256-bit master key from the user's passphrase and reference image secret.
|
||||||
|
///
|
||||||
|
/// The two factors (passphrase + image_secret) are concatenated into a single
|
||||||
|
/// password input to Argon2id. This means both factors contribute entropy to
|
||||||
|
/// the derived key -- compromising one factor alone is insufficient.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// - `passphrase`: the user's passphrase as raw UTF-8 bytes.
|
||||||
|
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG via
|
||||||
|
/// [`crate::imgsecret::extract`].
|
||||||
|
/// - `salt`: a 32-byte vault-specific salt (stored in `.relicario/salt`).
|
||||||
|
/// - `params`: the Argon2id tuning parameters (stored in `.relicario/params.json`).
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// A 32-byte master key suitable for use with [`encrypt`] and [`decrypt`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RelicarioError::Kdf`] if the Argon2id parameters are invalid (e.g.,
|
||||||
|
/// memory cost below the library's minimum).
|
||||||
|
pub fn derive_master_key(
|
||||||
|
passphrase: &[u8],
|
||||||
|
image_secret: &[u8; 32],
|
||||||
|
salt: &[u8; 32],
|
||||||
|
params: &KdfParams,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let argon2_params = Params::new(
|
||||||
|
params.argon2_m,
|
||||||
|
params.argon2_t,
|
||||||
|
params.argon2_p,
|
||||||
|
Some(32),
|
||||||
|
)
|
||||||
|
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||||
|
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||||
|
|
||||||
|
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
|
||||||
|
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
||||||
|
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
||||||
|
Err(_) => passphrase.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
|
||||||
|
// [u64_be(32)][image_secret]
|
||||||
|
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
|
||||||
|
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
|
||||||
|
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
|
||||||
|
password.extend_from_slice(&nfc_passphrase);
|
||||||
|
password.extend_from_slice(&32u64.to_be_bytes());
|
||||||
|
password.extend_from_slice(image_secret);
|
||||||
|
|
||||||
|
let mut output = Zeroizing::new([0u8; 32]);
|
||||||
|
argon2
|
||||||
|
.hash_password_into(password.as_slice(), salt, output.as_mut())
|
||||||
|
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `derive_master_key` but takes an already-assembled `input` byte slice directly,
|
||||||
|
/// allowing callers to apply their own domain separation before KDF.
|
||||||
|
pub fn derive_master_key_raw(
|
||||||
|
input: &[u8],
|
||||||
|
salt: &[u8; 32],
|
||||||
|
params: &KdfParams,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let argon2_params = Params::new(params.argon2_m, params.argon2_t, params.argon2_p, Some(32))
|
||||||
|
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
|
||||||
|
let mut output = Zeroizing::new([0u8; 32]);
|
||||||
|
argon2
|
||||||
|
.hash_password_into(input, salt, output.as_mut())
|
||||||
|
.map_err(|e| RelicarioError::Kdf(e.to_string()))?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fast_params() -> KdfParams {
|
||||||
|
KdfParams {
|
||||||
|
argon2_m: 256,
|
||||||
|
argon2_t: 1,
|
||||||
|
argon2_p: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_master_key_deterministic() {
|
||||||
|
let passphrase = b"test-passphrase";
|
||||||
|
let image_secret = [0x42u8; 32];
|
||||||
|
let salt = [0x01u8; 32];
|
||||||
|
let params = fast_params();
|
||||||
|
|
||||||
|
let key1 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||||||
|
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*key1, *key2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_master_key_different_passphrase() {
|
||||||
|
let image_secret = [0x42u8; 32];
|
||||||
|
let salt = [0x01u8; 32];
|
||||||
|
let params = fast_params();
|
||||||
|
|
||||||
|
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, ¶ms).unwrap();
|
||||||
|
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, ¶ms).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(*key1, *key2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_master_key_different_image_secret() {
|
||||||
|
let passphrase = b"test-passphrase";
|
||||||
|
let salt = [0x01u8; 32];
|
||||||
|
let params = fast_params();
|
||||||
|
|
||||||
|
let image_secret1 = [0x11u8; 32];
|
||||||
|
let image_secret2 = [0x22u8; 32];
|
||||||
|
|
||||||
|
let key1 = derive_master_key(passphrase, &image_secret1, &salt, ¶ms).unwrap();
|
||||||
|
let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(*key1, *key2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_round_trip() {
|
||||||
|
let key = [0xABu8; 32];
|
||||||
|
let plaintext = b"hello, relicario!";
|
||||||
|
|
||||||
|
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||||
|
let decrypted = decrypt(&key, &ciphertext).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_wrong_key_fails() {
|
||||||
|
let key = [0xABu8; 32];
|
||||||
|
let wrong_key = [0xCDu8; 32];
|
||||||
|
let plaintext = b"sensitive data";
|
||||||
|
|
||||||
|
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||||
|
let result = decrypt(&wrong_key, &ciphertext);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(matches!(result.unwrap_err(), RelicarioError::Decrypt));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_tampered_data_fails() {
|
||||||
|
let key = [0xABu8; 32];
|
||||||
|
let plaintext = b"sensitive data";
|
||||||
|
|
||||||
|
let mut ciphertext = encrypt(&key, plaintext).unwrap();
|
||||||
|
// Flip a byte in the ciphertext portion (after header)
|
||||||
|
let flip_pos = HEADER_LEN + 2;
|
||||||
|
ciphertext[flip_pos] ^= 0xFF;
|
||||||
|
|
||||||
|
let result = decrypt(&key, &ciphertext);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ciphertext_format_has_correct_structure() {
|
||||||
|
let key = [0x11u8; 32];
|
||||||
|
let plaintext = b"test plaintext for structure check";
|
||||||
|
|
||||||
|
let ciphertext = encrypt(&key, plaintext).unwrap();
|
||||||
|
|
||||||
|
// Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag)
|
||||||
|
let expected_len = 1 + 24 + plaintext.len() + 16;
|
||||||
|
assert_eq!(ciphertext.len(), expected_len);
|
||||||
|
|
||||||
|
// Version byte must be 0x02
|
||||||
|
assert_eq!(ciphertext[0], 0x02);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn length_prefix_eliminates_concatenation_ambiguity() {
|
||||||
|
// Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
|
||||||
|
// With length-prefix: distinct inputs always yield distinct keys.
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let params = fast_params();
|
||||||
|
|
||||||
|
// Pair A: passphrase "abc", image_secret starts with 0x44
|
||||||
|
let mut img_a = [0u8; 32]; img_a[0] = 0x44;
|
||||||
|
let key_a = derive_master_key(b"abc", &img_a, &salt, ¶ms).unwrap();
|
||||||
|
|
||||||
|
// Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
|
||||||
|
let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image
|
||||||
|
let key_b = derive_master_key(b"abcD", &img_b, &salt, ¶ms).unwrap();
|
||||||
|
|
||||||
|
// With length-prefix, the keys MUST differ.
|
||||||
|
assert_ne!(*key_a, *key_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nfc_normalization_collapses_unicode_forms() {
|
||||||
|
// "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
|
||||||
|
// Both must produce the same key after NFC normalization.
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let img = [0u8; 32];
|
||||||
|
let params = fast_params();
|
||||||
|
|
||||||
|
let nfc = "caf\u{00e9}".as_bytes(); // é precomposed
|
||||||
|
let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute
|
||||||
|
|
||||||
|
let key_nfc = derive_master_key(nfc, &img, &salt, ¶ms).unwrap();
|
||||||
|
let key_nfd = derive_master_key(nfd, &img, &salt, ¶ms).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(*key_nfc, *key_nfd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn master_key_is_zeroized_on_drop() {
|
||||||
|
// Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
|
||||||
|
// we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let img = [0u8; 32];
|
||||||
|
let params = fast_params();
|
||||||
|
let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, ¶ms).unwrap();
|
||||||
|
assert_eq!(key.len(), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_byte_is_0x02() {
|
||||||
|
assert_eq!(VERSION_BYTE, 0x02);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_rejects_v1_blob_with_typed_error() {
|
||||||
|
// Construct a v1-style blob: [0x01][24 nonce bytes][16 tag bytes].
|
||||||
|
let mut blob = vec![0x01u8];
|
||||||
|
blob.extend_from_slice(&[0u8; 24]);
|
||||||
|
blob.extend_from_slice(&[0u8; 16]);
|
||||||
|
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
|
||||||
|
match err {
|
||||||
|
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||||
|
assert_eq!(found, 0x01);
|
||||||
|
assert_eq!(expected, 0x02);
|
||||||
|
}
|
||||||
|
other => panic!("expected UnsupportedFormatVersion, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
crates/relicario-core/src/device.rs
Normal file
168
crates/relicario-core/src/device.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification.
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ssh_key::{LineEnding, PrivateKey, PublicKey};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// A registered device entry in devices.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceEntry {
|
||||||
|
pub name: String,
|
||||||
|
/// OpenSSH public key format: "ssh-ed25519 AAAA..."
|
||||||
|
pub public_key: String,
|
||||||
|
pub added_at: i64,
|
||||||
|
pub added_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A revoked device entry in revoked.json.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RevokedEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub revoked_at: i64,
|
||||||
|
pub revoked_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new ed25519 keypair, returning (private_openssh, public_openssh).
|
||||||
|
pub fn generate_keypair() -> Result<(Zeroizing<String>, String)> {
|
||||||
|
use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData};
|
||||||
|
use ssh_key::public::Ed25519PublicKey;
|
||||||
|
|
||||||
|
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
|
||||||
|
// Build ssh-key types from raw bytes
|
||||||
|
let ed_private = Ed25519PrivateKey::from_bytes(signing_key.as_bytes());
|
||||||
|
let ed_public = Ed25519PublicKey(*verifying_key.as_bytes());
|
||||||
|
let keypair = Ed25519Keypair { public: ed_public, private: ed_private };
|
||||||
|
let keypair_data = KeypairData::Ed25519(keypair);
|
||||||
|
|
||||||
|
let ssh_private = PrivateKey::new(keypair_data, "")
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("private key create: {e}")))?;
|
||||||
|
let ssh_public = ssh_private.public_key();
|
||||||
|
|
||||||
|
let private_pem = ssh_private
|
||||||
|
.to_openssh(LineEnding::LF)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?;
|
||||||
|
let public_line = ssh_public
|
||||||
|
.to_openssh()
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?;
|
||||||
|
|
||||||
|
Ok((Zeroizing::new(private_pem.to_string()), public_line))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign data with an OpenSSH private key, returning base64 signature.
|
||||||
|
pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let private = PrivateKey::from_openssh(private_key_openssh)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?;
|
||||||
|
|
||||||
|
let key_data = private
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
|
||||||
|
|
||||||
|
let secret_slice: &[u8] = key_data.private.as_ref();
|
||||||
|
let secret_bytes: [u8; 32] = secret_slice
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
|
||||||
|
|
||||||
|
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
||||||
|
let signature = signing_key.sign(data);
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a signature against an OpenSSH public key.
|
||||||
|
pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result<bool> {
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
let public = PublicKey::from_openssh(public_key_openssh)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||||
|
|
||||||
|
let key_data = public
|
||||||
|
.key_data()
|
||||||
|
.ed25519()
|
||||||
|
.ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?;
|
||||||
|
|
||||||
|
let pub_slice: &[u8] = key_data.as_ref();
|
||||||
|
let pub_bytes: [u8; 32] = pub_slice
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?;
|
||||||
|
|
||||||
|
let verifying_key = VerifyingKey::from_bytes(&pub_bytes)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("invalid public key: {e}")))?;
|
||||||
|
|
||||||
|
let sig_bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(signature_b64)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?;
|
||||||
|
|
||||||
|
let signature = Signature::from_slice(&sig_bytes)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?;
|
||||||
|
|
||||||
|
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
|
||||||
|
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
|
||||||
|
/// `SHA256:<43-char base64 without padding>`.
|
||||||
|
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
|
||||||
|
use ssh_key::HashAlg;
|
||||||
|
let public = PublicKey::from_openssh(public_key_openssh)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||||
|
Ok(public.fingerprint(HashAlg::Sha256).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_and_sign_verify_roundtrip() {
|
||||||
|
let (private, public) = generate_keypair().unwrap();
|
||||||
|
let data = b"hello world";
|
||||||
|
let sig = sign(&private, data).unwrap();
|
||||||
|
assert!(verify(&public, data, &sig).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_wrong_data() {
|
||||||
|
let (private, public) = generate_keypair().unwrap();
|
||||||
|
let sig = sign(&private, b"hello").unwrap();
|
||||||
|
assert!(!verify(&public, b"world", &sig).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_wrong_key() {
|
||||||
|
let (private, _) = generate_keypair().unwrap();
|
||||||
|
let (_, other_public) = generate_keypair().unwrap();
|
||||||
|
let sig = sign(&private, b"hello").unwrap();
|
||||||
|
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_matches_ssh_keygen_format() {
|
||||||
|
let (_, public) = generate_keypair().unwrap();
|
||||||
|
let fp = fingerprint(&public).unwrap();
|
||||||
|
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
|
||||||
|
let body = fp.strip_prefix("SHA256:").unwrap();
|
||||||
|
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
|
||||||
|
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_is_deterministic() {
|
||||||
|
let (_, public) = generate_keypair().unwrap();
|
||||||
|
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_differs_per_key() {
|
||||||
|
let (_, p1) = generate_keypair().unwrap();
|
||||||
|
let (_, p2) = generate_keypair().unwrap();
|
||||||
|
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
195
crates/relicario-core/src/error.rs
Normal file
195
crates/relicario-core/src/error.rs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
//! Unified error type for the Relicario core crate.
|
||||||
|
//!
|
||||||
|
//! Every fallible function in this crate returns [`Result<T>`], which is an alias
|
||||||
|
//! for `std::result::Result<T, RelicarioError>`. Using a single error enum keeps the
|
||||||
|
//! public API surface predictable and makes error handling in callers (CLI, WASM
|
||||||
|
//! bindings, mobile FFI) straightforward.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// All errors that can originate from Relicario core operations.
|
||||||
|
///
|
||||||
|
/// Variants are ordered roughly by the pipeline stage where they occur:
|
||||||
|
/// KDF -> encryption -> decryption -> format parsing -> item lookup -> image
|
||||||
|
/// steganography -> serialization -> device keys.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum RelicarioError {
|
||||||
|
/// The Argon2id key derivation failed. This typically means invalid KDF
|
||||||
|
/// parameters were supplied (e.g., memory cost below Argon2's minimum).
|
||||||
|
#[error("key derivation failed: {0}")]
|
||||||
|
Kdf(String),
|
||||||
|
|
||||||
|
/// XChaCha20-Poly1305 encryption failed. In practice this is extremely rare
|
||||||
|
/// -- the only realistic cause is an internal library error, since the cipher
|
||||||
|
/// accepts arbitrary-length plaintext.
|
||||||
|
#[error("encryption failed: {0}")]
|
||||||
|
Encrypt(String),
|
||||||
|
|
||||||
|
/// Authenticated decryption failed. Message intentionally opaque (audit M4).
|
||||||
|
#[error("decryption failed")]
|
||||||
|
Decrypt,
|
||||||
|
|
||||||
|
/// The binary ciphertext blob does not match the expected format (e.g.,
|
||||||
|
/// too short to contain the version byte + nonce + tag, or an unrecognized
|
||||||
|
/// version byte). This usually indicates file corruption or a version
|
||||||
|
/// mismatch between the writer and reader.
|
||||||
|
#[error("invalid vault format: {0}")]
|
||||||
|
Format(String),
|
||||||
|
|
||||||
|
#[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")]
|
||||||
|
UnsupportedFormatVersion { found: u8, expected: u8 },
|
||||||
|
|
||||||
|
/// Backup file's first 4 bytes don't match the "RBAK" magic.
|
||||||
|
#[error("not a Relicario backup file")]
|
||||||
|
BackupBadMagic,
|
||||||
|
|
||||||
|
/// Backup format version is newer than this binary supports.
|
||||||
|
#[error("backup created by a newer Relicario; upgrade required")]
|
||||||
|
BackupUnsupportedVersion { found: u8, expected: u8 },
|
||||||
|
|
||||||
|
/// Backup envelope schema version doesn't match.
|
||||||
|
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||||
|
BackupSchemaMismatch { found: u32, expected: u32 },
|
||||||
|
|
||||||
|
/// An error during backup restore (e.g., tar safety validation failure).
|
||||||
|
#[error("backup restore: {0}")]
|
||||||
|
BackupRestore(String),
|
||||||
|
|
||||||
|
/// CSV header doesn't match the LastPass column layout.
|
||||||
|
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||||
|
ImportCsvHeader(String),
|
||||||
|
|
||||||
|
/// CSV body could not be parsed (mismatched quoting, encoding, etc.).
|
||||||
|
/// Per-row record errors that the importer recovers from become
|
||||||
|
/// `ImportWarning` entries — this variant is reserved for failures
|
||||||
|
/// that abort the whole import.
|
||||||
|
#[error("CSV parse failed: {0}")]
|
||||||
|
ImportCsvFormat(String),
|
||||||
|
|
||||||
|
/// An item was looked up by ID but does not exist in the manifest.
|
||||||
|
#[error("item not found: {0}")]
|
||||||
|
ItemNotFound(String),
|
||||||
|
|
||||||
|
/// A passphrase failed the strength gate at vault creation (audit H3).
|
||||||
|
#[error("passphrase strength insufficient (score {score}/4)")]
|
||||||
|
WeakPassphrase { score: u8 },
|
||||||
|
|
||||||
|
/// An attachment exceeded the per-attachment cap from VaultSettings.
|
||||||
|
#[error("attachment too large: {size} bytes > {max} bytes max")]
|
||||||
|
AttachmentTooLarge { size: u64, max: u64 },
|
||||||
|
|
||||||
|
/// A general error from the image steganography subsystem (imgsecret).
|
||||||
|
/// Covers issues like failing to decode the carrier JPEG or failing to
|
||||||
|
/// encode the output JPEG after modification.
|
||||||
|
#[error("imgsecret: {0}")]
|
||||||
|
ImgSecret(String),
|
||||||
|
|
||||||
|
/// The carrier image is too small to hold the embedded secret with
|
||||||
|
/// sufficient redundancy. The embed region (central 70% of the image)
|
||||||
|
/// must contain at least `BLOCKS_PER_COPY * MIN_COPIES` 8x8 blocks.
|
||||||
|
#[error("image too small: need at least {min_width}x{min_height}, got {actual_width}x{actual_height}")]
|
||||||
|
ImageTooSmall {
|
||||||
|
min_width: u32,
|
||||||
|
min_height: u32,
|
||||||
|
actual_width: u32,
|
||||||
|
actual_height: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Secret extraction from a JPEG failed. This can mean:
|
||||||
|
/// - The image never had a secret embedded in it.
|
||||||
|
/// - The image was recompressed below Q85, destroying the QIM watermarks.
|
||||||
|
/// - The image was cropped beyond the 15% crumple zone.
|
||||||
|
/// - Majority-vote confidence fell below the 60% threshold on one or more bits.
|
||||||
|
#[error("extraction failed: no valid secret found in image")]
|
||||||
|
ExtractionFailed,
|
||||||
|
|
||||||
|
/// JSON serialization or deserialization of an entry or manifest failed.
|
||||||
|
/// Wraps [`serde_json::Error`] transparently via `#[from]`.
|
||||||
|
#[error("json error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// An error related to device ed25519 key operations. Device keys are
|
||||||
|
/// separate from the vault KDF -- revoking a device does not require
|
||||||
|
/// rotating the passphrase or reference image.
|
||||||
|
#[error("device key error: {0}")]
|
||||||
|
DeviceKey(String),
|
||||||
|
|
||||||
|
/// HOTP requires incrementing and persisting the counter after each use.
|
||||||
|
/// Without vault-save machinery in compute_totp_code, HOTP would desync
|
||||||
|
/// immediately. Use TOTP instead.
|
||||||
|
#[error("HOTP is not supported: counter persistence requires vault save after each use")]
|
||||||
|
HotpNotSupported,
|
||||||
|
|
||||||
|
/// Recovery QR generation or parsing failed.
|
||||||
|
#[error("recovery QR: {0}")]
|
||||||
|
RecoveryQr(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
|
pub type Result<T> = std::result::Result<T, RelicarioError>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_error_message_is_opaque() {
|
||||||
|
let err = RelicarioError::Decrypt;
|
||||||
|
assert_eq!(format!("{}", err), "decryption failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weak_passphrase_carries_score() {
|
||||||
|
let err = RelicarioError::WeakPassphrase { score: 1 };
|
||||||
|
let s = format!("{}", err);
|
||||||
|
assert!(s.contains("passphrase"));
|
||||||
|
assert!(s.contains("strength"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_too_large_reports_sizes() {
|
||||||
|
let err = RelicarioError::AttachmentTooLarge { size: 11_000_000, max: 10_485_760 };
|
||||||
|
let s = format!("{}", err);
|
||||||
|
assert!(s.contains("11000000"));
|
||||||
|
assert!(s.contains("10485760"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_not_found_carries_id() {
|
||||||
|
let err = RelicarioError::ItemNotFound("abc123".to_string());
|
||||||
|
assert!(format!("{}", err).contains("abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_format_version_reports_byte() {
|
||||||
|
let err = RelicarioError::UnsupportedFormatVersion { found: 0x01, expected: 0x02 };
|
||||||
|
let s = format!("{}", err);
|
||||||
|
assert!(s.contains("01") || s.contains("1"));
|
||||||
|
assert!(s.contains("02") || s.contains("2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_errors_carry_useful_messages() {
|
||||||
|
let bad = RelicarioError::BackupBadMagic;
|
||||||
|
assert!(format!("{}", bad).contains("not a Relicario backup file"));
|
||||||
|
|
||||||
|
let ver = RelicarioError::BackupUnsupportedVersion { found: 0x02, expected: 0x01 };
|
||||||
|
let s = format!("{}", ver);
|
||||||
|
assert!(s.contains("newer"));
|
||||||
|
|
||||||
|
let schema = RelicarioError::BackupSchemaMismatch { found: 2, expected: 1 };
|
||||||
|
let s = format!("{}", schema);
|
||||||
|
assert!(s.contains("v2") && s.contains("v1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_errors_carry_useful_messages() {
|
||||||
|
let h = RelicarioError::ImportCsvHeader("missing 'name' column".into());
|
||||||
|
assert!(format!("{}", h).contains("LastPass"));
|
||||||
|
assert!(format!("{}", h).contains("missing 'name'"));
|
||||||
|
|
||||||
|
let f = RelicarioError::ImportCsvFormat("unterminated quote at line 12".into());
|
||||||
|
assert!(format!("{}", f).contains("CSV parse failed"));
|
||||||
|
assert!(format!("{}", f).contains("unterminated quote"));
|
||||||
|
}
|
||||||
|
}
|
||||||
269
crates/relicario-core/src/generators.rs
Normal file
269
crates/relicario-core/src/generators.rs
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
|
||||||
|
//! eliminate modulo bias. Strength rating via zxcvbn.
|
||||||
|
|
||||||
|
use bip39::{Language, Mnemonic};
|
||||||
|
use rand::distributions::{Distribution, Uniform};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
|
||||||
|
|
||||||
|
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
|
||||||
|
const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?.";
|
||||||
|
const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const DIGITS: &[u8] = b"0123456789";
|
||||||
|
|
||||||
|
pub fn generate_password(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||||||
|
match req {
|
||||||
|
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||||
|
random_password(*length, classes, symbol_charset)
|
||||||
|
}
|
||||||
|
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::Format(
|
||||||
|
"use generate_passphrase() for BIP39 requests".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_password(
|
||||||
|
length: u32,
|
||||||
|
classes: &CharClasses,
|
||||||
|
symbol_charset: &SymbolCharset,
|
||||||
|
) -> Result<Zeroizing<String>> {
|
||||||
|
if length == 0 || length > 128 {
|
||||||
|
return Err(RelicarioError::Format("length must be 1..=128".into()));
|
||||||
|
}
|
||||||
|
let mut charset: Vec<u8> = Vec::new();
|
||||||
|
if classes.lower { charset.extend_from_slice(LOWER); }
|
||||||
|
if classes.upper { charset.extend_from_slice(UPPER); }
|
||||||
|
if classes.digits { charset.extend_from_slice(DIGITS); }
|
||||||
|
if classes.symbols {
|
||||||
|
let symbols: &[u8] = match symbol_charset {
|
||||||
|
SymbolCharset::SafeOnly => SAFE_SYMBOLS,
|
||||||
|
SymbolCharset::Extended => EXTENDED_SYMBOLS,
|
||||||
|
SymbolCharset::Custom(s) => {
|
||||||
|
if !s.is_ascii() {
|
||||||
|
return Err(RelicarioError::Format(
|
||||||
|
"SymbolCharset::Custom must be ASCII-only".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s.as_bytes()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
charset.extend_from_slice(symbols);
|
||||||
|
}
|
||||||
|
if charset.is_empty() {
|
||||||
|
return Err(RelicarioError::Format("at least one character class required".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dist = Uniform::from(0..charset.len());
|
||||||
|
let mut rng = OsRng;
|
||||||
|
let bytes: Vec<u8> = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect();
|
||||||
|
Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||||||
|
match req {
|
||||||
|
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||||
|
bip39_passphrase(*word_count, separator, *capitalization)
|
||||||
|
}
|
||||||
|
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
|
||||||
|
"use generate_password() for Random requests".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
|
||||||
|
if !matches!(word_count, 3..=12) {
|
||||||
|
return Err(RelicarioError::Format("word_count must be 3..=12".into()));
|
||||||
|
}
|
||||||
|
// bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes).
|
||||||
|
// We always generate 128 bits (16 bytes) → 12 words, then take the first
|
||||||
|
// word_count words. This gives full-entropy sourcing even for short passphrases.
|
||||||
|
let mut entropy = Zeroizing::new([0u8; 16]);
|
||||||
|
OsRng.fill_bytes(entropy.as_mut_slice());
|
||||||
|
let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice())
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("bip39: {e}")))?;
|
||||||
|
let words: Vec<String> = m.words().take(word_count as usize).map(|w| {
|
||||||
|
match cap {
|
||||||
|
Capitalization::Lower => w.to_ascii_lowercase(),
|
||||||
|
Capitalization::Upper => w.to_ascii_uppercase(),
|
||||||
|
Capitalization::FirstOfEach | Capitalization::Title => {
|
||||||
|
let mut chars = w.chars();
|
||||||
|
chars.next().map(|c| c.to_ascii_uppercase().to_string())
|
||||||
|
.unwrap_or_default() + chars.as_str()
|
||||||
|
}
|
||||||
|
Capitalization::Mixed => {
|
||||||
|
w.chars().enumerate().map(|(i, c)| {
|
||||||
|
if i % 2 == 0 { c.to_ascii_uppercase() } else { c }
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
Ok(Zeroizing::new(words.join(separator)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct StrengthEstimate {
|
||||||
|
pub score: u8,
|
||||||
|
pub guesses_log10: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rate_passphrase(p: &str) -> StrengthEstimate {
|
||||||
|
let est = zxcvbn::zxcvbn(p, &[]);
|
||||||
|
StrengthEstimate {
|
||||||
|
score: est.score().into(),
|
||||||
|
guesses_log10: est.guesses_log10(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strength gate at vault creation (audit H3): require score >= 3.
|
||||||
|
pub fn validate_passphrase_strength(p: &str) -> Result<()> {
|
||||||
|
let est = rate_passphrase(p);
|
||||||
|
if est.score < 3 {
|
||||||
|
return Err(RelicarioError::WeakPassphrase { score: est.score });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bip39_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bip39_default_is_5_space_separated_words() {
|
||||||
|
let req = GeneratorRequest::Bip39 {
|
||||||
|
word_count: 5,
|
||||||
|
separator: " ".into(),
|
||||||
|
capitalization: Capitalization::Lower,
|
||||||
|
};
|
||||||
|
let pw = generate_passphrase(&req).unwrap();
|
||||||
|
assert_eq!(pw.split(' ').count(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bip39_dash_separator() {
|
||||||
|
let req = GeneratorRequest::Bip39 {
|
||||||
|
word_count: 4,
|
||||||
|
separator: "-".into(),
|
||||||
|
capitalization: Capitalization::Lower,
|
||||||
|
};
|
||||||
|
let pw = generate_passphrase(&req).unwrap();
|
||||||
|
assert_eq!(pw.split('-').count(), 4);
|
||||||
|
assert!(!pw.contains(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bip39_first_of_each_capitalizes() {
|
||||||
|
let req = GeneratorRequest::Bip39 {
|
||||||
|
word_count: 5,
|
||||||
|
separator: " ".into(),
|
||||||
|
capitalization: Capitalization::FirstOfEach,
|
||||||
|
};
|
||||||
|
let pw = generate_passphrase(&req).unwrap();
|
||||||
|
for word in pw.split(' ') {
|
||||||
|
let first = word.chars().next().unwrap();
|
||||||
|
assert!(first.is_ascii_uppercase(), "word {word} should start uppercase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bip39_rejects_bad_word_count() {
|
||||||
|
let req = GeneratorRequest::Bip39 {
|
||||||
|
word_count: 2,
|
||||||
|
separator: " ".into(),
|
||||||
|
capitalization: Capitalization::Lower,
|
||||||
|
};
|
||||||
|
assert!(generate_passphrase(&req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_passphrase_strong_one_passes_gate() {
|
||||||
|
// 6-word bip39 passphrase
|
||||||
|
let req = GeneratorRequest::Bip39 {
|
||||||
|
word_count: 6,
|
||||||
|
separator: " ".into(),
|
||||||
|
capitalization: Capitalization::Lower,
|
||||||
|
};
|
||||||
|
let pw = generate_passphrase(&req).unwrap();
|
||||||
|
assert!(validate_passphrase_strength(&pw).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_passphrase_weak_fails_gate() {
|
||||||
|
assert!(validate_passphrase_strength("password").is_err());
|
||||||
|
assert!(validate_passphrase_strength("12345678").is_err());
|
||||||
|
assert!(validate_passphrase_strength("hunter2").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_default_password_is_20_chars() {
|
||||||
|
let req = GeneratorRequest::default();
|
||||||
|
let pw = generate_password(&req).unwrap();
|
||||||
|
assert_eq!(pw.len(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_zero_length() {
|
||||||
|
let req = GeneratorRequest::Random {
|
||||||
|
length: 0,
|
||||||
|
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
|
||||||
|
symbol_charset: SymbolCharset::SafeOnly,
|
||||||
|
};
|
||||||
|
assert!(generate_password(&req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_no_classes() {
|
||||||
|
let req = GeneratorRequest::Random {
|
||||||
|
length: 8,
|
||||||
|
classes: CharClasses { lower: false, upper: false, digits: false, symbols: false },
|
||||||
|
symbol_charset: SymbolCharset::SafeOnly,
|
||||||
|
};
|
||||||
|
assert!(generate_password(&req).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lower_only_password_uses_lowercase() {
|
||||||
|
let req = GeneratorRequest::Random {
|
||||||
|
length: 100,
|
||||||
|
classes: CharClasses { lower: true, upper: false, digits: false, symbols: false },
|
||||||
|
symbol_charset: SymbolCharset::SafeOnly,
|
||||||
|
};
|
||||||
|
let pw = generate_password(&req).unwrap();
|
||||||
|
assert!(pw.chars().all(|c| c.is_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_symbols_excludes_quotes_and_brackets() {
|
||||||
|
let req = GeneratorRequest::Random {
|
||||||
|
length: 128,
|
||||||
|
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
|
||||||
|
symbol_charset: SymbolCharset::SafeOnly,
|
||||||
|
};
|
||||||
|
let pw = generate_password(&req).unwrap();
|
||||||
|
for c in pw.chars() {
|
||||||
|
assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'),
|
||||||
|
"safe charset must not include {c}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_charset_rejects_non_ascii() {
|
||||||
|
let req = GeneratorRequest::Random {
|
||||||
|
length: 8,
|
||||||
|
classes: CharClasses { lower: false, upper: false, digits: false, symbols: true },
|
||||||
|
symbol_charset: SymbolCharset::Custom("ñé".into()),
|
||||||
|
};
|
||||||
|
let err = generate_password(&req);
|
||||||
|
assert!(err.is_err(), "non-ASCII custom charset must be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
161
crates/relicario-core/src/ids.rs
Normal file
161
crates/relicario-core/src/ids.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
//! Random and content-addressed identifiers for items, fields, and attachments.
|
||||||
|
//!
|
||||||
|
//! - `ItemId` and `FieldId` are random 16-char hex strings (64 bits of entropy)
|
||||||
|
//! generated via `OsRng` (audit M8: bumped from the v1 8-char/32-bit format).
|
||||||
|
//! - `AttachmentId` is the first 32 hex chars of `sha256(plaintext)` (128 bits) —
|
||||||
|
//! content-addressed so identical plaintext blobs deduplicate naturally in git.
|
||||||
|
//! (audit I2/B4: bumped from 8-byte/64-bit format to prevent birthday collisions)
|
||||||
|
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct ItemId(pub String);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct FieldId(pub String);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct AttachmentId(pub String);
|
||||||
|
|
||||||
|
impl ItemId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
|
||||||
|
/// Returns true if this ID is valid for filesystem paths.
|
||||||
|
/// Valid ItemIds are 16 lowercase hex chars.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 16 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ItemId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut bytes = [0u8; 8];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
Self(hex::encode(bytes))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FieldId {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AttachmentId {
|
||||||
|
pub fn from_plaintext(plaintext: &[u8]) -> Self {
|
||||||
|
let digest = Sha256::digest(plaintext);
|
||||||
|
Self(hex::encode(&digest[..16])) // 16 bytes = 128 bits
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str { &self.0 }
|
||||||
|
|
||||||
|
/// Returns true if this ID is valid for filesystem paths.
|
||||||
|
/// Valid AttachmentIds are 32 lowercase hex chars.
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.0.len() == 32 && self.0.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_16_hex_chars() {
|
||||||
|
let id = ItemId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_ids_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
assert!(seen.insert(ItemId::new().0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_id_is_16_hex_chars() {
|
||||||
|
let id = FieldId::new();
|
||||||
|
assert_eq!(id.0.len(), 16);
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_ids_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
assert!(seen.insert(FieldId::new().0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_is_deterministic() {
|
||||||
|
let plaintext = b"hello world";
|
||||||
|
let a = AttachmentId::from_plaintext(plaintext);
|
||||||
|
let b = AttachmentId::from_plaintext(plaintext);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_changes_with_plaintext() {
|
||||||
|
let a = AttachmentId::from_plaintext(b"hello");
|
||||||
|
let b = AttachmentId::from_plaintext(b"world");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_is_32_hex_chars() {
|
||||||
|
let id = AttachmentId::from_plaintext(b"any bytes");
|
||||||
|
assert_eq!(id.0.len(), 32); // 16 bytes = 32 hex chars = 128 bits
|
||||||
|
assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_valid_for_normal_ids() {
|
||||||
|
let id = ItemId::new();
|
||||||
|
assert!(id.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_invalid_for_traversal() {
|
||||||
|
let bad = ItemId("../../../etc".to_string());
|
||||||
|
assert!(!bad.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_is_valid_for_normal_ids() {
|
||||||
|
let id = AttachmentId::from_plaintext(b"test");
|
||||||
|
assert!(id.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_id_is_invalid_for_traversal() {
|
||||||
|
let bad = AttachmentId("../../passwd".to_string());
|
||||||
|
assert!(!bad.is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_serialize_as_bare_strings() {
|
||||||
|
let item = ItemId("abcdef0123456789".to_string());
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
assert_eq!(json, "\"abcdef0123456789\"");
|
||||||
|
|
||||||
|
let parsed: ItemId = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,45 @@
|
|||||||
//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping.
|
//! DCT-based steganographic embedding of a 256-bit secret in JPEG images.
|
||||||
//!
|
//!
|
||||||
//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance
|
//! This is the novel component of relicario. It hides a 32-byte secret inside a
|
||||||
//! channel using Quantization Index Modulation (QIM) with majority voting.
|
//! JPEG image's luminance channel using Quantization Index Modulation (QIM) on
|
||||||
|
//! mid-frequency DCT coefficients, with majority voting across multiple redundant
|
||||||
|
//! copies for robustness.
|
||||||
|
//!
|
||||||
|
//! ## High-level algorithm
|
||||||
|
//!
|
||||||
|
//! ### Embedding (`embed`)
|
||||||
|
//!
|
||||||
|
//! 1. Decode the carrier JPEG and extract the luminance (Y) channel.
|
||||||
|
//! 2. Compute the "embed region" -- the central 70% of the image (15% margin
|
||||||
|
//! on each side acts as a crumple zone for mild cropping).
|
||||||
|
//! 3. Divide the embed region into 8x8 pixel blocks and select evenly-spaced
|
||||||
|
//! blocks for embedding.
|
||||||
|
//! 4. For each copy of the secret (5-50 copies depending on image size):
|
||||||
|
//! - For each of the 22 blocks needed to hold 256 bits (12 bits per block):
|
||||||
|
//! - Apply the 2D DCT to the 8x8 block.
|
||||||
|
//! - Embed bits into 12 mid-frequency DCT coefficients using QIM.
|
||||||
|
//! - Apply the inverse DCT to write the modified block back.
|
||||||
|
//! 5. Reconstruct the JPEG by replacing only the Y channel and re-encoding.
|
||||||
|
//!
|
||||||
|
//! ### Extraction (`extract`)
|
||||||
|
//!
|
||||||
|
//! 1. Decode the JPEG and extract the Y channel.
|
||||||
|
//! 2. Try the canonical extraction (assuming the image is uncropped).
|
||||||
|
//! 3. If that fails, try crop-recovery: search for plausible original dimensions
|
||||||
|
//! and pixel offsets, reconstructing the block grid accordingly.
|
||||||
|
//! 4. For each copy of the secret, extract bits from DCT coefficients via QIM.
|
||||||
|
//! 5. Majority-vote each bit position across all copies. Require >= 60% confidence.
|
||||||
|
//!
|
||||||
|
//! ## Robustness
|
||||||
|
//!
|
||||||
|
//! The combination of QIM with a high quantization step (50.0), mid-frequency
|
||||||
|
//! coefficient placement, and majority voting across many copies makes the
|
||||||
|
//! watermark survive:
|
||||||
|
//! - JPEG recompression down to quality ~85
|
||||||
|
//! - Mild cropping (up to ~10% from edges, within the 15% crumple zone)
|
||||||
|
//! - Color space conversions (embedding is in luminance only)
|
||||||
|
|
||||||
use crate::error::{IdfotoError, Result};
|
use crate::error::{RelicarioError, Result};
|
||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
use image::{ImageEncoder, Rgb, RgbImage};
|
use image::{ImageEncoder, Rgb, RgbImage};
|
||||||
@@ -12,43 +48,160 @@ use std::io::Cursor;
|
|||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// DCT block size. JPEG uses 8x8 blocks, so we match that to minimize
|
||||||
|
/// interference with the JPEG codec's own quantization.
|
||||||
const BLOCK_SIZE: usize = 8;
|
const BLOCK_SIZE: usize = 8;
|
||||||
const QUANT_STEP: f64 = 50.0;
|
|
||||||
const MIN_DIMENSION: u32 = 100;
|
|
||||||
const SECRET_BITS: usize = 256;
|
|
||||||
const MIN_COPIES: usize = 5;
|
|
||||||
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
|
||||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
|
||||||
|
|
||||||
/// Mid-frequency DCT positions (zig-zag positions 4–15)
|
/// QIM quantization step. Higher values make the watermark more robust to
|
||||||
|
/// recompression but introduce more visible artifacts. A value of 50.0 is
|
||||||
|
/// higher than the typical academic value of 25 -- this is intentional because
|
||||||
|
/// we need to survive JPEG recompression at Q85 and below, which applies
|
||||||
|
/// aggressive quantization to mid-frequency coefficients. The trade-off is
|
||||||
|
/// acceptable because the reference image is a personal photo, not a
|
||||||
|
/// publication-quality image.
|
||||||
|
const QUANT_STEP: f64 = 50.0;
|
||||||
|
|
||||||
|
/// Minimum image dimension (width or height) in pixels. Images smaller than
|
||||||
|
/// this cannot hold enough 8x8 blocks for reliable embedding.
|
||||||
|
const MIN_DIMENSION: u32 = 100;
|
||||||
|
|
||||||
|
/// Maximum image dimension (width or height) in pixels. Images larger than
|
||||||
|
/// this are rejected before full decode to prevent DoS via attacker-supplied
|
||||||
|
/// oversized JPEGs (audit M3).
|
||||||
|
pub const MAX_DIMENSION: u32 = 10_000;
|
||||||
|
|
||||||
|
/// Number of secret bits to embed: 256 bits = 32 bytes.
|
||||||
|
const SECRET_BITS: usize = 256;
|
||||||
|
|
||||||
|
/// Minimum number of redundant copies of the secret. More copies improve
|
||||||
|
/// extraction reliability via majority voting, but require more blocks.
|
||||||
|
const MIN_COPIES: usize = 5;
|
||||||
|
|
||||||
|
/// Number of mid-frequency DCT positions used per block. Each block carries
|
||||||
|
/// 12 bits of the secret. This matches `EMBED_POSITIONS.len()`.
|
||||||
|
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
||||||
|
|
||||||
|
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||||
|
/// ceil(256 / 12) = 22 blocks per copy.
|
||||||
|
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
|
||||||
|
|
||||||
|
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||||
|
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||||
|
///
|
||||||
|
/// These correspond to zig-zag scan positions 6 through 17 -- the "sweet spot"
|
||||||
|
/// between low-frequency coefficients (which carry visible image structure and
|
||||||
|
/// are heavily quantized by JPEG) and high-frequency coefficients (which carry
|
||||||
|
/// noise/detail and are aggressively zeroed by JPEG compression).
|
||||||
|
///
|
||||||
|
/// Mid-frequency coefficients survive JPEG recompression better than high-frequency
|
||||||
|
/// ones, while causing less visible distortion than modifying low-frequency ones.
|
||||||
|
///
|
||||||
|
/// The zig-zag ordering is the standard JPEG scan order:
|
||||||
|
/// ```text
|
||||||
|
/// Zig-zag positions 6-9: (0,3) (1,2) (2,1) (3,0)
|
||||||
|
/// Zig-zag positions 10-13: (4,0) (3,1) (2,2) (1,3)
|
||||||
|
/// Zig-zag positions 14-17: (0,4) (0,5) (1,4) (2,3)
|
||||||
|
/// ```
|
||||||
const EMBED_POSITIONS: [(usize, usize); 12] = [
|
const EMBED_POSITIONS: [(usize, usize); 12] = [
|
||||||
(0, 3),
|
(0, 3),
|
||||||
(1, 2),
|
(1, 2),
|
||||||
(2, 1),
|
(2, 1),
|
||||||
(3, 0), // zig-zag 4-7
|
(3, 0), // zig-zag 6-9
|
||||||
(0, 4),
|
(0, 4),
|
||||||
(1, 3),
|
(1, 3),
|
||||||
(2, 2),
|
(2, 2),
|
||||||
(3, 1), // zig-zag 8-11
|
(3, 1), // zig-zag 10-13
|
||||||
(4, 0),
|
(4, 0),
|
||||||
(0, 5),
|
(0, 5),
|
||||||
(1, 4),
|
(1, 4),
|
||||||
(2, 3), // zig-zag 12-15
|
(2, 3), // zig-zag 14-17
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Dimension guard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Walk JPEG markers until we hit an SOF (start-of-frame) marker, which
|
||||||
|
/// carries the image dimensions in bytes 5..=8 of its segment.
|
||||||
|
///
|
||||||
|
/// This peek does NOT decode any pixel data, so an oversized JPEG header is
|
||||||
|
/// rejected in O(marker-count) time without allocating a frame buffer.
|
||||||
|
fn peek_jpeg_dimensions(jpeg: &[u8]) -> Result<(u32, u32)> {
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 1 < jpeg.len() {
|
||||||
|
if jpeg[i] != 0xFF {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let marker = jpeg[i + 1];
|
||||||
|
match marker {
|
||||||
|
0xD8 | 0xD9 => {
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
} // SOI / EOI
|
||||||
|
0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF => {
|
||||||
|
// SOFn — height in [i+5..i+7], width in [i+7..i+9]
|
||||||
|
if i + 8 >= jpeg.len() {
|
||||||
|
return Err(RelicarioError::ImgSecret("truncated SOF marker".into()));
|
||||||
|
}
|
||||||
|
let height = u16::from_be_bytes([jpeg[i + 5], jpeg[i + 6]]) as u32;
|
||||||
|
let width = u16::from_be_bytes([jpeg[i + 7], jpeg[i + 8]]) as u32;
|
||||||
|
return Ok((width, height));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if i + 3 >= jpeg.len() {
|
||||||
|
return Err(RelicarioError::ImgSecret("truncated marker segment".into()));
|
||||||
|
}
|
||||||
|
let seg_len = u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize;
|
||||||
|
i += 2 + seg_len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RelicarioError::ImgSecret(
|
||||||
|
"no SOF marker found in JPEG".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject JPEGs that claim dimensions exceeding [`MAX_DIMENSION`].
|
||||||
|
///
|
||||||
|
/// Called at the entry point of both `embed` and `extract` to prevent
|
||||||
|
/// attacker-supplied 32000×32000 images from wedging the WASM service worker
|
||||||
|
/// during the expensive DCT extraction pass (audit M3).
|
||||||
|
fn enforce_dimension_cap(jpeg: &[u8]) -> Result<()> {
|
||||||
|
let (w, h) = peek_jpeg_dimensions(jpeg)?;
|
||||||
|
if w > MAX_DIMENSION || h > MAX_DIMENSION {
|
||||||
|
return Err(RelicarioError::ImgSecret(format!(
|
||||||
|
"image dimensions {w}x{h} exceed {MAX_DIMENSION}x{MAX_DIMENSION} cap"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ─── YChannel ────────────────────────────────────────────────────────────────
|
// ─── YChannel ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The luminance (Y) channel of an image, stored as a flat array of f64 values.
|
||||||
|
///
|
||||||
|
/// We embed exclusively in the luminance channel because:
|
||||||
|
/// - Luminance is not spatially subsampled by JPEG (unlike chrominance which
|
||||||
|
/// is typically 4:2:0), so the full DCT block grid is available for embedding.
|
||||||
|
/// - JPEG's chrominance subsampling would destroy embedded data by halving
|
||||||
|
/// the spatial resolution before DCT, misaligning our block positions.
|
||||||
|
/// - Working with a single channel keeps the DCT operations simple and fast.
|
||||||
struct YChannel {
|
struct YChannel {
|
||||||
|
/// Row-major luminance values. `data[y * width + x]` gives the luminance
|
||||||
|
/// at pixel (x, y). Values are in the range [0, 255] after extraction
|
||||||
|
/// from RGB, but may temporarily go slightly outside this range during
|
||||||
|
/// DCT manipulation.
|
||||||
data: Vec<f64>,
|
data: Vec<f64>,
|
||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YChannel {
|
impl YChannel {
|
||||||
|
/// Get the luminance value at pixel (x, y).
|
||||||
fn get(&self, x: usize, y: usize) -> f64 {
|
fn get(&self, x: usize, y: usize) -> f64 {
|
||||||
self.data[y * self.width + x]
|
self.data[y * self.width + x]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the luminance value at pixel (x, y).
|
||||||
fn set(&mut self, x: usize, y: usize, val: f64) {
|
fn set(&mut self, x: usize, y: usize, val: f64) {
|
||||||
self.data[y * self.width + x] = val;
|
self.data[y * self.width + x] = val;
|
||||||
}
|
}
|
||||||
@@ -56,32 +209,50 @@ impl YChannel {
|
|||||||
|
|
||||||
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
|
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Defines the central region of the image where embedding occurs.
|
||||||
|
///
|
||||||
|
/// The embed region is the central 70% of the image -- a 15% margin is excluded
|
||||||
|
/// on each side. This margin acts as a "crumple zone": if the image is mildly
|
||||||
|
/// cropped (e.g., a social media platform trims edges), the embedded data in the
|
||||||
|
/// center remains intact. The 15% margin is sufficient to tolerate up to ~10%
|
||||||
|
/// cropping from any single edge.
|
||||||
struct EmbedRegion {
|
struct EmbedRegion {
|
||||||
|
/// Pixel offset from the left edge to the start of the embed region.
|
||||||
x_offset: usize,
|
x_offset: usize,
|
||||||
|
/// Pixel offset from the top edge to the start of the embed region.
|
||||||
y_offset: usize,
|
y_offset: usize,
|
||||||
|
/// Width of the embed region in pixels.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
region_width: usize,
|
region_width: usize,
|
||||||
|
/// Height of the embed region in pixels.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
region_height: usize,
|
region_height: usize,
|
||||||
|
/// Number of complete 8x8 blocks that fit horizontally in the embed region.
|
||||||
blocks_x: usize,
|
blocks_x: usize,
|
||||||
|
/// Number of complete 8x8 blocks that fit vertically in the embed region.
|
||||||
blocks_y: usize,
|
blocks_y: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helper functions ────────────────────────────────────────────────────────
|
// ─── Helper functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Decode a JPEG from raw bytes and extract the luminance (Y) channel.
|
||||||
|
///
|
||||||
|
/// Converts each RGB pixel to luminance using the ITU-R BT.601 formula:
|
||||||
|
/// `Y = 0.299*R + 0.587*G + 0.114*B`
|
||||||
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
||||||
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||||
let img = reader
|
let img = reader
|
||||||
.decode()
|
.decode()
|
||||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||||
let rgb = img.to_rgb8();
|
let rgb = img.to_rgb8();
|
||||||
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
|
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
|
||||||
let mut data = Vec::with_capacity(width * height);
|
let mut data = Vec::with_capacity(width * height);
|
||||||
for y in 0..height {
|
for y in 0..height {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
let p = rgb.get_pixel(x as u32, y as u32);
|
let p = rgb.get_pixel(x as u32, y as u32);
|
||||||
|
// ITU-R BT.601 luma coefficients
|
||||||
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
|
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
|
||||||
data.push(luma);
|
data.push(luma);
|
||||||
}
|
}
|
||||||
@@ -93,10 +264,15 @@ fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the embed region for a YChannel (convenience wrapper).
|
||||||
fn central_region(y: &YChannel) -> EmbedRegion {
|
fn central_region(y: &YChannel) -> EmbedRegion {
|
||||||
compute_region(y.width, y.height)
|
compute_region(y.width, y.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the central embed region for given image dimensions.
|
||||||
|
///
|
||||||
|
/// The region excludes a 15% margin on each side, leaving the central 70%.
|
||||||
|
/// The margin acts as a crumple zone for crop tolerance.
|
||||||
fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
||||||
let margin_x = (width as f64 * 0.15) as usize;
|
let margin_x = (width as f64 * 0.15) as usize;
|
||||||
let margin_y = (height as f64 * 0.15) as usize;
|
let margin_y = (height as f64 * 0.15) as usize;
|
||||||
@@ -116,76 +292,111 @@ fn compute_region(width: usize, height: usize) -> EmbedRegion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an 8x8 pixel block from the Y channel at absolute pixel coordinates.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the block would extend beyond the image boundaries
|
||||||
|
/// (used during crop-recovery extraction where some blocks may have been
|
||||||
|
/// cropped away).
|
||||||
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
||||||
if px + 8 > y.width || py + 8 > y.height {
|
if px + 8 > y.width || py + 8 > y.height {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut block = [[0.0f64; 8]; 8];
|
let mut block = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for (row, block_row) in block.iter_mut().enumerate() {
|
||||||
for col in 0..8 {
|
for (col, cell) in block_row.iter_mut().enumerate() {
|
||||||
block[row][col] = y.get(px + col, py + row);
|
*cell = y.get(px + col, py + row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(block)
|
Some(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read an 8x8 block from the Y channel using block coordinates relative to
|
||||||
|
/// the embed region.
|
||||||
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
|
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
|
||||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
read_block_abs(y, start_x, start_y).unwrap()
|
read_block_abs(y, start_x, start_y).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write an 8x8 block back to the Y channel using block coordinates relative
|
||||||
|
/// to the embed region.
|
||||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
for row in 0..8 {
|
for (row, block_row) in block.iter().enumerate() {
|
||||||
for col in 0..8 {
|
for (col, &cell) in block_row.iter().enumerate() {
|
||||||
y.set(start_x + col, start_y + row, block[row][col]);
|
y.set(start_x + col, start_y + row, cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DCT ─────────────────────────────────────────────────────────────────────
|
// ─── DCT ─────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The Discrete Cosine Transform (DCT) converts a spatial-domain signal (pixel
|
||||||
|
// values) into a frequency-domain representation (coefficients). JPEG compression
|
||||||
|
// itself uses the 8x8 Type-II DCT, so working in the same domain lets us embed
|
||||||
|
// data where JPEG's own quantization is least destructive.
|
||||||
|
//
|
||||||
|
// We implement the DCT from scratch (rather than depending on a library) to keep
|
||||||
|
// the crate dependency-light and WASM-friendly. The 8x8 size is small enough
|
||||||
|
// that the naive O(N^2) computation is fast.
|
||||||
|
|
||||||
|
/// 1D Type-II DCT of an 8-element signal.
|
||||||
|
///
|
||||||
|
/// Applies the orthonormal DCT-II:
|
||||||
|
/// X[k] = c(k) * sum_{i=0}^{7} x[i] * cos((2i+1)*k*pi/16)
|
||||||
|
///
|
||||||
|
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for k in 0..8 {
|
for (k, out_k) in output.iter_mut().enumerate() {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for i in 0..8 {
|
for (i, &x) in input.iter().enumerate() {
|
||||||
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
output[k] = ck * sum;
|
*out_k = ck * sum;
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 1D Type-III DCT (inverse DCT) of an 8-element signal.
|
||||||
|
///
|
||||||
|
/// Reconstructs the spatial-domain signal from DCT coefficients:
|
||||||
|
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for i in 0..8 {
|
for (i, out_i) in output.iter_mut().enumerate() {
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for k in 0..8 {
|
for (k, &x) in input.iter().enumerate() {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
output[i] = sum;
|
*out_i = sum;
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 2D DCT of an 8x8 block, computed as separable 1D DCTs.
|
||||||
|
///
|
||||||
|
/// First applies the 1D DCT to each row, then to each column of the result.
|
||||||
|
/// This is mathematically equivalent to the full 2D DCT but faster (O(N^3)
|
||||||
|
/// instead of O(N^4) for the naive 2D formulation).
|
||||||
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||||
|
// Step 1: DCT along rows
|
||||||
let mut temp = [[0.0f64; 8]; 8];
|
let mut temp = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for row in 0..8 {
|
||||||
temp[row] = dct1d(&block[row]);
|
temp[row] = dct1d(&block[row]);
|
||||||
}
|
}
|
||||||
|
// Step 2: DCT along columns
|
||||||
let mut result = [[0.0f64; 8]; 8];
|
let mut result = [[0.0f64; 8]; 8];
|
||||||
for col in 0..8 {
|
for col in 0..8 {
|
||||||
let mut column = [0.0f64; 8];
|
let mut column = [0.0f64; 8];
|
||||||
@@ -200,7 +411,12 @@ fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 2D inverse DCT of an 8x8 block, computed as separable 1D inverse DCTs.
|
||||||
|
///
|
||||||
|
/// Reverses the 2D DCT: first applies IDCT along columns, then along rows.
|
||||||
|
/// (The order is reversed compared to the forward transform.)
|
||||||
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
||||||
|
// Step 1: IDCT along columns
|
||||||
let mut temp = [[0.0f64; 8]; 8];
|
let mut temp = [[0.0f64; 8]; 8];
|
||||||
for col in 0..8 {
|
for col in 0..8 {
|
||||||
let mut column = [0.0f64; 8];
|
let mut column = [0.0f64; 8];
|
||||||
@@ -212,6 +428,7 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
|||||||
temp[row][col] = transformed[row];
|
temp[row][col] = transformed[row];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Step 2: IDCT along rows
|
||||||
let mut result = [[0.0f64; 8]; 8];
|
let mut result = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for row in 0..8 {
|
||||||
result[row] = idct1d(&temp[row]);
|
result[row] = idct1d(&temp[row]);
|
||||||
@@ -220,7 +437,28 @@ fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── QIM ─────────────────────────────────────────────────────────────────────
|
// ─── QIM ─────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Quantization Index Modulation (QIM) is the core technique for encoding bits
|
||||||
|
// into DCT coefficients. It works by quantizing each coefficient to one of two
|
||||||
|
// interleaved grids, where the grid selection encodes the bit value.
|
||||||
|
//
|
||||||
|
// For bit 0: quantize to the nearest multiple of Q (grid: ..., -Q, 0, Q, 2Q, ...)
|
||||||
|
// For bit 1: quantize to the nearest multiple of Q, offset by Q/2 (grid: ..., -Q/2, Q/2, 3Q/2, ...)
|
||||||
|
//
|
||||||
|
// Extraction simply measures which grid the coefficient is closest to.
|
||||||
|
//
|
||||||
|
// QIM is preferred over spread-spectrum or LSB methods because it is:
|
||||||
|
// - Robust to recompression (the quantization step is larger than JPEG's own)
|
||||||
|
// - Simple to implement and analyze
|
||||||
|
// - Deterministic (no pseudo-random spreading sequence to synchronize)
|
||||||
|
|
||||||
|
/// Embed a single bit into a DCT coefficient using QIM.
|
||||||
|
///
|
||||||
|
/// Quantizes the coefficient to the nearest point on the grid selected by `bit`:
|
||||||
|
/// - `bit=0`: grid at multiples of `q` (i.e., 0, q, 2q, ...)
|
||||||
|
/// - `bit=1`: grid at multiples of `q` offset by `q/2` (i.e., q/2, 3q/2, ...)
|
||||||
|
///
|
||||||
|
/// The returned value is the modified coefficient.
|
||||||
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
||||||
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
|
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
|
||||||
let shifted = coef - offset;
|
let shifted = coef - offset;
|
||||||
@@ -228,8 +466,15 @@ fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
|
|||||||
quantized + offset
|
quantized + offset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a single bit from a DCT coefficient using QIM.
|
||||||
|
///
|
||||||
|
/// Computes the distance from the coefficient to each grid (bit-0 grid and
|
||||||
|
/// bit-1 grid) and returns whichever grid is closer. This is the ML (maximum
|
||||||
|
/// likelihood) decoder for QIM under additive noise.
|
||||||
fn qim_extract(coef: f64, q: f64) -> u8 {
|
fn qim_extract(coef: f64, q: f64) -> u8 {
|
||||||
|
// Distance to the nearest bit-0 grid point
|
||||||
let d0 = (coef - (coef / q).round() * q).abs();
|
let d0 = (coef - (coef / q).round() * q).abs();
|
||||||
|
// Distance to the nearest bit-1 grid point (offset by q/2)
|
||||||
let offset = q / 2.0;
|
let offset = q / 2.0;
|
||||||
let shifted = coef - offset;
|
let shifted = coef - offset;
|
||||||
let d1 = (shifted - (shifted / q).round() * q).abs();
|
let d1 = (shifted - (shifted / q).round() * q).abs();
|
||||||
@@ -238,6 +483,10 @@ fn qim_extract(coef: f64, q: f64) -> u8 {
|
|||||||
|
|
||||||
// ─── Bit conversion ──────────────────────────────────────────────────────────
|
// ─── Bit conversion ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Convert a byte slice to a vector of individual bits (MSB first).
|
||||||
|
///
|
||||||
|
/// Each byte is expanded to 8 bits, with bit 7 (MSB) first.
|
||||||
|
/// Example: `[0xCA]` -> `[1, 1, 0, 0, 1, 0, 1, 0]`
|
||||||
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
||||||
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||||
for &byte in bytes {
|
for &byte in bytes {
|
||||||
@@ -248,8 +497,11 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
|||||||
bits
|
bits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a vector of individual bits (MSB first) back to bytes.
|
||||||
|
///
|
||||||
|
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
||||||
for chunk in bits.chunks(8) {
|
for chunk in bits.chunks(8) {
|
||||||
let mut byte = 0u8;
|
let mut byte = 0u8;
|
||||||
for (i, &bit) in chunk.iter().enumerate() {
|
for (i, &bit) in chunk.iter().enumerate() {
|
||||||
@@ -263,7 +515,18 @@ fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
|||||||
// ─── Block selection ─────────────────────────────────────────────────────────
|
// ─── Block selection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Compute the absolute pixel positions of embed blocks for a given image size.
|
/// Compute the absolute pixel positions of embed blocks for a given image size.
|
||||||
/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks.
|
///
|
||||||
|
/// This function deterministically maps image dimensions to a list of block
|
||||||
|
/// positions. Both the embedder and extractor call this function with the same
|
||||||
|
/// dimensions to agree on where blocks are. During crop recovery, the extractor
|
||||||
|
/// tries different assumed original dimensions to find the correct grid.
|
||||||
|
///
|
||||||
|
/// Returns `Vec<(px, py)>` -- top-left corners of 8x8 blocks in pixel coordinates.
|
||||||
|
/// Returns an empty vec if the image is too small to embed.
|
||||||
|
///
|
||||||
|
/// Blocks are selected with even spacing (stride) across the embed region to
|
||||||
|
/// spread the watermark uniformly, making it more resilient to localized damage.
|
||||||
|
/// The number of copies is capped at 50 to avoid diminishing returns.
|
||||||
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
|
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
|
||||||
let region = compute_region(img_width, img_height);
|
let region = compute_region(img_width, img_height);
|
||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
@@ -273,6 +536,7 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
|||||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||||
let target_count = num_copies * BLOCKS_PER_COPY;
|
let target_count = num_copies * BLOCKS_PER_COPY;
|
||||||
|
|
||||||
|
// Stride ensures blocks are evenly distributed across the embed region
|
||||||
let stride = (total_blocks / target_count).max(1);
|
let stride = (total_blocks / target_count).max(1);
|
||||||
let mut positions = Vec::with_capacity(target_count);
|
let mut positions = Vec::with_capacity(target_count);
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
@@ -287,11 +551,17 @@ fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, u
|
|||||||
positions
|
positions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Select embed blocks using block-coordinate indices relative to the embed region.
|
||||||
|
///
|
||||||
|
/// Similar to [`compute_embed_positions`] but returns `(bx, by)` block indices
|
||||||
|
/// rather than absolute pixel positions. Used during embedding where block
|
||||||
|
/// coordinates are more convenient for the read_block/write_block API.
|
||||||
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
|
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
|
||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
if total_blocks == 0 || target_count == 0 {
|
if total_blocks == 0 || target_count == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// Even stride distributes blocks uniformly across the region
|
||||||
let stride = (total_blocks / target_count).max(1);
|
let stride = (total_blocks / target_count).max(1);
|
||||||
let mut blocks = Vec::with_capacity(target_count);
|
let mut blocks = Vec::with_capacity(target_count);
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
@@ -306,13 +576,24 @@ fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize,
|
|||||||
|
|
||||||
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
|
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Reconstruct a JPEG image after modifying its luminance channel.
|
||||||
|
///
|
||||||
|
/// This function takes the original JPEG (for its Cb/Cr chrominance data) and
|
||||||
|
/// the modified Y channel, then:
|
||||||
|
///
|
||||||
|
/// 1. Decodes the original JPEG to get per-pixel Cb and Cr values.
|
||||||
|
/// 2. For each pixel, combines the modified Y with the original Cb/Cr.
|
||||||
|
/// 3. Converts YCbCr back to RGB using the ITU-R BT.601 inverse formula.
|
||||||
|
/// 4. Re-encodes as JPEG at quality 92 (high enough to preserve the watermark).
|
||||||
|
///
|
||||||
|
/// Only the luminance changes; chrominance is preserved from the original.
|
||||||
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
|
||||||
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
let reader = ImageReader::new(Cursor::new(original_jpeg))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
|
.map_err(|e| RelicarioError::ImgSecret(format!("failed to read image: {e}")))?;
|
||||||
let img = reader
|
let img = reader
|
||||||
.decode()
|
.decode()
|
||||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
|
.map_err(|e| RelicarioError::ImgSecret(format!("failed to decode image: {e}")))?;
|
||||||
let rgb = img.to_rgb8();
|
let rgb = img.to_rgb8();
|
||||||
let (width, height) = (rgb.width(), rgb.height());
|
let (width, height) = (rgb.width(), rgb.height());
|
||||||
|
|
||||||
@@ -325,12 +606,15 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
|||||||
let g = orig[1] as f64;
|
let g = orig[1] as f64;
|
||||||
let b = orig[2] as f64;
|
let b = orig[2] as f64;
|
||||||
|
|
||||||
|
// Extract Cb and Cr from the original pixel (we only modify Y)
|
||||||
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
|
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||||
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
|
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
|
||||||
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
|
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
|
||||||
|
|
||||||
|
// Use the modified Y value from our watermarked luminance channel
|
||||||
let y_new = y_modified.get(px as usize, py as usize);
|
let y_new = y_modified.get(px as usize, py as usize);
|
||||||
|
|
||||||
|
// Convert YCbCr -> RGB using ITU-R BT.601 inverse
|
||||||
let r_new = y_new + 1.402 * (cr - 128.0);
|
let r_new = y_new + 1.402 * (cr - 128.0);
|
||||||
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
|
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
|
||||||
let b_new = y_new + 1.772 * (cb - 128.0);
|
let b_new = y_new + 1.772 * (cb - 128.0);
|
||||||
@@ -351,18 +635,40 @@ fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u
|
|||||||
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
|
||||||
encoder
|
encoder
|
||||||
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
|
||||||
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
.map_err(|e| RelicarioError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
|
||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes.
|
/// Embed a 256-bit secret into a carrier JPEG image.
|
||||||
|
///
|
||||||
|
/// Returns the modified JPEG bytes with the secret hidden in the luminance
|
||||||
|
/// channel's mid-frequency DCT coefficients.
|
||||||
|
///
|
||||||
|
/// ## Pipeline
|
||||||
|
///
|
||||||
|
/// 1. Decode the carrier and extract the Y (luminance) channel.
|
||||||
|
/// 2. Validate that the image is large enough (>= 100x100 pixels, and enough
|
||||||
|
/// blocks in the central region for at least 5 redundant copies).
|
||||||
|
/// 3. Compute how many copies fit (up to 50) and select evenly-spaced blocks.
|
||||||
|
/// 4. For each copy, iterate through the 22 blocks that hold 256 bits:
|
||||||
|
/// - Forward DCT the 8x8 block.
|
||||||
|
/// - Embed 12 bits per block into the mid-frequency coefficients via QIM.
|
||||||
|
/// - Inverse DCT to write the modified spatial-domain values back.
|
||||||
|
/// 5. Reconstruct the JPEG with the modified Y channel and original Cb/Cr.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RelicarioError::ImageTooSmall`] if the image is below minimum dimensions
|
||||||
|
/// or does not have enough blocks for reliable embedding.
|
||||||
|
/// - [`RelicarioError::ImgSecret`] if the image cannot be decoded or re-encoded.
|
||||||
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
||||||
|
enforce_dimension_cap(carrier_jpeg)?;
|
||||||
let mut y = extract_y_channel(carrier_jpeg)?;
|
let mut y = extract_y_channel(carrier_jpeg)?;
|
||||||
|
|
||||||
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
|
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
|
||||||
return Err(IdfotoError::ImageTooSmall {
|
return Err(RelicarioError::ImageTooSmall {
|
||||||
min_width: MIN_DIMENSION,
|
min_width: MIN_DIMENSION,
|
||||||
min_height: MIN_DIMENSION,
|
min_height: MIN_DIMENSION,
|
||||||
actual_width: y.width as u32,
|
actual_width: y.width as u32,
|
||||||
@@ -374,7 +680,7 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
|
|
||||||
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
|
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
|
||||||
return Err(IdfotoError::ImageTooSmall {
|
return Err(RelicarioError::ImageTooSmall {
|
||||||
min_width: MIN_DIMENSION,
|
min_width: MIN_DIMENSION,
|
||||||
min_height: MIN_DIMENSION,
|
min_height: MIN_DIMENSION,
|
||||||
actual_width: y.width as u32,
|
actual_width: y.width as u32,
|
||||||
@@ -382,12 +688,15 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap at 50 copies -- beyond that, additional redundancy has diminishing
|
||||||
|
// returns and the image modification becomes more visible.
|
||||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||||
let bits = bytes_to_bits(secret);
|
let bits = bytes_to_bits(secret);
|
||||||
|
|
||||||
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
let blocks_needed = num_copies * BLOCKS_PER_COPY;
|
||||||
let embed_blocks = select_embed_blocks(®ion, blocks_needed);
|
let embed_blocks = select_embed_blocks(®ion, blocks_needed);
|
||||||
|
|
||||||
|
// Embed each copy of the secret into its assigned blocks
|
||||||
for copy in 0..num_copies {
|
for copy in 0..num_copies {
|
||||||
for block_idx in 0..BLOCKS_PER_COPY {
|
for block_idx in 0..BLOCKS_PER_COPY {
|
||||||
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
|
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
|
||||||
@@ -398,6 +707,8 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
let mut block = read_block(&y, bx, by, ®ion);
|
let mut block = read_block(&y, bx, by, ®ion);
|
||||||
let mut dct = dct2_8x8(&block);
|
let mut dct = dct2_8x8(&block);
|
||||||
|
|
||||||
|
// Embed up to 12 bits (BITS_PER_BLOCK) in this block's
|
||||||
|
// mid-frequency DCT coefficients
|
||||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||||
if bit_idx >= SECRET_BITS {
|
if bit_idx >= SECRET_BITS {
|
||||||
@@ -414,14 +725,32 @@ pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
reconstruct_jpeg(carrier_jpeg, &y)
|
reconstruct_jpeg(carrier_jpeg, &y)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
|
/// Extract a 256-bit secret from a (possibly re-encoded or mildly cropped) JPEG.
|
||||||
|
///
|
||||||
|
/// Delegates to [`extract_with_crop_recovery`] which first tries canonical
|
||||||
|
/// extraction (assuming the image has its original dimensions), then falls back
|
||||||
|
/// to searching for plausible original dimensions if the image was cropped.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RelicarioError::ExtractionFailed`] if no valid secret could be recovered
|
||||||
|
/// (image was never watermarked, or was too heavily recompressed/cropped).
|
||||||
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||||
|
enforce_dimension_cap(jpeg_bytes)?;
|
||||||
extract_with_crop_recovery(jpeg_bytes)
|
extract_with_crop_recovery(jpeg_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to extract using a specific assumed original image size and pixel offset.
|
/// Attempt to extract the secret assuming specific original image dimensions
|
||||||
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies).
|
/// and a pixel offset (for crop recovery).
|
||||||
/// `dx`/`dy` shift all block positions when reading from the actual image.
|
///
|
||||||
|
/// The block grid is computed based on `orig_w`/`orig_h` (the assumed original
|
||||||
|
/// dimensions), and then each block position is shifted by `dx`/`dy` when
|
||||||
|
/// reading from the actual (possibly cropped) image.
|
||||||
|
///
|
||||||
|
/// Uses majority voting across all copies: for each of the 256 bit positions,
|
||||||
|
/// the extracted bit from every copy votes, and the majority wins. A minimum
|
||||||
|
/// confidence threshold of 60% is required -- below that, the extraction is
|
||||||
|
/// considered unreliable and fails.
|
||||||
fn try_extract_with_layout(
|
fn try_extract_with_layout(
|
||||||
y: &YChannel,
|
y: &YChannel,
|
||||||
orig_w: usize,
|
orig_w: usize,
|
||||||
@@ -431,13 +760,14 @@ fn try_extract_with_layout(
|
|||||||
) -> Result<[u8; 32]> {
|
) -> Result<[u8; 32]> {
|
||||||
let positions = compute_embed_positions(orig_w, orig_h);
|
let positions = compute_embed_positions(orig_w, orig_h);
|
||||||
if positions.is_empty() {
|
if positions.is_empty() {
|
||||||
return Err(IdfotoError::ExtractionFailed);
|
return Err(RelicarioError::ExtractionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
let region = compute_region(orig_w, orig_h);
|
let region = compute_region(orig_w, orig_h);
|
||||||
let total_blocks = region.blocks_x * region.blocks_y;
|
let total_blocks = region.blocks_x * region.blocks_y;
|
||||||
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
|
||||||
|
|
||||||
|
// Accumulate votes for each bit position across all copies
|
||||||
let mut votes_one = vec![0usize; SECRET_BITS];
|
let mut votes_one = vec![0usize; SECRET_BITS];
|
||||||
let mut votes_total = vec![0usize; SECRET_BITS];
|
let mut votes_total = vec![0usize; SECRET_BITS];
|
||||||
|
|
||||||
@@ -447,6 +777,8 @@ fn try_extract_with_layout(
|
|||||||
if global_idx >= positions.len() {
|
if global_idx >= positions.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Apply crop offset to find the actual block position in the
|
||||||
|
// (possibly cropped) image
|
||||||
let (orig_px, orig_py) = positions[global_idx];
|
let (orig_px, orig_py) = positions[global_idx];
|
||||||
let actual_px = orig_px as isize + dx;
|
let actual_px = orig_px as isize + dx;
|
||||||
let actual_py = orig_py as isize + dy;
|
let actual_py = orig_py as isize + dy;
|
||||||
@@ -462,6 +794,7 @@ fn try_extract_with_layout(
|
|||||||
};
|
};
|
||||||
let dct = dct2_8x8(&block);
|
let dct = dct2_8x8(&block);
|
||||||
|
|
||||||
|
// Extract bits from mid-frequency coefficients and tally votes
|
||||||
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
|
||||||
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
|
||||||
if bit_idx >= SECRET_BITS {
|
if bit_idx >= SECRET_BITS {
|
||||||
@@ -476,18 +809,20 @@ fn try_extract_with_layout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Majority vote with confidence check
|
// Majority vote with confidence check: each bit must have >= 60% agreement
|
||||||
|
// across copies. Below that threshold, the watermark is considered too
|
||||||
|
// degraded for reliable extraction.
|
||||||
let mut result_bits = vec![0u8; SECRET_BITS];
|
let mut result_bits = vec![0u8; SECRET_BITS];
|
||||||
for i in 0..SECRET_BITS {
|
for i in 0..SECRET_BITS {
|
||||||
if votes_total[i] == 0 {
|
if votes_total[i] == 0 {
|
||||||
return Err(IdfotoError::ExtractionFailed);
|
return Err(RelicarioError::ExtractionFailed);
|
||||||
}
|
}
|
||||||
let ones = votes_one[i];
|
let ones = votes_one[i];
|
||||||
let zeros = votes_total[i] - ones;
|
let zeros = votes_total[i] - ones;
|
||||||
let majority = ones.max(zeros);
|
let majority = ones.max(zeros);
|
||||||
let confidence = majority as f64 / votes_total[i] as f64;
|
let confidence = majority as f64 / votes_total[i] as f64;
|
||||||
if confidence < 0.60 {
|
if confidence < 0.60 {
|
||||||
return Err(IdfotoError::ExtractionFailed);
|
return Err(RelicarioError::ExtractionFailed);
|
||||||
}
|
}
|
||||||
result_bits[i] = if ones > zeros { 1 } else { 0 };
|
result_bits[i] = if ones > zeros { 1 } else { 0 };
|
||||||
}
|
}
|
||||||
@@ -498,14 +833,27 @@ fn try_extract_with_layout(
|
|||||||
Ok(secret)
|
Ok(secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract with automatic crop recovery.
|
||||||
|
///
|
||||||
|
/// Tries extraction in order of decreasing likelihood:
|
||||||
|
///
|
||||||
|
/// 1. **Uncropped**: assume the image has its original dimensions (most common case).
|
||||||
|
/// 2. **Width-only crop (8-pixel aligned)**: try original widths from current up to
|
||||||
|
/// +20%, stepping by 8 pixels (JPEG block alignment). Assumes right-side crop
|
||||||
|
/// (left edge unchanged, dx=0).
|
||||||
|
/// 3. **Height-only crop (8-pixel aligned)**: same strategy for vertical crops.
|
||||||
|
/// 4. **Width crop (non-aligned)**: finer 1-pixel step for non-block-aligned crops.
|
||||||
|
///
|
||||||
|
/// The search space is limited to 20% expansion in each dimension, which covers
|
||||||
|
/// the 15% crumple zone plus some margin for measurement error.
|
||||||
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
||||||
let y = extract_y_channel(jpeg_bytes)?;
|
let y = extract_y_channel(jpeg_bytes)?;
|
||||||
|
|
||||||
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
|
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
|
||||||
return Err(IdfotoError::ExtractionFailed);
|
return Err(RelicarioError::ExtractionFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try assuming the image is uncropped (original size = current size)
|
// Try 1: assume the image is uncropped (original size = current size)
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
|
||||||
return Ok(secret);
|
return Ok(secret);
|
||||||
}
|
}
|
||||||
@@ -522,7 +870,7 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
|||||||
let max_orig_w = (y.width as f64 * 1.20) as usize;
|
let max_orig_w = (y.width as f64 * 1.20) as usize;
|
||||||
let max_orig_h = (y.height as f64 * 1.20) as usize;
|
let max_orig_h = (y.height as f64 * 1.20) as usize;
|
||||||
|
|
||||||
// Try width-only crops first (most common: crop from one side)
|
// Try 2: width-only crops, block-aligned steps (most common crop scenario)
|
||||||
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
|
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
|
||||||
// Right-side crop: dx = 0 (left edge unchanged)
|
// Right-side crop: dx = 0 (left edge unchanged)
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
||||||
@@ -530,24 +878,24 @@ fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try height-only crops
|
// Try 3: height-only crops, block-aligned steps
|
||||||
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
|
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
|
||||||
return Ok(secret);
|
return Ok(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try width crops with finer step (non-8-aligned crops)
|
// Try 4: width crops with finer step (non-8-aligned crops are rarer but possible)
|
||||||
for orig_w in (y.width..=max_orig_w).step_by(1) {
|
for orig_w in (y.width..=max_orig_w).step_by(1) {
|
||||||
if orig_w % BLOCK_SIZE == 0 {
|
if orig_w % BLOCK_SIZE == 0 {
|
||||||
continue; // already tried
|
continue; // already tried in step 2
|
||||||
}
|
}
|
||||||
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
|
||||||
return Ok(secret);
|
return Ok(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(IdfotoError::ExtractionFailed)
|
Err(RelicarioError::ExtractionFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
@@ -732,6 +1080,30 @@ mod tests {
|
|||||||
assert_eq!(extracted, secret);
|
assert_eq!(extracted, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_image_without_full_decode() {
|
||||||
|
// Synthesize a JPEG header claiming 20000x20000 dimensions.
|
||||||
|
// The actual pixel data is irrelevant — the dimension peek should bail out
|
||||||
|
// before decoding any pixels.
|
||||||
|
let jpeg = build_oversized_jpeg_header(20_000, 20_000);
|
||||||
|
let result = extract(&jpeg);
|
||||||
|
assert!(matches!(result, Err(RelicarioError::ImgSecret(ref msg)) if msg.contains("dimension")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_oversized_jpeg_header(width: u16, height: u16) -> Vec<u8> {
|
||||||
|
// SOI + APP0 JFIF + SOF0 declaring width/height + SOS with minimal data + EOI
|
||||||
|
let mut v = vec![0xFF, 0xD8]; // SOI
|
||||||
|
v.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]); // APP0
|
||||||
|
v.extend_from_slice(b"JFIF\0");
|
||||||
|
v.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
|
||||||
|
v.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11, 0x08]); // SOF0
|
||||||
|
v.extend_from_slice(&height.to_be_bytes());
|
||||||
|
v.extend_from_slice(&width.to_be_bytes());
|
||||||
|
v.extend_from_slice(&[0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01]);
|
||||||
|
v.extend_from_slice(&[0xFF, 0xD9]); // EOI
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn embed_extract_survives_10pct_crop() {
|
fn embed_extract_survives_10pct_crop() {
|
||||||
let jpeg = make_test_jpeg(400, 300);
|
let jpeg = make_test_jpeg(400, 300);
|
||||||
220
crates/relicario-core/src/import_lastpass.rs
Normal file
220
crates/relicario-core/src/import_lastpass.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! LastPass CSV importer.
|
||||||
|
//!
|
||||||
|
//! Pure: takes CSV bytes, returns a vector of `Item` (with freshly-minted
|
||||||
|
//! IDs and timestamps) plus a vector of `ImportWarning` for skipped or
|
||||||
|
//! partially-imported rows. Failed rows never abort the whole import;
|
||||||
|
//! the only fatal error is a missing or malformed header.
|
||||||
|
//!
|
||||||
|
//! Spec: docs/superpowers/specs/2026-04-27-relicario-import-export-design.md
|
||||||
|
//! (D10–D13 + the LastPass field-mapping table).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::item::Item;
|
||||||
|
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
|
||||||
|
|
||||||
|
/// LastPass column order. The header row must contain these exact column
|
||||||
|
/// names in this exact order.
|
||||||
|
pub const EXPECTED_HEADER: &[&str] =
|
||||||
|
&["url", "username", "password", "totp", "extra", "name", "grouping", "fav"];
|
||||||
|
|
||||||
|
/// A row that was skipped, or partially imported with a downgrade
|
||||||
|
/// (e.g., login imported without TOTP).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImportWarning {
|
||||||
|
/// 1-indexed row number in the CSV body (the header is row 0).
|
||||||
|
pub row: usize,
|
||||||
|
/// Title from the row's `name` column, if present and non-empty.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Human-readable explanation, suitable for stderr / inline UI.
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a LastPass CSV export.
|
||||||
|
///
|
||||||
|
/// Returns the parsed items (with fresh IDs and timestamps) and any
|
||||||
|
/// per-row warnings. The function only fails if the header is missing
|
||||||
|
/// or doesn't match `EXPECTED_HEADER`.
|
||||||
|
pub fn parse_lastpass_csv(csv_bytes: &[u8]) -> Result<(Vec<Item>, Vec<ImportWarning>)> {
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.has_headers(true)
|
||||||
|
.flexible(false)
|
||||||
|
.from_reader(csv_bytes);
|
||||||
|
|
||||||
|
// Validate header.
|
||||||
|
let headers = reader
|
||||||
|
.headers()
|
||||||
|
.map_err(|e| RelicarioError::ImportCsvFormat(format!("read header: {e}")))?
|
||||||
|
.clone();
|
||||||
|
if headers.len() != EXPECTED_HEADER.len()
|
||||||
|
|| headers.iter().zip(EXPECTED_HEADER).any(|(got, want)| got != *want)
|
||||||
|
{
|
||||||
|
return Err(RelicarioError::ImportCsvHeader(format!(
|
||||||
|
"expected `{}`, got `{}`",
|
||||||
|
EXPECTED_HEADER.join(","),
|
||||||
|
headers.iter().collect::<Vec<_>>().join(",")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
for (idx, record) in reader.records().enumerate() {
|
||||||
|
let row_num = idx + 1;
|
||||||
|
let record = match record {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
warnings.push(ImportWarning {
|
||||||
|
row: row_num,
|
||||||
|
title: None,
|
||||||
|
message: format!("CSV parse error — skipped: {e}"),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (item, warn) = map_row(&record, row_num);
|
||||||
|
if let Some(it) = item { items.push(it); }
|
||||||
|
if let Some(w) = warn { warnings.push(w); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((items, warnings))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a single CSV record. Returns:
|
||||||
|
/// - `(Some(item), None)` for a fully-imported row.
|
||||||
|
/// - `(Some(item), Some(warn))` for a partially-imported row (e.g.,
|
||||||
|
/// bad TOTP base32 — login imported without TOTP).
|
||||||
|
/// - `(None, Some(warn))` for a skipped row (missing required field).
|
||||||
|
fn map_row(
|
||||||
|
record: &csv::StringRecord,
|
||||||
|
row: usize,
|
||||||
|
) -> (Option<Item>, Option<ImportWarning>) {
|
||||||
|
let url = record.get(0).unwrap_or("").trim();
|
||||||
|
let username = record.get(1).unwrap_or("").trim();
|
||||||
|
// password and extra are deliberately NOT trimmed: leading/trailing
|
||||||
|
// whitespace is significant inside passwords and free-form notes.
|
||||||
|
let password = record.get(2).unwrap_or("");
|
||||||
|
let totp_raw = record.get(3).unwrap_or("").trim();
|
||||||
|
let extra = record.get(4).unwrap_or("");
|
||||||
|
let name = record.get(5).unwrap_or("").trim();
|
||||||
|
let group = record.get(6).unwrap_or("").trim();
|
||||||
|
let fav = record.get(7).unwrap_or("").trim();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return (None, Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: None,
|
||||||
|
message: "missing `name` — skipped".into(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecureNote marker: LastPass exports notes with `url` set to "http://sn".
|
||||||
|
// The `extra` column carries the body verbatim.
|
||||||
|
if url == "http://sn" {
|
||||||
|
let mut item = Item::new(
|
||||||
|
name.to_string(),
|
||||||
|
ItemCore::SecureNote(SecureNoteCore {
|
||||||
|
body: Zeroizing::new(extra.to_string()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
|
||||||
|
item.favorite = fav == "1";
|
||||||
|
return (Some(item), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if password.is_empty() {
|
||||||
|
return (None, Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: Some(name.to_string()),
|
||||||
|
message: "missing `password` — skipped".into(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warning: Option<ImportWarning> = None;
|
||||||
|
|
||||||
|
let parsed_url = if url.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match Url::parse(url) {
|
||||||
|
Ok(u) => Some(u),
|
||||||
|
Err(_) => {
|
||||||
|
// Login still imports — URL becomes None, with a warning.
|
||||||
|
if warning.is_none() {
|
||||||
|
warning = Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: Some(name.to_string()),
|
||||||
|
message: format!("invalid URL `{url}` — login imported without URL"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let totp = if totp_raw.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
match decode_base32_totp(totp_raw) {
|
||||||
|
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
||||||
|
secret: Zeroizing::new(bytes),
|
||||||
|
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: crate::item_types::TotpKind::Totp,
|
||||||
|
}),
|
||||||
|
_ => {
|
||||||
|
if warning.is_none() {
|
||||||
|
warning = Some(ImportWarning {
|
||||||
|
row,
|
||||||
|
title: Some(name.to_string()),
|
||||||
|
message: "invalid base32 TOTP secret — login imported without TOTP"
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut item = Item::new(
|
||||||
|
name.to_string(),
|
||||||
|
ItemCore::Login(LoginCore {
|
||||||
|
username: if username.is_empty() { None } else { Some(username.to_string()) },
|
||||||
|
password: Some(Zeroizing::new(password.to_string())),
|
||||||
|
url: parsed_url,
|
||||||
|
totp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
item.group = if group.is_empty() { None } else { Some(group.to_string()) };
|
||||||
|
item.favorite = fav == "1";
|
||||||
|
item.notes = if extra.is_empty() { None } else { Some(extra.to_string()) };
|
||||||
|
|
||||||
|
(Some(item), warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive,
|
||||||
|
/// padding optional. Returns None if the input contains any non-alphabet
|
||||||
|
/// character (after upper-casing). Used by the LastPass importer.
|
||||||
|
fn decode_base32_totp(secret: &str) -> Option<Vec<u8>> {
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
|
||||||
|
if upper.is_empty() { return None; }
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for ch in upper.bytes() {
|
||||||
|
let idx = ALPHA.iter().position(|&a| a == ch)?;
|
||||||
|
buffer = (buffer << 5) | (idx as u32);
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push(((buffer >> bits) & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
497
crates/relicario-core/src/item.rs
Normal file
497
crates/relicario-core/src/item.rs
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
//! Item envelope, sections, and custom fields.
|
||||||
|
//!
|
||||||
|
//! `FieldKind` and `FieldValue` are kept as parallel enums (rather than collapsing
|
||||||
|
//! to a single tagged enum) so the kind can be queried without inspecting the value.
|
||||||
|
//! Validation invariant: kind and value's discriminants must match — enforced at
|
||||||
|
//! construction (`Field::new`) and during deserialization (`Field::validate`).
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
use crate::ids::{AttachmentId, FieldId};
|
||||||
|
use crate::item_types::TotpConfig;
|
||||||
|
use crate::time::MonthYear;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FieldKind {
|
||||||
|
Text,
|
||||||
|
Multiline,
|
||||||
|
Password,
|
||||||
|
Concealed,
|
||||||
|
Url,
|
||||||
|
Email,
|
||||||
|
Phone,
|
||||||
|
Date,
|
||||||
|
MonthYear,
|
||||||
|
Totp,
|
||||||
|
Reference,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
|
pub enum FieldValue {
|
||||||
|
Text(String),
|
||||||
|
Multiline(String),
|
||||||
|
Password(Zeroizing<String>),
|
||||||
|
Concealed(Zeroizing<String>),
|
||||||
|
Url(Url),
|
||||||
|
Email(String),
|
||||||
|
Phone(String),
|
||||||
|
Date(NaiveDate),
|
||||||
|
MonthYear(MonthYear),
|
||||||
|
Totp(TotpConfig),
|
||||||
|
Reference(AttachmentId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldValue {
|
||||||
|
pub fn kind(&self) -> FieldKind {
|
||||||
|
match self {
|
||||||
|
FieldValue::Text(_) => FieldKind::Text,
|
||||||
|
FieldValue::Multiline(_) => FieldKind::Multiline,
|
||||||
|
FieldValue::Password(_) => FieldKind::Password,
|
||||||
|
FieldValue::Concealed(_) => FieldKind::Concealed,
|
||||||
|
FieldValue::Url(_) => FieldKind::Url,
|
||||||
|
FieldValue::Email(_) => FieldKind::Email,
|
||||||
|
FieldValue::Phone(_) => FieldKind::Phone,
|
||||||
|
FieldValue::Date(_) => FieldKind::Date,
|
||||||
|
FieldValue::MonthYear(_) => FieldKind::MonthYear,
|
||||||
|
FieldValue::Totp(_) => FieldKind::Totp,
|
||||||
|
FieldValue::Reference(_) => FieldKind::Reference,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this kind triggers field-history capture on update.
|
||||||
|
pub fn is_history_tracked(&self) -> bool {
|
||||||
|
matches!(self, FieldValue::Password(_) | FieldValue::Concealed(_) | FieldValue::Totp(_))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Field {
|
||||||
|
pub id: FieldId,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: FieldKind,
|
||||||
|
pub value: FieldValue,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hidden_by_default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Field {
|
||||||
|
/// Construct a field, deriving `kind` from `value`.
|
||||||
|
pub fn new(label: String, value: FieldValue) -> Self {
|
||||||
|
let kind = value.kind();
|
||||||
|
Self {
|
||||||
|
id: FieldId::new(),
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
value,
|
||||||
|
hidden_by_default: matches!(kind, FieldKind::Password | FieldKind::Concealed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify kind/value discriminants match. Called after deserialization.
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if self.kind != self.value.kind() {
|
||||||
|
return Err(RelicarioError::Format(format!(
|
||||||
|
"field {}: kind {:?} does not match value discriminant {:?}",
|
||||||
|
self.id.as_str(),
|
||||||
|
self.kind,
|
||||||
|
self.value.kind()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Section {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub fields: Vec<Field>,
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::attachment::AttachmentRef;
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item_types::{ItemCore, ItemType};
|
||||||
|
use crate::time::now_unix;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FieldHistoryEntry {
|
||||||
|
pub value: Zeroizing<String>,
|
||||||
|
pub replaced_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Item {
|
||||||
|
pub id: ItemId,
|
||||||
|
pub title: String,
|
||||||
|
pub r#type: ItemType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub favorite: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub group: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub created: i64,
|
||||||
|
pub modified: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
|
pub core: ItemCore,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sections: Vec<Section>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachments: Vec<AttachmentRef>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub field_history: HashMap<FieldId, Vec<FieldHistoryEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
/// Construct a new Item from a typed core; auto-fills id, type, timestamps.
|
||||||
|
pub fn new(title: String, core: ItemCore) -> Self {
|
||||||
|
let now = now_unix();
|
||||||
|
let r#type = core.item_type();
|
||||||
|
Self {
|
||||||
|
id: ItemId::new(),
|
||||||
|
title,
|
||||||
|
r#type,
|
||||||
|
tags: Vec::new(),
|
||||||
|
favorite: false,
|
||||||
|
group: None,
|
||||||
|
notes: None,
|
||||||
|
created: now,
|
||||||
|
modified: now,
|
||||||
|
trashed_at: None,
|
||||||
|
core,
|
||||||
|
sections: Vec::new(),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
field_history: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a custom field's value, capturing the previous value into
|
||||||
|
/// field_history if the field's kind is history-tracked.
|
||||||
|
pub fn set_field_value(&mut self, field_id: &FieldId, new_value: FieldValue) -> Result<()> {
|
||||||
|
for section in &mut self.sections {
|
||||||
|
if let Some(field) = section.fields.iter_mut().find(|f| &f.id == field_id) {
|
||||||
|
if field.value.kind() != new_value.kind() {
|
||||||
|
return Err(RelicarioError::Format(format!(
|
||||||
|
"field {}: cannot change kind from {:?} to {:?}",
|
||||||
|
field.id.as_str(), field.value.kind(), new_value.kind()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if field.value.is_history_tracked() {
|
||||||
|
let serialized = serialize_history_value(&field.value)?;
|
||||||
|
self.field_history
|
||||||
|
.entry(field.id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(FieldHistoryEntry { value: serialized, replaced_at: now_unix() });
|
||||||
|
}
|
||||||
|
field.value = new_value;
|
||||||
|
self.modified = now_unix();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(RelicarioError::Format(format!("field {} not found", field_id.as_str())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn soft_delete(&mut self) {
|
||||||
|
self.trashed_at = Some(now_unix());
|
||||||
|
self.modified = now_unix();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore(&mut self) {
|
||||||
|
self.trashed_at = None;
|
||||||
|
self.modified = now_unix();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_trashed(&self) -> bool {
|
||||||
|
self.trashed_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune_history(&mut self, retention: &crate::settings::HistoryRetention, now: i64) {
|
||||||
|
use crate::settings::HistoryRetention;
|
||||||
|
for history in self.field_history.values_mut() {
|
||||||
|
match retention {
|
||||||
|
HistoryRetention::Forever => {}
|
||||||
|
HistoryRetention::LastN(n) => {
|
||||||
|
let n = *n as usize;
|
||||||
|
if history.len() > n {
|
||||||
|
let drop_count = history.len() - n;
|
||||||
|
history.drain(..drop_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HistoryRetention::Days(d) => {
|
||||||
|
let cutoff = now - (*d as i64) * 86_400;
|
||||||
|
history.retain(|e| e.replaced_at > cutoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a FieldValue to the string form stored in field_history.
|
||||||
|
fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
||||||
|
let s = match value {
|
||||||
|
FieldValue::Password(p) => Zeroizing::new(p.as_str().to_owned()),
|
||||||
|
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
||||||
|
FieldValue::Totp(cfg) => {
|
||||||
|
// Store the base32-encoded secret string for human-recognizability.
|
||||||
|
let s = base32_encode(&cfg.secret);
|
||||||
|
Zeroizing::new(s)
|
||||||
|
}
|
||||||
|
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
||||||
|
};
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
|
||||||
|
fn base32_encode(bytes: &[u8]) -> String {
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for &b in bytes {
|
||||||
|
buffer = (buffer << 8) | (b as u32);
|
||||||
|
bits += 8;
|
||||||
|
while bits >= 5 {
|
||||||
|
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
||||||
|
out.push(ALPHA[idx] as char);
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
||||||
|
out.push(ALPHA[idx] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_value_kind_matches() {
|
||||||
|
let v = FieldValue::Text("hello".into());
|
||||||
|
assert_eq!(v.kind(), FieldKind::Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_field_marked_history_tracked() {
|
||||||
|
assert!(FieldValue::Password(Zeroizing::new("x".into())).is_history_tracked());
|
||||||
|
assert!(FieldValue::Concealed(Zeroizing::new("x".into())).is_history_tracked());
|
||||||
|
assert!(FieldValue::Totp(TotpConfig::default()).is_history_tracked());
|
||||||
|
assert!(!FieldValue::Text("x".into()).is_history_tracked());
|
||||||
|
assert!(!FieldValue::Url(Url::parse("https://example.com").unwrap()).is_history_tracked());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_new_derives_kind_from_value() {
|
||||||
|
let f = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("x".into())));
|
||||||
|
assert_eq!(f.kind, FieldKind::Password);
|
||||||
|
assert!(f.hidden_by_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_new_text_not_hidden() {
|
||||||
|
let f = Field::new("Username".into(), FieldValue::Text("alice".into()));
|
||||||
|
assert!(!f.hidden_by_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_validate_catches_kind_value_mismatch() {
|
||||||
|
let f = Field {
|
||||||
|
id: FieldId::new(),
|
||||||
|
label: "x".into(),
|
||||||
|
kind: FieldKind::Password,
|
||||||
|
value: FieldValue::Text("not actually a password".into()),
|
||||||
|
hidden_by_default: false,
|
||||||
|
};
|
||||||
|
assert!(f.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_round_trips() {
|
||||||
|
let f = Field::new("Recovery code".into(), FieldValue::Concealed(Zeroizing::new("abcd-efgh".into())));
|
||||||
|
let json = serde_json::to_string(&f).unwrap();
|
||||||
|
let parsed: Field = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.label, "Recovery code");
|
||||||
|
assert_eq!(parsed.kind, FieldKind::Concealed);
|
||||||
|
parsed.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn section_round_trip() {
|
||||||
|
let s = Section {
|
||||||
|
name: Some("Recovery codes".into()),
|
||||||
|
fields: vec![
|
||||||
|
Field::new("code1".into(), FieldValue::Concealed(Zeroizing::new("abc".into()))),
|
||||||
|
Field::new("code2".into(), FieldValue::Concealed(Zeroizing::new("def".into()))),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
let parsed: Section = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.name.as_deref(), Some("Recovery codes"));
|
||||||
|
assert_eq!(parsed.fields.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_item_has_timestamps_and_id() {
|
||||||
|
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
|
||||||
|
let item = Item::new("note".into(), core);
|
||||||
|
assert_eq!(item.id.0.len(), 16);
|
||||||
|
assert_eq!(item.r#type, ItemType::SecureNote);
|
||||||
|
assert!(item.created > 0);
|
||||||
|
assert_eq!(item.created, item.modified);
|
||||||
|
assert!(item.field_history.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn soft_delete_and_restore_round_trip() {
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("login".into(), core);
|
||||||
|
assert!(!item.is_trashed());
|
||||||
|
item.soft_delete();
|
||||||
|
assert!(item.is_trashed());
|
||||||
|
item.restore();
|
||||||
|
assert!(!item.is_trashed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_field_value_captures_history_for_password() {
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("login".into(), core);
|
||||||
|
let pw_field = Field::new("Password".into(), FieldValue::Password(Zeroizing::new("old".into())));
|
||||||
|
let pw_id = pw_field.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![pw_field] });
|
||||||
|
|
||||||
|
item.set_field_value(&pw_id, FieldValue::Password(Zeroizing::new("new".into()))).unwrap();
|
||||||
|
let hist = item.field_history.get(&pw_id).expect("history should exist");
|
||||||
|
assert_eq!(hist.len(), 1);
|
||||||
|
assert_eq!(hist[0].value.as_str(), "old");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_field_value_does_not_capture_history_for_text() {
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("login".into(), core);
|
||||||
|
let f = Field::new("nickname".into(), FieldValue::Text("a".into()));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
|
||||||
|
item.set_field_value(&fid, FieldValue::Text("b".into())).unwrap();
|
||||||
|
assert!(item.field_history.get(&fid).is_none_or(|v| v.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_field_value_rejects_kind_change() {
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("login".into(), core);
|
||||||
|
let f = Field::new("x".into(), FieldValue::Text("a".into()));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
|
||||||
|
let err = item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("p".into())));
|
||||||
|
assert!(err.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_serializes_with_minimal_optional_fields() {
|
||||||
|
let core = ItemCore::SecureNote(crate::item_types::SecureNoteCore::default());
|
||||||
|
let item = Item::new("note".into(), core);
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
// No "trashed_at" or "group" or "notes" should appear when None
|
||||||
|
assert!(!json.contains("trashed_at"));
|
||||||
|
assert!(!json.contains("\"group\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_item_round_trip() {
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some(Zeroizing::new("hunter2".into())),
|
||||||
|
url: Some(Url::parse("https://github.com").unwrap()),
|
||||||
|
totp: None,
|
||||||
|
});
|
||||||
|
let mut item = Item::new("GitHub".into(), core);
|
||||||
|
item.tags = vec!["work".into()];
|
||||||
|
item.favorite = true;
|
||||||
|
item.notes = Some("notes".into());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
let parsed: Item = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.title, "GitHub");
|
||||||
|
assert_eq!(parsed.tags, vec!["work".to_string()]);
|
||||||
|
assert!(parsed.favorite);
|
||||||
|
match parsed.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
assert_eq!(l.username.as_deref(), Some("alice"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_history_keeps_last_n() {
|
||||||
|
use crate::settings::HistoryRetention;
|
||||||
|
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("x".into(), core);
|
||||||
|
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
|
||||||
|
for i in 1..=5 {
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(item.field_history[&fid].len(), 5);
|
||||||
|
|
||||||
|
item.prune_history(&HistoryRetention::LastN(3), 0);
|
||||||
|
assert_eq!(item.field_history[&fid].len(), 3);
|
||||||
|
// Keeps the MOST RECENT 3
|
||||||
|
assert_eq!(item.field_history[&fid][0].value.as_str(), "v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_history_drops_old_entries_by_days() {
|
||||||
|
use crate::settings::HistoryRetention;
|
||||||
|
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("x".into(), core);
|
||||||
|
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
|
||||||
|
let now = 1_000_000_000;
|
||||||
|
item.field_history.insert(fid.clone(), vec![
|
||||||
|
FieldHistoryEntry { value: Zeroizing::new("old".into()), replaced_at: now - 100 * 86_400 },
|
||||||
|
FieldHistoryEntry { value: Zeroizing::new("recent".into()), replaced_at: now - 86_400 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
item.prune_history(&HistoryRetention::Days(30), now);
|
||||||
|
assert_eq!(item.field_history[&fid].len(), 1);
|
||||||
|
assert_eq!(item.field_history[&fid][0].value.as_str(), "recent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_history_forever_keeps_all() {
|
||||||
|
use crate::settings::HistoryRetention;
|
||||||
|
|
||||||
|
let core = ItemCore::Login(crate::item_types::LoginCore::default());
|
||||||
|
let mut item = Item::new("x".into(), core);
|
||||||
|
item.field_history.insert(FieldId::new(), vec![
|
||||||
|
FieldHistoryEntry { value: Zeroizing::new("a".into()), replaced_at: 0 },
|
||||||
|
FieldHistoryEntry { value: Zeroizing::new("b".into()), replaced_at: 0 },
|
||||||
|
]);
|
||||||
|
item.prune_history(&HistoryRetention::Forever, 1_000_000_000);
|
||||||
|
assert_eq!(item.field_history.values().next().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
crates/relicario-core/src/item_types/card.rs
Normal file
68
crates/relicario-core/src/item_types/card.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//! Card: number, holder, expiry (MonthYear), CVV, PIN, kind.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::time::MonthYear;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct CardCore {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub number: Option<Zeroizing<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub holder: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expiry: Option<MonthYear>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cvv: Option<Zeroizing<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pin: Option<Zeroizing<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: CardKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CardKind {
|
||||||
|
#[default]
|
||||||
|
Credit,
|
||||||
|
Debit,
|
||||||
|
Gift,
|
||||||
|
Loyalty,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_full_round_trip() {
|
||||||
|
let card = CardCore {
|
||||||
|
number: Some(Zeroizing::new("4111111111111111".into())),
|
||||||
|
holder: Some("Alice Doe".into()),
|
||||||
|
expiry: Some(MonthYear::new(12, 2030).unwrap()),
|
||||||
|
cvv: Some(Zeroizing::new("123".into())),
|
||||||
|
pin: Some(Zeroizing::new("0000".into())),
|
||||||
|
kind: CardKind::Credit,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&card).unwrap();
|
||||||
|
let parsed: CardCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.holder.as_deref(), Some("Alice Doe"));
|
||||||
|
assert_eq!(parsed.kind, CardKind::Credit);
|
||||||
|
assert_eq!(parsed.expiry, Some(MonthYear::new(12, 2030).unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_kind_default_is_credit() {
|
||||||
|
let json = "{}";
|
||||||
|
let card: CardCore = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(card.kind, CardKind::Credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn card_kind_serializes_snake_case() {
|
||||||
|
let json = serde_json::to_string(&CardKind::Loyalty).unwrap();
|
||||||
|
assert_eq!(json, "\"loyalty\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/relicario-core/src/item_types/document.rs
Normal file
40
crates/relicario-core/src/item_types/document.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//! Document: filename + mime + pointer to the primary attachment blob.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::ids::AttachmentId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DocumentCore {
|
||||||
|
pub filename: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub primary_attachment: AttachmentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DocumentCore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
filename: String::new(),
|
||||||
|
mime_type: "application/octet-stream".into(),
|
||||||
|
primary_attachment: AttachmentId(String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_round_trip() {
|
||||||
|
let doc = DocumentCore {
|
||||||
|
filename: "passport.pdf".into(),
|
||||||
|
mime_type: "application/pdf".into(),
|
||||||
|
primary_attachment: AttachmentId("0123456789abcdef".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&doc).unwrap();
|
||||||
|
let parsed: DocumentCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.filename, "passport.pdf");
|
||||||
|
assert_eq!(parsed.primary_attachment.as_str(), "0123456789abcdef");
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/relicario-core/src/item_types/identity.rs
Normal file
45
crates/relicario-core/src/item_types/identity.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Identity: name, address, phone, email, date-of-birth.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct IdentityCore {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub address: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub date_of_birth: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_full_round_trip() {
|
||||||
|
let id = IdentityCore {
|
||||||
|
full_name: Some("Alice Doe".into()),
|
||||||
|
address: Some("123 Main St\nAnytown".into()),
|
||||||
|
phone: Some("+1-555-0100".into()),
|
||||||
|
email: Some("alice@example.com".into()),
|
||||||
|
date_of_birth: NaiveDate::from_ymd_opt(1990, 4, 18),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&id).unwrap();
|
||||||
|
let parsed: IdentityCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.full_name.as_deref(), Some("Alice Doe"));
|
||||||
|
assert_eq!(parsed.date_of_birth, NaiveDate::from_ymd_opt(1990, 4, 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_identity_omits_all_fields() {
|
||||||
|
let id = IdentityCore::default();
|
||||||
|
let json = serde_json::to_string(&id).unwrap();
|
||||||
|
assert_eq!(json, "{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/relicario-core/src/item_types/key.rs
Normal file
42
crates/relicario-core/src/item_types/key.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//! Key: arbitrary key material (Zeroizing), label, public key, algorithm.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct KeyCore {
|
||||||
|
pub key_material: Zeroizing<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub algorithm: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_round_trip() {
|
||||||
|
let k = KeyCore {
|
||||||
|
key_material: Zeroizing::new("-----BEGIN OPENSSH PRIVATE KEY-----\n...".into()),
|
||||||
|
label: Some("yubikey-backup".into()),
|
||||||
|
public_key: Some("ssh-ed25519 AAAAC3...".into()),
|
||||||
|
algorithm: Some("ed25519".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&k).unwrap();
|
||||||
|
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.key_material.starts_with("-----BEGIN"));
|
||||||
|
assert_eq!(parsed.algorithm.as_deref(), Some("ed25519"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_key_material_round_trips() {
|
||||||
|
let k = KeyCore::default();
|
||||||
|
let json = serde_json::to_string(&k).unwrap();
|
||||||
|
let parsed: KeyCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.key_material.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/relicario-core/src/item_types/login.rs
Normal file
63
crates/relicario-core/src/item_types/login.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! Login item core: username, password (Zeroizing), URL, optional TOTP.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::item_types::TotpConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LoginCore {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub password: Option<Zeroizing<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<Url>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub totp: Option<TotpConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_login_round_trips() {
|
||||||
|
let login = LoginCore::default();
|
||||||
|
let json = serde_json::to_string(&login).unwrap();
|
||||||
|
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.username.is_none());
|
||||||
|
assert!(parsed.password.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_login_round_trips() {
|
||||||
|
let login = LoginCore {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some(Zeroizing::new("hunter2".into())),
|
||||||
|
url: Some(Url::parse("https://github.com/login").unwrap()),
|
||||||
|
totp: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&login).unwrap();
|
||||||
|
let parsed: LoginCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.username.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(parsed.password.as_deref().map(String::as_str), Some("hunter2"));
|
||||||
|
assert_eq!(parsed.url.as_ref().map(Url::as_str), Some("https://github.com/login"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn omitted_fields_dont_appear_in_json() {
|
||||||
|
let login = LoginCore {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: None,
|
||||||
|
url: None,
|
||||||
|
totp: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&login).unwrap();
|
||||||
|
assert!(!json.contains("password"));
|
||||||
|
assert!(!json.contains("url"));
|
||||||
|
assert!(!json.contains("totp"));
|
||||||
|
assert!(json.contains("alice"));
|
||||||
|
}
|
||||||
|
}
|
||||||
127
crates/relicario-core/src/item_types/mod.rs
Normal file
127
crates/relicario-core/src/item_types/mod.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//! Per-type "core" structs for typed items.
|
||||||
|
//!
|
||||||
|
//! Each variant lives in its own submodule. The `ItemCore` enum + match
|
||||||
|
//! exhaustiveness is the extension mechanism — adding a new variant later
|
||||||
|
//! means: create the submodule, add the enum variant, fix the match arms
|
||||||
|
//! the compiler points at, register the popup form (Plan 1C).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod login;
|
||||||
|
pub mod secure_note;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod card;
|
||||||
|
pub mod key;
|
||||||
|
pub mod document;
|
||||||
|
pub mod totp;
|
||||||
|
|
||||||
|
pub use login::LoginCore;
|
||||||
|
pub use secure_note::SecureNoteCore;
|
||||||
|
pub use identity::IdentityCore;
|
||||||
|
pub use card::{CardCore, CardKind};
|
||||||
|
pub use key::KeyCore;
|
||||||
|
pub use document::DocumentCore;
|
||||||
|
pub use totp::{TotpCore, TotpConfig, TotpAlgorithm, TotpKind, compute_totp_code};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ItemType {
|
||||||
|
Login,
|
||||||
|
SecureNote,
|
||||||
|
Identity,
|
||||||
|
Card,
|
||||||
|
Key,
|
||||||
|
Document,
|
||||||
|
Totp,
|
||||||
|
}
|
||||||
|
|
||||||
|
// INVARIANT: no *Core struct may have a field serialized as "type" —
|
||||||
|
// that key is reserved for serde's internal tag. Use "kind" for
|
||||||
|
// type-discriminant fields within core structs (CardKind, TotpKind).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ItemCore {
|
||||||
|
Login(LoginCore),
|
||||||
|
SecureNote(SecureNoteCore),
|
||||||
|
Identity(IdentityCore),
|
||||||
|
Card(CardCore),
|
||||||
|
Key(KeyCore),
|
||||||
|
Document(DocumentCore),
|
||||||
|
Totp(TotpCore),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemCore {
|
||||||
|
pub fn item_type(&self) -> ItemType {
|
||||||
|
match self {
|
||||||
|
ItemCore::Login(_) => ItemType::Login,
|
||||||
|
ItemCore::SecureNote(_) => ItemType::SecureNote,
|
||||||
|
ItemCore::Identity(_) => ItemType::Identity,
|
||||||
|
ItemCore::Card(_) => ItemType::Card,
|
||||||
|
ItemCore::Key(_) => ItemType::Key,
|
||||||
|
ItemCore::Document(_) => ItemType::Document,
|
||||||
|
ItemCore::Totp(_) => ItemType::Totp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_type_serializes_snake_case() {
|
||||||
|
let json = serde_json::to_string(&ItemType::SecureNote).unwrap();
|
||||||
|
assert_eq!(json, "\"secure_note\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_core_login_round_trip_via_tag() {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
let core = ItemCore::Login(LoginCore {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some(Zeroizing::new("hunter2".into())),
|
||||||
|
url: None,
|
||||||
|
totp: None,
|
||||||
|
});
|
||||||
|
let json = serde_json::to_string(&core).unwrap();
|
||||||
|
// Tag-based: outer object has "type": "login"
|
||||||
|
assert!(json.contains("\"type\":\"login\""));
|
||||||
|
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.item_type(), ItemType::Login);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_core_secure_note_round_trip_via_tag() {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
let core = ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new("hello".into()) });
|
||||||
|
let json = serde_json::to_string(&core).unwrap();
|
||||||
|
assert!(json.contains("\"type\":\"secure_note\""));
|
||||||
|
let parsed: ItemCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.item_type(), ItemType::SecureNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_core_round_trips_for_all_seven_types() {
|
||||||
|
use crate::ids::AttachmentId;
|
||||||
|
|
||||||
|
let cores = vec![
|
||||||
|
ItemCore::Login(LoginCore::default()),
|
||||||
|
ItemCore::SecureNote(SecureNoteCore::default()),
|
||||||
|
ItemCore::Identity(IdentityCore::default()),
|
||||||
|
ItemCore::Card(CardCore::default()),
|
||||||
|
ItemCore::Key(KeyCore::default()),
|
||||||
|
ItemCore::Document(DocumentCore {
|
||||||
|
filename: "x".into(),
|
||||||
|
mime_type: "text/plain".into(),
|
||||||
|
primary_attachment: AttachmentId("0123456789abcdef".into()),
|
||||||
|
}),
|
||||||
|
ItemCore::Totp(TotpCore::default()),
|
||||||
|
];
|
||||||
|
for core in cores {
|
||||||
|
let expected_type = core.item_type();
|
||||||
|
let json = serde_json::to_string(&core).unwrap();
|
||||||
|
let parsed: ItemCore = serde_json::from_str(&json).expect("round-trip failed");
|
||||||
|
assert_eq!(parsed.item_type(), expected_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/relicario-core/src/item_types/secure_note.rs
Normal file
30
crates/relicario-core/src/item_types/secure_note.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! Secure note: just a multiline body, Zeroizing.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SecureNoteCore {
|
||||||
|
pub body: Zeroizing<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_round_trips() {
|
||||||
|
let note = SecureNoteCore { body: Zeroizing::new("a multi\nline note".into()) };
|
||||||
|
let json = serde_json::to_string(¬e).unwrap();
|
||||||
|
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.body.as_str(), "a multi\nline note");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_body_round_trips() {
|
||||||
|
let note = SecureNoteCore::default();
|
||||||
|
let json = serde_json::to_string(¬e).unwrap();
|
||||||
|
let parsed: SecureNoteCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(parsed.body.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
293
crates/relicario-core/src/item_types/totp.rs
Normal file
293
crates/relicario-core/src/item_types/totp.rs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
//! TOTP: standalone 2FA item type. Also reused as TotpConfig field on Login.
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha1::Sha1 as HmacSha1;
|
||||||
|
use sha2::{Sha256 as HmacSha256, Sha512 as HmacSha512};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||||
|
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
||||||
|
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct TotpCore {
|
||||||
|
pub config: TotpConfig,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub issuer: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TotpConfig {
|
||||||
|
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||||
|
pub secret: Zeroizing<Vec<u8>>,
|
||||||
|
pub algorithm: TotpAlgorithm,
|
||||||
|
pub digits: u8,
|
||||||
|
pub period_seconds: u32,
|
||||||
|
pub kind: TotpKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TotpConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
secret: Zeroizing::new(Vec::new()),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 6,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TotpAlgorithm {
|
||||||
|
#[default]
|
||||||
|
Sha1,
|
||||||
|
Sha256,
|
||||||
|
Sha512,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TotpKind {
|
||||||
|
#[default]
|
||||||
|
Totp,
|
||||||
|
Hotp { counter: u64 },
|
||||||
|
Steam,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||||
|
///
|
||||||
|
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||||
|
/// HOTP is not supported — returns [`RelicarioError::HotpNotSupported`].
|
||||||
|
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
|
||||||
|
let counter = match config.kind {
|
||||||
|
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
|
||||||
|
TotpKind::Hotp { .. } => return Err(RelicarioError::HotpNotSupported),
|
||||||
|
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
|
||||||
|
};
|
||||||
|
let counter_bytes = counter.to_be_bytes();
|
||||||
|
let hmac_out: Vec<u8> = match config.algorithm {
|
||||||
|
TotpAlgorithm::Sha1 => {
|
||||||
|
let mut mac = Hmac::<HmacSha1>::new_from_slice(&config.secret)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||||
|
mac.update(&counter_bytes);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
TotpAlgorithm::Sha256 => {
|
||||||
|
let mut mac = Hmac::<HmacSha256>::new_from_slice(&config.secret)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||||
|
mac.update(&counter_bytes);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
TotpAlgorithm::Sha512 => {
|
||||||
|
let mut mac = Hmac::<HmacSha512>::new_from_slice(&config.secret)
|
||||||
|
.map_err(|e| RelicarioError::Format(format!("hmac: {e}")))?;
|
||||||
|
mac.update(&counter_bytes);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let offset = (hmac_out[hmac_out.len() - 1] & 0x0F) as usize;
|
||||||
|
let truncated = ((hmac_out[offset] as u32 & 0x7F) << 24)
|
||||||
|
| ((hmac_out[offset + 1] as u32) << 16)
|
||||||
|
| ((hmac_out[offset + 2] as u32) << 8)
|
||||||
|
| (hmac_out[offset + 3] as u32);
|
||||||
|
if matches!(config.kind, TotpKind::Steam) {
|
||||||
|
let mut t = truncated;
|
||||||
|
let mut out = String::with_capacity(5);
|
||||||
|
for _ in 0..5 {
|
||||||
|
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
|
||||||
|
t /= 26;
|
||||||
|
}
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let modulus = 10u32.pow(config.digits as u32);
|
||||||
|
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod compute_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rfc6238_sha1_vector_59() {
|
||||||
|
let cfg = TotpConfig {
|
||||||
|
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 8,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
};
|
||||||
|
assert_eq!(compute_totp_code(&cfg, 59).unwrap(), "94287082");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totp_default_is_sha1_6_30_totp() {
|
||||||
|
let cfg = TotpConfig::default();
|
||||||
|
assert_eq!(cfg.algorithm, TotpAlgorithm::Sha1);
|
||||||
|
assert_eq!(cfg.digits, 6);
|
||||||
|
assert_eq!(cfg.period_seconds, 30);
|
||||||
|
assert_eq!(cfg.kind, TotpKind::Totp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totp_round_trip() {
|
||||||
|
let core = TotpCore {
|
||||||
|
config: TotpConfig {
|
||||||
|
secret: Zeroizing::new(vec![0x12, 0x34, 0x56]),
|
||||||
|
algorithm: TotpAlgorithm::Sha256,
|
||||||
|
digits: 8,
|
||||||
|
period_seconds: 60,
|
||||||
|
kind: TotpKind::Totp,
|
||||||
|
},
|
||||||
|
issuer: Some("github".into()),
|
||||||
|
label: Some("alice@github".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&core).unwrap();
|
||||||
|
let parsed: TotpCore = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.config.digits, 8);
|
||||||
|
assert_eq!(parsed.config.algorithm, TotpAlgorithm::Sha256);
|
||||||
|
assert_eq!(parsed.issuer.as_deref(), Some("github"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hotp_kind_roundtrips_through_json() {
|
||||||
|
let cfg = TotpConfig { kind: TotpKind::Hotp { counter: 42 }, ..TotpConfig::default() };
|
||||||
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
|
let parsed: TotpConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed.kind {
|
||||||
|
TotpKind::Hotp { counter } => assert_eq!(counter, 42),
|
||||||
|
other => panic!("expected Hotp, got {:?}", other),
|
||||||
|
}
|
||||||
|
// Note: compute_totp_code will reject this — HOTP not supported
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hotp_returns_not_supported_error() {
|
||||||
|
let cfg = TotpConfig {
|
||||||
|
secret: Zeroizing::new(b"12345678901234567890".to_vec()),
|
||||||
|
kind: TotpKind::Hotp { counter: 0 },
|
||||||
|
..TotpConfig::default()
|
||||||
|
};
|
||||||
|
let result = compute_totp_code(&cfg, 0);
|
||||||
|
assert!(matches!(result, Err(RelicarioError::HotpNotSupported)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_kind_serializes() {
|
||||||
|
let cfg = TotpConfig { kind: TotpKind::Steam, ..TotpConfig::default() };
|
||||||
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
|
assert!(json.contains("steam"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod steam_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Reference implementation of the Steam 5-character output, per the
|
||||||
|
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
|
||||||
|
/// Used by tests below to cross-check the production impl without
|
||||||
|
/// requiring a third-party vector. The algorithm is short enough to
|
||||||
|
/// be reproduced here in isolation.
|
||||||
|
fn steam_output_reference(truncated: u32) -> String {
|
||||||
|
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
let mut t = truncated;
|
||||||
|
let mut out = String::with_capacity(5);
|
||||||
|
for _ in 0..5 {
|
||||||
|
out.push(ALPHA[(t % 26) as usize] as char);
|
||||||
|
t /= 26;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
|
||||||
|
/// does internally — used to drive the reference impl.
|
||||||
|
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha1::Sha1;
|
||||||
|
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
|
||||||
|
mac.update(&counter.to_be_bytes());
|
||||||
|
let bytes = mac.finalize().into_bytes();
|
||||||
|
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
|
||||||
|
((bytes[offset] as u32 & 0x7F) << 24)
|
||||||
|
| ((bytes[offset + 1] as u32) << 16)
|
||||||
|
| ((bytes[offset + 2] as u32) << 8)
|
||||||
|
| (bytes[offset + 3] as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_output_matches_reference_impl() {
|
||||||
|
let secret = b"12345678901234567890".to_vec();
|
||||||
|
let cfg = TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret.clone()),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 5,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Steam,
|
||||||
|
};
|
||||||
|
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
|
||||||
|
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
|
||||||
|
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
|
||||||
|
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
|
||||||
|
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
|
||||||
|
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
|
||||||
|
let secret = b"hello world!".to_vec();
|
||||||
|
for digits in [4u8, 5, 6, 7, 8] {
|
||||||
|
let cfg = TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret.clone()),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Steam,
|
||||||
|
};
|
||||||
|
let code = compute_totp_code(&cfg, 0).unwrap();
|
||||||
|
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_output_uses_only_alphabet_chars() {
|
||||||
|
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
let secret = b"hello world!".to_vec();
|
||||||
|
let cfg = TotpConfig {
|
||||||
|
secret: Zeroizing::new(secret),
|
||||||
|
algorithm: TotpAlgorithm::Sha1,
|
||||||
|
digits: 5,
|
||||||
|
period_seconds: 30,
|
||||||
|
kind: TotpKind::Steam,
|
||||||
|
};
|
||||||
|
for t in 0u64..1000 {
|
||||||
|
let code = compute_totp_code(&cfg, t * 30).unwrap();
|
||||||
|
for ch in code.chars() {
|
||||||
|
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steam_alphabet_excludes_ambiguous_glyphs() {
|
||||||
|
// Authoritative Steam Guard alphabet from Valve's Steam Mobile
|
||||||
|
// Authenticator: 26 chars, excludes 0/O, 1/I/L, S, A, E, U, Z.
|
||||||
|
// (Note: '5' IS in the alphabet — S is excluded, so 5 is unambiguous.)
|
||||||
|
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
for ch in ['0', 'O', '1', 'I', 'L', 'S', 'A', 'Z'] {
|
||||||
|
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
crates/relicario-core/src/lib.rs
Normal file
99
crates/relicario-core/src/lib.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! # relicario-core
|
||||||
|
//!
|
||||||
|
//! Platform-agnostic core library for the Relicario password manager.
|
||||||
|
//!
|
||||||
|
//! This crate is intentionally **bytes-in/bytes-out** -- it performs no filesystem
|
||||||
|
//! access, no network I/O, and no git operations. All inputs arrive as byte slices
|
||||||
|
//! or typed structs, and all outputs are returned as byte vectors or typed structs.
|
||||||
|
//! This design makes the crate portable to WASM, Android (via JNI/UniFFI), and iOS
|
||||||
|
//! without any conditional compilation or platform shims.
|
||||||
|
//!
|
||||||
|
//! ## Modules
|
||||||
|
//!
|
||||||
|
//! - [`error`] — The unified error type ([`RelicarioError`]).
|
||||||
|
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||||
|
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||||
|
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
||||||
|
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
||||||
|
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||||
|
//! `ItemCore`/`ItemType` enums.
|
||||||
|
//! - [`item`] — `Item` envelope, `Field`, `FieldKind`, `FieldValue`, `Section`,
|
||||||
|
//! `FieldHistoryEntry`.
|
||||||
|
//! - [`attachment`] — `AttachmentRef`, `AttachmentSummary`, encrypt/decrypt helpers.
|
||||||
|
//! - [`manifest`] — Browse-without-decrypt index (schema_version 2).
|
||||||
|
//! - [`settings`] — Vault-level retention, generator defaults, attachment caps.
|
||||||
|
//! - [`generators`] — CSPRNG password + BIP39 passphrase generators; zxcvbn
|
||||||
|
//! strength gate.
|
||||||
|
//! - [`vault`] — Typed encrypt/decrypt wrappers (Item, Manifest, VaultSettings).
|
||||||
|
//! - [`imgsecret`] — DCT-based steganography for the second auth factor.
|
||||||
|
//!
|
||||||
|
//! ## Crypto pipeline
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
||||||
|
//! -> Argon2id(salt=vault_salt, m=64MiB, t=3, p=4)
|
||||||
|
//! -> master_key (32 bytes)
|
||||||
|
//! -> XChaCha20-Poly1305(nonce=random 24 bytes)
|
||||||
|
//! -> encrypted entry/manifest
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub use error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
|
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||||
|
|
||||||
|
pub mod ids;
|
||||||
|
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||||
|
|
||||||
|
pub mod time;
|
||||||
|
pub use time::{now_unix, MonthYear};
|
||||||
|
|
||||||
|
pub mod item_types;
|
||||||
|
pub use item_types::{ItemCore, ItemType};
|
||||||
|
|
||||||
|
pub mod item;
|
||||||
|
pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section};
|
||||||
|
|
||||||
|
pub mod attachment;
|
||||||
|
pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment};
|
||||||
|
|
||||||
|
pub mod manifest;
|
||||||
|
pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};
|
||||||
|
|
||||||
|
pub mod settings;
|
||||||
|
pub use settings::{
|
||||||
|
AttachmentCaps, Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||||
|
SymbolCharset, TrashRetention, VaultSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod generators;
|
||||||
|
pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate};
|
||||||
|
|
||||||
|
pub mod vault;
|
||||||
|
pub use vault::{
|
||||||
|
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||||
|
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod imgsecret;
|
||||||
|
|
||||||
|
pub mod backup;
|
||||||
|
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
||||||
|
|
||||||
|
pub mod import_lastpass;
|
||||||
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|
||||||
|
pub mod device;
|
||||||
|
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|
||||||
|
pub mod tar_safe;
|
||||||
|
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||||
|
|
||||||
|
pub mod recovery_qr;
|
||||||
|
pub use recovery_qr::{
|
||||||
|
generate_recovery_qr, generate_recovery_qr_with_params,
|
||||||
|
recovery_qr_to_svg,
|
||||||
|
unwrap_recovery_qr, unwrap_recovery_qr_with_params,
|
||||||
|
RecoveryQrPayload,
|
||||||
|
};
|
||||||
159
crates/relicario-core/src/manifest.rs
Normal file
159
crates/relicario-core/src/manifest.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
//! New typed-item manifest. Lives next to the old entry.rs Manifest
|
||||||
|
//! during this rewrite; entry.rs is deleted in Task 25.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::attachment::AttachmentSummary;
|
||||||
|
use crate::ids::ItemId;
|
||||||
|
use crate::item::Item;
|
||||||
|
use crate::item_types::ItemType;
|
||||||
|
|
||||||
|
pub const MANIFEST_SCHEMA_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub items: HashMap<ItemId, ManifestEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestEntry {
|
||||||
|
pub id: ItemId,
|
||||||
|
pub r#type: ItemType,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub favorite: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub group: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon_hint: Option<String>,
|
||||||
|
pub modified: i64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attachment_summaries: Vec<AttachmentSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Manifest {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert(&mut self, item: &Item) {
|
||||||
|
let entry = ManifestEntry::from_item(item);
|
||||||
|
self.items.insert(item.id.clone(), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, id: &ItemId) -> Option<ManifestEntry> {
|
||||||
|
self.items.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> {
|
||||||
|
self.items.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive substring match on title and tags.
|
||||||
|
pub fn search(&self, query: &str) -> Vec<&ManifestEntry> {
|
||||||
|
let q = query.to_lowercase();
|
||||||
|
self.items
|
||||||
|
.values()
|
||||||
|
.filter(|e| {
|
||||||
|
e.title.to_lowercase().contains(&q)
|
||||||
|
|| e.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Manifest {
|
||||||
|
fn default() -> Self { Self::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManifestEntry {
|
||||||
|
pub fn from_item(item: &Item) -> Self {
|
||||||
|
Self {
|
||||||
|
id: item.id.clone(),
|
||||||
|
r#type: item.r#type,
|
||||||
|
title: item.title.clone(),
|
||||||
|
tags: item.tags.clone(),
|
||||||
|
favorite: item.favorite,
|
||||||
|
group: item.group.clone(),
|
||||||
|
icon_hint: derive_icon_hint(item),
|
||||||
|
modified: item.modified,
|
||||||
|
trashed_at: item.trashed_at,
|
||||||
|
attachment_summaries: item.attachments.iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive an icon hint string from an item — for Login items, this is the URL hostname.
|
||||||
|
fn derive_icon_hint(item: &Item) -> Option<String> {
|
||||||
|
use crate::item_types::ItemCore;
|
||||||
|
match &item.core {
|
||||||
|
ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::item_types::{ItemCore, LoginCore, SecureNoteCore};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_manifest_has_schema_v2() {
|
||||||
|
let m = Manifest::new();
|
||||||
|
assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION);
|
||||||
|
assert!(m.items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_and_search() {
|
||||||
|
let mut m = Manifest::new();
|
||||||
|
let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default()));
|
||||||
|
item.tags = vec!["work".into()];
|
||||||
|
m.upsert(&item);
|
||||||
|
|
||||||
|
let results = m.search("github");
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
let by_tag = m.search("work");
|
||||||
|
assert_eq!(by_tag.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_hint_is_login_url_host() {
|
||||||
|
use url::Url;
|
||||||
|
let mut m = Manifest::new();
|
||||||
|
let core = ItemCore::Login(LoginCore {
|
||||||
|
url: Some(Url::parse("https://api.github.com/login").unwrap()),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let item = Item::new("X".into(), core);
|
||||||
|
m.upsert(&item);
|
||||||
|
let entry = m.items.values().next().unwrap();
|
||||||
|
assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_hint_is_none_for_non_login() {
|
||||||
|
let mut m = Manifest::new();
|
||||||
|
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||||
|
m.upsert(&item);
|
||||||
|
let entry = m.items.values().next().unwrap();
|
||||||
|
assert!(entry.icon_hint.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_round_trips() {
|
||||||
|
let mut m = Manifest::new();
|
||||||
|
let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||||
|
m.upsert(&item);
|
||||||
|
let json = serde_json::to_string(&m).unwrap();
|
||||||
|
let parsed: Manifest = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION);
|
||||||
|
assert_eq!(parsed.items.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
284
crates/relicario-core/src/recovery_qr.rs
Normal file
284
crates/relicario-core/src/recovery_qr.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
//! Recovery-QR encoding for the reference image_secret.
|
||||||
|
//!
|
||||||
|
//! ## What this module produces
|
||||||
|
//!
|
||||||
|
//! Given a user-chosen recovery passphrase and the 32-byte image_secret
|
||||||
|
//! (extracted from the reference JPEG via [`crate::imgsecret::extract`]), this
|
||||||
|
//! module produces a 109-byte sealed payload that — at recovery time, with the
|
||||||
|
//! same passphrase — yields the original image_secret back. The payload is
|
||||||
|
//! intended to be rendered as a QR v40 EcLevel::M SVG via [`recovery_qr_to_svg`]
|
||||||
|
//! and printed on paper, so a user who loses access to the reference JPEG can
|
||||||
|
//! still unlock their vault if they remember the recovery passphrase.
|
||||||
|
//!
|
||||||
|
//! ## Why the format is structured this way
|
||||||
|
//!
|
||||||
|
//! The payload is an XChaCha20-Poly1305 envelope around the image_secret. The
|
||||||
|
//! AEAD key (the "wrap key") is derived by Argon2id from a domain-separated
|
||||||
|
//! input:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! kdf_input = b"relicario-recovery-v1\0"
|
||||||
|
//! || u64_be(len(nfc(passphrase)))
|
||||||
|
//! || nfc(passphrase)
|
||||||
|
//! wrap_key = Argon2id(kdf_input, kdf_salt, RECOVERY_PRODUCTION_PARAMS) -> 32 bytes
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The `b"relicario-recovery-v1\0"` prefix is **domain separation**: it
|
||||||
|
//! guarantees that even if the user reuses their vault passphrase as their
|
||||||
|
//! recovery passphrase, the wrap key derived here can never collide with a
|
||||||
|
//! vault master key derived in [`crate::crypto::derive_master_key`] (which has
|
||||||
|
//! a different input shape entirely — passphrase + image_secret, no prefix).
|
||||||
|
//! Without this prefix, a determined attacker who somehow recovered a wrap key
|
||||||
|
//! could try it as a master key and vice versa.
|
||||||
|
//!
|
||||||
|
//! Both `kdf_salt` and `wrap_nonce` are freshly randomized per call to
|
||||||
|
//! [`generate_recovery_qr`], so two QRs printed from the same passphrase and
|
||||||
|
//! image_secret are different bytes — the printed QR does not leak whether
|
||||||
|
//! the user has printed others before.
|
||||||
|
//!
|
||||||
|
//! ## Parameter-pinning rationale
|
||||||
|
//!
|
||||||
|
//! The Argon2id parameters used here are NOT [`crate::crypto::KdfParams::default`].
|
||||||
|
//! They are pinned in `RECOVERY_PRODUCTION_PARAMS` at the value
|
||||||
|
//! `KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }` — the same values
|
||||||
|
//! the default happens to have *today*, but deliberately re-stated rather than
|
||||||
|
//! referenced. This is because `KdfParams::default()` may evolve as we re-tune
|
||||||
|
//! Argon2 cost for newer hardware, and a recovery QR printed on paper has no
|
||||||
|
//! way to negotiate parameters at decode time. Changing the pinned values here
|
||||||
|
//! would silently invalidate every recovery QR a user has ever printed under
|
||||||
|
//! the previous parameter set. The const lives at module scope so the
|
||||||
|
//! "pinned, do not change once shipped" property is visible at every use site.
|
||||||
|
|
||||||
|
use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead};
|
||||||
|
use rand::RngCore;
|
||||||
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
use crate::{crypto::KdfParams, error::{RelicarioError, Result}};
|
||||||
|
|
||||||
|
// Recovery QR payload — 109 bytes total:
|
||||||
|
//
|
||||||
|
// byte field length
|
||||||
|
// ------ -------------- ------
|
||||||
|
// 0..4 MAGIC = "RREC" 4
|
||||||
|
// 4..5 VERSION = 0x01 1
|
||||||
|
// 5..37 kdf_salt 32 (random per QR)
|
||||||
|
// 37..61 wrap_nonce 24 (random per QR)
|
||||||
|
// 61..109 ciphertext 48 (32 image_secret + 16 AEAD tag)
|
||||||
|
// ------------------------------
|
||||||
|
// total 109
|
||||||
|
const MAGIC: &[u8; 4] = b"RREC";
|
||||||
|
const VERSION: u8 = 0x01;
|
||||||
|
const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109
|
||||||
|
|
||||||
|
// Static assertion that the documented layout above and the PAYLOAD_LEN
|
||||||
|
// constant cannot drift apart. If a future edit changes one without the other,
|
||||||
|
// this fails to compile.
|
||||||
|
const _: () = assert!(PAYLOAD_LEN == 4 + 1 + 32 + 24 + 48);
|
||||||
|
|
||||||
|
// Named slice ranges derived from the layout offsets above. Used by
|
||||||
|
// `unwrap_recovery_qr_with_params` so the byte-position arithmetic at the
|
||||||
|
// parse site is self-documenting.
|
||||||
|
const KDF_SALT_RANGE: std::ops::Range<usize> = 5..37;
|
||||||
|
const WRAP_NONCE_RANGE: std::ops::Range<usize> = 37..61;
|
||||||
|
const CIPHERTEXT_RANGE: std::ops::Range<usize> = 61..109;
|
||||||
|
|
||||||
|
/// Pinned recovery-QR Argon2id parameters. Re-states `KdfParams::default()`'s
|
||||||
|
/// values rather than referencing them, because a recovery QR printed under
|
||||||
|
/// one parameter set cannot be decoded under another. **Once shipped, these
|
||||||
|
/// values MUST NOT change** — doing so silently invalidates every previously
|
||||||
|
/// printed QR. See the module header for full rationale.
|
||||||
|
const RECOVERY_PRODUCTION_PARAMS: KdfParams = KdfParams {
|
||||||
|
argon2_m: 65536,
|
||||||
|
argon2_t: 3,
|
||||||
|
argon2_p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A sealed 109-byte recovery payload. The bytes are an opaque package — they
|
||||||
|
/// only become useful when fed back through [`unwrap_recovery_qr`] together
|
||||||
|
/// with the recovery passphrase that was used to produce them.
|
||||||
|
///
|
||||||
|
/// [`as_bytes`](Self::as_bytes) is the only accessor. The bytes are designed to
|
||||||
|
/// travel as a single unit; the supported transport is rendering via
|
||||||
|
/// [`recovery_qr_to_svg`] and printing the QR on paper, but a hex string
|
||||||
|
/// (sneakernet-friendly) works equally well as long as the full 109 bytes
|
||||||
|
/// are preserved.
|
||||||
|
pub struct RecoveryQrPayload {
|
||||||
|
bytes: [u8; PAYLOAD_LEN],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecoveryQrPayload {
|
||||||
|
pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recovery_kdf_input(passphrase: &str) -> Vec<u8> {
|
||||||
|
let nfc: String = passphrase.nfc().collect();
|
||||||
|
let nfc_bytes = nfc.as_bytes();
|
||||||
|
let prefix = b"relicario-recovery-v1\0";
|
||||||
|
let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len());
|
||||||
|
input.extend_from_slice(prefix);
|
||||||
|
// length-prefix on nfc_bytes mirrors crypto::derive_master_key (audit H1)
|
||||||
|
input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes());
|
||||||
|
input.extend_from_slice(nfc_bytes);
|
||||||
|
input
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_wrap_key(
|
||||||
|
passphrase: &str,
|
||||||
|
kdf_salt: &[u8; 32],
|
||||||
|
params: &KdfParams,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
let input = recovery_kdf_input(passphrase);
|
||||||
|
crate::crypto::derive_master_key_raw(&input, kdf_salt, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce a sealed [`RecoveryQrPayload`] from the recovery passphrase and the
|
||||||
|
/// 32-byte image_secret.
|
||||||
|
///
|
||||||
|
/// # Inputs
|
||||||
|
///
|
||||||
|
/// - `passphrase`: the user's recovery passphrase (UTF-8). Independent of the
|
||||||
|
/// vault passphrase, but the user may reuse them — the
|
||||||
|
/// `b"relicario-recovery-v1\0"` domain-separation prefix in the KDF input
|
||||||
|
/// guarantees the wrap key still cannot collide with a vault master key.
|
||||||
|
/// - `image_secret`: the 32-byte secret extracted from the reference JPEG
|
||||||
|
/// via [`crate::imgsecret::extract`].
|
||||||
|
///
|
||||||
|
/// # Output
|
||||||
|
///
|
||||||
|
/// A [`RecoveryQrPayload`] whose 109 bytes encode `MAGIC || VERSION || kdf_salt
|
||||||
|
/// || wrap_nonce || ciphertext`. Both `kdf_salt` and `wrap_nonce` are freshly
|
||||||
|
/// drawn from `OsRng` on every call, so two payloads generated from the same
|
||||||
|
/// `(passphrase, image_secret)` pair are distinct bit-for-bit. The printed QR
|
||||||
|
/// therefore does not reveal that the user has printed others before.
|
||||||
|
///
|
||||||
|
/// To render the payload as a printable SVG, see [`recovery_qr_to_svg`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`RelicarioError::RecoveryQr`] if the AEAD wrap fails (extremely
|
||||||
|
/// unlikely in practice — this can only happen if the cipher implementation
|
||||||
|
/// itself errors, not on user input).
|
||||||
|
pub fn generate_recovery_qr(
|
||||||
|
passphrase: &str,
|
||||||
|
image_secret: &[u8; 32],
|
||||||
|
) -> Result<RecoveryQrPayload> {
|
||||||
|
generate_recovery_qr_with_params(passphrase, image_secret, &RECOVERY_PRODUCTION_PARAMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn generate_recovery_qr_with_params(
|
||||||
|
passphrase: &str,
|
||||||
|
image_secret: &[u8; 32],
|
||||||
|
params: &KdfParams,
|
||||||
|
) -> Result<RecoveryQrPayload> {
|
||||||
|
let mut kdf_salt = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut kdf_salt);
|
||||||
|
|
||||||
|
let mut wrap_nonce = [0u8; 24];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut wrap_nonce);
|
||||||
|
|
||||||
|
let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?;
|
||||||
|
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||||
|
let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce);
|
||||||
|
let ciphertext = cipher.encrypt(nonce, image_secret.as_ref())
|
||||||
|
.map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?;
|
||||||
|
|
||||||
|
let mut bytes = [0u8; PAYLOAD_LEN];
|
||||||
|
let mut pos = 0;
|
||||||
|
bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4;
|
||||||
|
bytes[pos] = VERSION; pos += 1;
|
||||||
|
bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32;
|
||||||
|
bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24;
|
||||||
|
bytes[pos..pos+48].copy_from_slice(&ciphertext);
|
||||||
|
|
||||||
|
Ok(RecoveryQrPayload { bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a recovery payload back into the original 32-byte image_secret.
|
||||||
|
///
|
||||||
|
/// # Inputs
|
||||||
|
///
|
||||||
|
/// - `payload_bytes`: the 109 bytes produced by [`generate_recovery_qr`] (after
|
||||||
|
/// the QR has been scanned, or the hex transcribed and decoded).
|
||||||
|
/// - `passphrase`: the recovery passphrase that was used at generate time.
|
||||||
|
///
|
||||||
|
/// # Output
|
||||||
|
///
|
||||||
|
/// The recovered image_secret as `Zeroizing<[u8; 32]>` — the wrapper ensures
|
||||||
|
/// the secret is wiped from memory when the binding goes out of scope, so a
|
||||||
|
/// caller that immediately feeds it into [`crate::crypto::derive_master_key`]
|
||||||
|
/// and then drops it never leaves a copy in process memory longer than
|
||||||
|
/// strictly necessary.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// - [`RelicarioError::RecoveryQr`] for **format** problems: wrong length,
|
||||||
|
/// bad magic, unsupported version byte. These come from inspecting the
|
||||||
|
/// bytes themselves, before any cryptographic work, so they leak nothing
|
||||||
|
/// about whether the passphrase is right.
|
||||||
|
/// - [`RelicarioError::Decrypt`] for **AEAD** failure — wrong passphrase
|
||||||
|
/// (wrong wrap key) **or** a payload tampered after the fact. The two
|
||||||
|
/// cases are deliberately not distinguished, mirroring the same
|
||||||
|
/// non-distinguishing rejection as [`crate::crypto::decrypt`] (audit M4):
|
||||||
|
/// a Poly1305 tag failure cannot, in principle, leak which bytes were
|
||||||
|
/// wrong, and the API surface preserves that property.
|
||||||
|
pub fn unwrap_recovery_qr(
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
unwrap_recovery_qr_with_params(payload_bytes, passphrase, &RECOVERY_PRODUCTION_PARAMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn unwrap_recovery_qr_with_params(
|
||||||
|
payload_bytes: &[u8],
|
||||||
|
passphrase: &str,
|
||||||
|
params: &KdfParams,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>> {
|
||||||
|
if payload_bytes.len() != PAYLOAD_LEN {
|
||||||
|
return Err(RelicarioError::RecoveryQr(
|
||||||
|
format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if &payload_bytes[0..4] != MAGIC {
|
||||||
|
return Err(RelicarioError::RecoveryQr("bad magic".into()));
|
||||||
|
}
|
||||||
|
if payload_bytes[4] != VERSION {
|
||||||
|
return Err(RelicarioError::RecoveryQr(
|
||||||
|
format!("unsupported version 0x{:02x}", payload_bytes[4])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let kdf_salt: &[u8; 32] = payload_bytes[KDF_SALT_RANGE].try_into().expect("slice length validated above");
|
||||||
|
let wrap_nonce = &payload_bytes[WRAP_NONCE_RANGE];
|
||||||
|
let ciphertext = &payload_bytes[CIPHERTEXT_RANGE];
|
||||||
|
|
||||||
|
let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?;
|
||||||
|
let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref()));
|
||||||
|
let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce);
|
||||||
|
let plaintext = cipher.decrypt(nonce, ciphertext)
|
||||||
|
.map_err(|_| RelicarioError::Decrypt)?;
|
||||||
|
|
||||||
|
let mut out = Zeroizing::new([0u8; 32]);
|
||||||
|
out.copy_from_slice(&plaintext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a [`RecoveryQrPayload`] as a printable QR-code SVG string.
|
||||||
|
///
|
||||||
|
/// The QR is encoded at **version 40** (the largest standard symbol, 177×177
|
||||||
|
/// modules) at **error-correction level M** (~15% recoverable), with a
|
||||||
|
/// minimum rendered dimension of **140×140** SVG units. The 109-byte payload
|
||||||
|
/// fits comfortably inside v40 at level M — there is significant
|
||||||
|
/// error-correction headroom left over, which is the point: the QR is
|
||||||
|
/// expected to live on paper (where smudges, folds, and fading are normal)
|
||||||
|
/// and must still scan years later.
|
||||||
|
pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String {
|
||||||
|
use qrcode::{QrCode, EcLevel};
|
||||||
|
let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M)
|
||||||
|
.expect("109 bytes fits well within QR v40 capacity at EcLevel::M");
|
||||||
|
code.render::<qrcode::render::svg::Color>()
|
||||||
|
.min_dimensions(140, 140)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
184
crates/relicario-core/src/settings.rs
Normal file
184
crates/relicario-core/src/settings.rs
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
//! Vault-level settings: trash retention, history retention, generator
|
||||||
|
//! defaults, attachment caps, autofill TOFU acks.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VaultSettings {
|
||||||
|
pub trash_retention: TrashRetention,
|
||||||
|
pub field_history_retention: HistoryRetention,
|
||||||
|
pub generator_defaults: GeneratorRequest,
|
||||||
|
pub attachment_caps: AttachmentCaps,
|
||||||
|
/// hostname → unix-seconds first-acked
|
||||||
|
#[serde(default)]
|
||||||
|
pub autofill_origin_acks: HashMap<String, i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VaultSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
trash_retention: TrashRetention::Days(30),
|
||||||
|
field_history_retention: HistoryRetention::Forever,
|
||||||
|
generator_defaults: GeneratorRequest::default(),
|
||||||
|
attachment_caps: AttachmentCaps::default(),
|
||||||
|
autofill_origin_acks: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
|
pub enum TrashRetention {
|
||||||
|
Days(u32),
|
||||||
|
Forever,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrashRetention {
|
||||||
|
pub fn should_purge(&self, trashed_at: i64, now: i64) -> bool {
|
||||||
|
match self {
|
||||||
|
TrashRetention::Forever => false,
|
||||||
|
TrashRetention::Days(d) => now - trashed_at > (*d as i64) * 86_400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
|
pub enum HistoryRetention {
|
||||||
|
LastN(u32),
|
||||||
|
Days(u32),
|
||||||
|
Forever,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum GeneratorRequest {
|
||||||
|
Bip39 {
|
||||||
|
word_count: u32,
|
||||||
|
separator: String,
|
||||||
|
capitalization: Capitalization,
|
||||||
|
},
|
||||||
|
Random {
|
||||||
|
length: u32,
|
||||||
|
classes: CharClasses,
|
||||||
|
symbol_charset: SymbolCharset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GeneratorRequest {
|
||||||
|
fn default() -> Self {
|
||||||
|
GeneratorRequest::Random {
|
||||||
|
length: 20,
|
||||||
|
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: SymbolCharset::SafeOnly,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Capitalization {
|
||||||
|
Lower,
|
||||||
|
Upper,
|
||||||
|
FirstOfEach,
|
||||||
|
Title,
|
||||||
|
Mixed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CharClasses {
|
||||||
|
pub lower: bool,
|
||||||
|
pub upper: bool,
|
||||||
|
pub digits: bool,
|
||||||
|
pub symbols: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
|
pub enum SymbolCharset {
|
||||||
|
SafeOnly,
|
||||||
|
Extended,
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct AttachmentCaps {
|
||||||
|
pub per_attachment_max_bytes: u64,
|
||||||
|
pub per_item_max_count: u32,
|
||||||
|
pub per_vault_soft_cap_bytes: u64,
|
||||||
|
pub per_vault_hard_cap_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AttachmentCaps {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
per_attachment_max_bytes: 10 * 1024 * 1024,
|
||||||
|
per_item_max_count: 20,
|
||||||
|
per_vault_soft_cap_bytes: 100 * 1024 * 1024,
|
||||||
|
per_vault_hard_cap_bytes: 500 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_spec() {
|
||||||
|
let s = VaultSettings::default();
|
||||||
|
assert!(matches!(s.trash_retention, TrashRetention::Days(30)));
|
||||||
|
assert!(matches!(s.field_history_retention, HistoryRetention::Forever));
|
||||||
|
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
|
||||||
|
assert_eq!(s.attachment_caps.per_item_max_count, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_retention_purges_after_days() {
|
||||||
|
let r = TrashRetention::Days(30);
|
||||||
|
let now = 1_000_000_000;
|
||||||
|
let recently_trashed = now - 29 * 86_400;
|
||||||
|
let long_trashed = now - 31 * 86_400;
|
||||||
|
assert!(!r.should_purge(recently_trashed, now));
|
||||||
|
assert!(r.should_purge(long_trashed, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_retention_forever_never_purges() {
|
||||||
|
let r = TrashRetention::Forever;
|
||||||
|
assert!(!r.should_purge(0, 1_000_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_round_trip() {
|
||||||
|
let s = VaultSettings::default();
|
||||||
|
let json = serde_json::to_string(&s).unwrap();
|
||||||
|
let parsed: VaultSettings = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.attachment_caps.per_attachment_max_bytes,
|
||||||
|
s.attachment_caps.per_attachment_max_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_generator_default_is_20_safe() {
|
||||||
|
match VaultSettings::default().generator_defaults {
|
||||||
|
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||||
|
assert_eq!(length, 20);
|
||||||
|
assert!(classes.lower && classes.upper && classes.digits && classes.symbols);
|
||||||
|
assert!(matches!(symbol_charset, SymbolCharset::SafeOnly));
|
||||||
|
}
|
||||||
|
_ => panic!("expected Random default"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn symbol_charset_custom_round_trips() {
|
||||||
|
let c = SymbolCharset::Custom("!@#".into());
|
||||||
|
let json = serde_json::to_string(&c).unwrap();
|
||||||
|
let parsed: SymbolCharset = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed {
|
||||||
|
SymbolCharset::Custom(s) => assert_eq!(s, "!@#"),
|
||||||
|
other => panic!("expected Custom, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
crates/relicario-core/src/tar_safe.rs
Normal file
138
crates/relicario-core/src/tar_safe.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//! Safe tar unpacking for backup restore.
|
||||||
|
//!
|
||||||
|
//! The standard `tar::Archive::unpack` has no guards against path traversal,
|
||||||
|
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
|
||||||
|
//! with `safe_unpack_git_archive`, which validates every entry before returning
|
||||||
|
//! `(relative_path, bytes)` pairs to the caller.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Component, PathBuf};
|
||||||
|
|
||||||
|
use tar::EntryType;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
|
||||||
|
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
|
||||||
|
/// regular files only.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
|
||||||
|
///
|
||||||
|
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
|
||||||
|
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
|
||||||
|
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
|
||||||
|
/// - An entry is a symlink or hardlink — "symlink/link rejected".
|
||||||
|
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||||
|
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||||
|
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
|
||||||
|
pub fn safe_unpack_git_archive(
|
||||||
|
tar_bytes: &[u8],
|
||||||
|
max_uncompressed_bytes: u64,
|
||||||
|
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
|
||||||
|
let mut archive = tar::Archive::new(tar_bytes);
|
||||||
|
let entries = archive
|
||||||
|
.entries()
|
||||||
|
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
|
||||||
|
|
||||||
|
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
||||||
|
let mut cumulative: u64 = 0;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let mut entry = entry.map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let header = entry.header();
|
||||||
|
let entry_type = header.entry_type();
|
||||||
|
|
||||||
|
// Reject symlinks and hardlinks.
|
||||||
|
match entry_type {
|
||||||
|
EntryType::Symlink => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"symlink entry rejected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
EntryType::Link => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"hardlink entry rejected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
EntryType::Directory => {
|
||||||
|
// Directories are implicit — skip without reading body.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
|
||||||
|
// These are normal file types; fall through to path checks.
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(RelicarioError::BackupRestore(format!(
|
||||||
|
"unexpected entry type: {:?}",
|
||||||
|
entry_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the path.
|
||||||
|
let path = entry.path().map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
|
||||||
|
})?;
|
||||||
|
let path = path.into_owned();
|
||||||
|
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::ParentDir => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"path traversal blocked: entry contains '..' component".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::RootDir => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"path traversal blocked: entry has absolute path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::Prefix(_) => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"path traversal blocked: entry has Windows drive prefix".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::Normal(_) | Component::CurDir => {
|
||||||
|
// Acceptable components.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check declared size before reading body.
|
||||||
|
let claimed = header.size().map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if claimed > max_uncompressed_bytes {
|
||||||
|
return Err(RelicarioError::BackupRestore(format!(
|
||||||
|
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_total = cumulative.saturating_add(claimed);
|
||||||
|
if new_total > max_uncompressed_bytes {
|
||||||
|
return Err(RelicarioError::BackupRestore(format!(
|
||||||
|
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file body.
|
||||||
|
let mut body = Vec::with_capacity(claimed as usize);
|
||||||
|
entry.read_to_end(&mut body).map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
cumulative += body.len() as u64;
|
||||||
|
|
||||||
|
result.push((path, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
63
crates/relicario-core/src/time.rs
Normal file
63
crates/relicario-core/src/time.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! Time helpers and the `MonthYear` type used for card expiries.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Current Unix timestamp in seconds.
|
||||||
|
pub fn now_unix() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MonthYear {
|
||||||
|
pub month: u8,
|
||||||
|
pub year: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MonthYear {
|
||||||
|
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
|
||||||
|
if !(1..=12).contains(&month) {
|
||||||
|
return Err("month must be 1..=12");
|
||||||
|
}
|
||||||
|
if !(2000..=2099).contains(&year) {
|
||||||
|
return Err("year must be 2000..=2099");
|
||||||
|
}
|
||||||
|
Ok(Self { month, year })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn now_unix_is_positive_and_recent() {
|
||||||
|
let t = now_unix();
|
||||||
|
assert!(t > 1_700_000_000); // after late 2023
|
||||||
|
assert!(t < 4_000_000_000); // before 2096
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn month_year_constructor_rejects_bad_month() {
|
||||||
|
assert!(MonthYear::new(0, 2026).is_err());
|
||||||
|
assert!(MonthYear::new(13, 2026).is_err());
|
||||||
|
assert!(MonthYear::new(1, 2026).is_ok());
|
||||||
|
assert!(MonthYear::new(12, 2026).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn month_year_constructor_rejects_bad_year() {
|
||||||
|
assert!(MonthYear::new(1, 1999).is_err());
|
||||||
|
assert!(MonthYear::new(1, 2100).is_err());
|
||||||
|
assert!(MonthYear::new(1, 2000).is_ok());
|
||||||
|
assert!(MonthYear::new(1, 2099).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn month_year_round_trips_through_json() {
|
||||||
|
let my = MonthYear::new(7, 2030).unwrap();
|
||||||
|
let json = serde_json::to_string(&my).unwrap();
|
||||||
|
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed, my);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
crates/relicario-core/src/vault.rs
Normal file
90
crates/relicario-core/src/vault.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item
|
||||||
|
//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse.
|
||||||
|
//!
|
||||||
|
//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
|
||||||
|
//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
|
||||||
|
//! Plan 1B switches to the new helpers.
|
||||||
|
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::crypto::{decrypt, encrypt};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::item::Item;
|
||||||
|
use crate::manifest::Manifest;
|
||||||
|
use crate::settings::VaultSettings;
|
||||||
|
|
||||||
|
pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
|
let json = serde_json::to_vec(item)?;
|
||||||
|
let plaintext = Zeroizing::new(json);
|
||||||
|
encrypt(master_key, plaintext.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
|
||||||
|
let plaintext = decrypt(master_key, encrypted)?;
|
||||||
|
let plaintext = Zeroizing::new(plaintext);
|
||||||
|
let item: Item = serde_json::from_slice(&plaintext)?;
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
|
let json = serde_json::to_vec(manifest)?;
|
||||||
|
let plaintext = Zeroizing::new(json);
|
||||||
|
encrypt(master_key, plaintext.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
|
||||||
|
let plaintext = decrypt(master_key, encrypted)?;
|
||||||
|
let plaintext = Zeroizing::new(plaintext);
|
||||||
|
let manifest: Manifest = serde_json::from_slice(&plaintext)?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
|
||||||
|
let json = serde_json::to_vec(settings)?;
|
||||||
|
let plaintext = Zeroizing::new(json);
|
||||||
|
encrypt(master_key, plaintext.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<VaultSettings> {
|
||||||
|
let plaintext = decrypt(master_key, encrypted)?;
|
||||||
|
let plaintext = Zeroizing::new(plaintext);
|
||||||
|
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::item_types::{ItemCore, SecureNoteCore};
|
||||||
|
|
||||||
|
fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_round_trip() {
|
||||||
|
let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
|
||||||
|
body: Zeroizing::new("hello".into()),
|
||||||
|
}));
|
||||||
|
let bytes = encrypt_item(&item, &key()).unwrap();
|
||||||
|
let decoded = decrypt_item(&bytes, &key()).unwrap();
|
||||||
|
assert_eq!(decoded.title, "note");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_round_trip() {
|
||||||
|
let mut m = Manifest::new();
|
||||||
|
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||||
|
m.upsert(&item);
|
||||||
|
let bytes = encrypt_manifest(&m, &key()).unwrap();
|
||||||
|
let decoded = decrypt_manifest(&bytes, &key()).unwrap();
|
||||||
|
assert_eq!(decoded.items.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_round_trip() {
|
||||||
|
let s = VaultSettings::default();
|
||||||
|
let bytes = encrypt_settings(&s, &key()).unwrap();
|
||||||
|
let decoded = decrypt_settings(&bytes, &key()).unwrap();
|
||||||
|
assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
|
||||||
|
s.attachment_caps.per_attachment_max_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
crates/relicario-core/tests/attachments.rs
Normal file
52
crates/relicario-core/tests/attachments.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! Attachment encrypt/decrypt + content-addressed AID + cap enforcement.
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
AttachmentId, RelicarioError,
|
||||||
|
crypto::KdfParams,
|
||||||
|
decrypt_attachment, derive_master_key, encrypt_attachment,
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
|
||||||
|
|
||||||
|
fn make_key() -> Zeroizing<[u8; 32]> {
|
||||||
|
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &fast_params()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attachment_round_trip_5kb() {
|
||||||
|
let plaintext: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
|
||||||
|
let key = make_key();
|
||||||
|
let enc = encrypt_attachment(&plaintext, &key, 10 * 1024 * 1024).unwrap();
|
||||||
|
assert_eq!(enc.id, AttachmentId::from_plaintext(&plaintext));
|
||||||
|
|
||||||
|
let dec = decrypt_attachment(&enc.bytes, &key).unwrap();
|
||||||
|
assert_eq!(&*dec, &plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_plaintexts_yield_identical_aids() {
|
||||||
|
let plaintext = b"hello world";
|
||||||
|
let key = make_key();
|
||||||
|
let a = encrypt_attachment(plaintext, &key, 1024).unwrap();
|
||||||
|
let b = encrypt_attachment(plaintext, &key, 1024).unwrap();
|
||||||
|
assert_eq!(a.id, b.id);
|
||||||
|
// (Bytes will differ because nonce is random per-encryption — that's expected.)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_enforcement_at_exact_max() {
|
||||||
|
let plaintext = vec![0u8; 1024];
|
||||||
|
let key = make_key();
|
||||||
|
// Exactly at max — should pass
|
||||||
|
let _ = encrypt_attachment(&plaintext, &key, 1024).unwrap();
|
||||||
|
// One byte over — should fail
|
||||||
|
let err = encrypt_attachment(&plaintext, &key, 1023);
|
||||||
|
match err {
|
||||||
|
Err(RelicarioError::AttachmentTooLarge { size, max }) => {
|
||||||
|
assert_eq!(size, 1024);
|
||||||
|
assert_eq!(max, 1023);
|
||||||
|
}
|
||||||
|
other => panic!("expected AttachmentTooLarge, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
215
crates/relicario-core/tests/backup.rs
Normal file
215
crates/relicario-core/tests/backup.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Backup container round-trip + error-path coverage.
|
||||||
|
|
||||||
|
use relicario_core::backup::{pack_backup, unpack_backup, BackupInput};
|
||||||
|
|
||||||
|
fn empty_input() -> BackupInput<'static> {
|
||||||
|
BackupInput {
|
||||||
|
salt: &[0u8; 32],
|
||||||
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
||||||
|
devices_json: "[]",
|
||||||
|
manifest_enc: &[],
|
||||||
|
settings_enc: &[],
|
||||||
|
items: vec![],
|
||||||
|
attachments: vec![],
|
||||||
|
reference_jpg: None,
|
||||||
|
git_archive: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_vault_round_trip() {
|
||||||
|
let out = pack_backup(empty_input(), "test-passphrase-1234").unwrap();
|
||||||
|
assert_eq!(&out[..4], b"RBAK", "magic header");
|
||||||
|
assert_eq!(out[4], 0x01, "format version");
|
||||||
|
|
||||||
|
let unpacked = unpack_backup(&out, "test-passphrase-1234").unwrap();
|
||||||
|
assert_eq!(unpacked.salt, [0u8; 32]);
|
||||||
|
assert!(unpacked.devices_json.contains("[]"));
|
||||||
|
assert!(unpacked.items.is_empty());
|
||||||
|
assert!(unpacked.attachments.is_empty());
|
||||||
|
assert!(unpacked.reference_jpg.is_none());
|
||||||
|
assert!(unpacked.git_archive.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
use relicario_core::backup::{BackupAttachment, BackupItem};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn populated_vault_round_trip() {
|
||||||
|
let manifest_enc = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||||
|
let settings_enc = vec![0x01, 0x02, 0x03];
|
||||||
|
let item_a_ct = vec![0xAA; 100];
|
||||||
|
let item_b_ct = vec![0xBB; 200];
|
||||||
|
let attach_x_ct = vec![0xCC; 4096];
|
||||||
|
let attach_y_ct = vec![0xDD; 8192];
|
||||||
|
|
||||||
|
let input = BackupInput {
|
||||||
|
salt: &[0x77u8; 32],
|
||||||
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
||||||
|
devices_json: r#"[{"name":"laptop","public_key":"deadbeef"}]"#,
|
||||||
|
manifest_enc: &manifest_enc,
|
||||||
|
settings_enc: &settings_enc,
|
||||||
|
items: vec![
|
||||||
|
BackupItem { id: "1111111111111111".to_string(), ciphertext: &item_a_ct },
|
||||||
|
BackupItem { id: "2222222222222222".to_string(), ciphertext: &item_b_ct },
|
||||||
|
],
|
||||||
|
attachments: vec![
|
||||||
|
BackupAttachment {
|
||||||
|
item_id: "1111111111111111".to_string(),
|
||||||
|
attachment_id: "aaaa1111".to_string(),
|
||||||
|
ciphertext: &attach_x_ct,
|
||||||
|
},
|
||||||
|
BackupAttachment {
|
||||||
|
item_id: "2222222222222222".to_string(),
|
||||||
|
attachment_id: "bbbb2222".to_string(),
|
||||||
|
ciphertext: &attach_y_ct,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reference_jpg: None,
|
||||||
|
git_archive: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let out = pack_backup(input, "another-strong-passphrase").unwrap();
|
||||||
|
let unpacked = unpack_backup(&out, "another-strong-passphrase").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unpacked.salt, [0x77u8; 32]);
|
||||||
|
assert!(unpacked.devices_json.contains("laptop"));
|
||||||
|
assert_eq!(unpacked.manifest_enc, manifest_enc);
|
||||||
|
assert_eq!(unpacked.settings_enc, settings_enc);
|
||||||
|
|
||||||
|
assert_eq!(unpacked.items.len(), 2);
|
||||||
|
let by_id: std::collections::HashMap<_, _> =
|
||||||
|
unpacked.items.iter().map(|i| (i.id.as_str(), &i.ciphertext)).collect();
|
||||||
|
assert_eq!(by_id.get("1111111111111111").unwrap(), &&item_a_ct);
|
||||||
|
assert_eq!(by_id.get("2222222222222222").unwrap(), &&item_b_ct);
|
||||||
|
|
||||||
|
assert_eq!(unpacked.attachments.len(), 2);
|
||||||
|
let by_aid: std::collections::HashMap<_, _> = unpacked
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.map(|a| ((a.item_id.as_str(), a.attachment_id.as_str()), &a.ciphertext))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct);
|
||||||
|
assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_with_reference_image() {
|
||||||
|
let jpg_bytes: Vec<u8> = (0u8..=255).cycle().take(1024 * 64).collect(); // 64 KiB
|
||||||
|
let mut input = empty_input();
|
||||||
|
input.reference_jpg = Some(&jpg_bytes);
|
||||||
|
|
||||||
|
let out = pack_backup(input, "p").unwrap();
|
||||||
|
let unpacked = unpack_backup(&out, "p").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice()));
|
||||||
|
assert!(unpacked.git_archive.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_with_git_archive() {
|
||||||
|
let tar_bytes: Vec<u8> = b"FAKE TAR BYTES; core treats opaquely".repeat(50);
|
||||||
|
let mut input = empty_input();
|
||||||
|
input.git_archive = Some(&tar_bytes);
|
||||||
|
|
||||||
|
let out = pack_backup(input, "p").unwrap();
|
||||||
|
let unpacked = unpack_backup(&out, "p").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unpacked.git_archive.as_deref(), Some(tar_bytes.as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_history_produces_strict_subset() {
|
||||||
|
let mut a = empty_input();
|
||||||
|
a.git_archive = Some(b"some-tar-bytes");
|
||||||
|
let with = pack_backup(a, "p").unwrap();
|
||||||
|
|
||||||
|
let without = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
|
||||||
|
// The "without" file is strictly smaller (one fewer base64-encoded blob in JSON).
|
||||||
|
assert!(without.len() < with.len(),
|
||||||
|
"no-history backup should be smaller: with={}, without={}",
|
||||||
|
with.len(), without.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
use relicario_core::RelicarioError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_magic_rejected() {
|
||||||
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
bytes[0] = b'X';
|
||||||
|
match unpack_backup(&bytes, "p") {
|
||||||
|
Err(RelicarioError::BackupBadMagic) => {}
|
||||||
|
other => panic!("expected BackupBadMagic, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_version_rejected() {
|
||||||
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
bytes[4] = 0xFF;
|
||||||
|
match unpack_backup(&bytes, "p") {
|
||||||
|
Err(RelicarioError::BackupUnsupportedVersion { found, expected }) => {
|
||||||
|
assert_eq!(found, 0xFF);
|
||||||
|
assert_eq!(expected, 0x01);
|
||||||
|
}
|
||||||
|
other => panic!("expected BackupUnsupportedVersion, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_passphrase_rejected_as_decrypt_error() {
|
||||||
|
let bytes = pack_backup(empty_input(), "right-passphrase").unwrap();
|
||||||
|
match unpack_backup(&bytes, "wrong-passphrase") {
|
||||||
|
Err(RelicarioError::Decrypt) => {}
|
||||||
|
other => panic!("expected Decrypt (opaque), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_file_rejected() {
|
||||||
|
let bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
let truncated = &bytes[..bytes.len().min(60)]; // shorter than HEADER_LEN + TAG_LEN
|
||||||
|
match unpack_backup(truncated, "p") {
|
||||||
|
Err(RelicarioError::Format(_)) => {}
|
||||||
|
other => panic!("expected Format(truncated), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_ciphertext_rejected_as_decrypt_error() {
|
||||||
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
||||||
|
let last = bytes.len() - 1;
|
||||||
|
bytes[last] ^= 0xFF; // flip a byte in the auth-tag region
|
||||||
|
match unpack_backup(&bytes, "p") {
|
||||||
|
Err(RelicarioError::Decrypt) => {}
|
||||||
|
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_roundtrip_with_nfd_passphrase() {
|
||||||
|
// "café" in NFD (decomposed: e + combining acute accent)
|
||||||
|
let nfd_passphrase = "caf\u{0065}\u{0301}";
|
||||||
|
// "café" in NFC (precomposed é)
|
||||||
|
let nfc_passphrase = "caf\u{00E9}";
|
||||||
|
|
||||||
|
let input = BackupInput {
|
||||||
|
salt: &[0u8; 32],
|
||||||
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
||||||
|
devices_json: "[]",
|
||||||
|
manifest_enc: &[1, 2, 3],
|
||||||
|
settings_enc: &[4, 5, 6],
|
||||||
|
items: vec![],
|
||||||
|
attachments: vec![],
|
||||||
|
reference_jpg: None,
|
||||||
|
git_archive: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pack with NFD passphrase
|
||||||
|
let packed = pack_backup(input, nfd_passphrase).unwrap();
|
||||||
|
|
||||||
|
// Unpack with NFC passphrase — should work after fix
|
||||||
|
let unpacked = unpack_backup(&packed, nfc_passphrase).unwrap();
|
||||||
|
assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]);
|
||||||
|
}
|
||||||
63
crates/relicario-core/tests/field_history.rs
Normal file
63
crates/relicario-core/tests/field_history.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//! Field history end-to-end: capture on update, prune by retention policy,
|
||||||
|
//! survive encrypt/decrypt round-trip.
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
Field, FieldValue, HistoryRetention, Item, ItemCore, Section,
|
||||||
|
crypto::KdfParams,
|
||||||
|
derive_master_key, decrypt_item, encrypt_item,
|
||||||
|
};
|
||||||
|
use relicario_core::item_types::LoginCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
fn key() -> Zeroizing<[u8; 32]> {
|
||||||
|
derive_master_key(b"x", &[0u8; 32], &[0u8; 32], &KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_field_history_captured_on_update() {
|
||||||
|
let mut item = Item::new("login".into(), ItemCore::Login(LoginCore::default()));
|
||||||
|
let f = Field::new("password".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v3".into()))).unwrap();
|
||||||
|
|
||||||
|
let hist = item.field_history.get(&fid).expect("history exists");
|
||||||
|
assert_eq!(hist.len(), 3);
|
||||||
|
assert_eq!(hist[0].value.as_str(), "v0");
|
||||||
|
assert_eq!(hist[2].value.as_str(), "v2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_last_n_keeps_most_recent() {
|
||||||
|
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||||
|
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
for i in 1..=10 {
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new(format!("v{i}")))).unwrap();
|
||||||
|
}
|
||||||
|
item.prune_history(&HistoryRetention::LastN(3), 0);
|
||||||
|
let hist = &item.field_history[&fid];
|
||||||
|
assert_eq!(hist.len(), 3);
|
||||||
|
// Most recent 3: v7, v8, v9 (v10's predecessor v9 was the latest captured)
|
||||||
|
assert!(hist.last().unwrap().value.as_str().starts_with('v'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_survives_encrypt_decrypt() {
|
||||||
|
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||||
|
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||||
|
|
||||||
|
let blob = encrypt_item(&item, &key()).unwrap();
|
||||||
|
let decoded = decrypt_item(&blob, &key()).unwrap();
|
||||||
|
|
||||||
|
let hist = decoded.field_history.get(&fid).expect("history survived");
|
||||||
|
assert_eq!(hist.len(), 1);
|
||||||
|
assert_eq!(hist[0].value.as_str(), "v0");
|
||||||
|
}
|
||||||
54
crates/relicario-core/tests/format_v2.rs
Normal file
54
crates/relicario-core/tests/format_v2.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//! Format v2 invariants: VERSION_BYTE = 0x02, v1 blobs are rejected with
|
||||||
|
//! UnsupportedFormatVersion, length-prefix construction guarantees domain
|
||||||
|
//! separation.
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
RelicarioError,
|
||||||
|
crypto::{KdfParams, VERSION_BYTE},
|
||||||
|
decrypt, derive_master_key, encrypt,
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
fn fast_params() -> KdfParams { KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_byte_is_2() {
|
||||||
|
assert_eq!(VERSION_BYTE, 0x02);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_ciphertext_starts_with_0x02() {
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
// encrypt(key: &[u8; 32], plaintext: &[u8])
|
||||||
|
let ct = encrypt(&key, b"hello").unwrap();
|
||||||
|
assert_eq!(ct[0], 0x02);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v1_blob_is_rejected_with_unsupported_format_version() {
|
||||||
|
// v1 layout: [0x01][24 nonce bytes][16 tag bytes]
|
||||||
|
let mut blob = vec![0x01u8];
|
||||||
|
blob.extend_from_slice(&[0u8; 24 + 16]);
|
||||||
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
|
// decrypt(key: &[u8; 32], data: &[u8])
|
||||||
|
let err = decrypt(&key, &blob);
|
||||||
|
match err {
|
||||||
|
Err(RelicarioError::UnsupportedFormatVersion { found, expected }) => {
|
||||||
|
assert_eq!(found, 0x01);
|
||||||
|
assert_eq!(expected, 0x02);
|
||||||
|
}
|
||||||
|
other => panic!("expected UnsupportedFormatVersion, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn length_prefix_distinguishes_concat_collisions() {
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let img = [0x44u8; 32];
|
||||||
|
let p1 = b"abc";
|
||||||
|
let p2 = b"abcD"; // Pre-length-prefix, ("abc", [0x44, ...]) and ("abcD", ...)
|
||||||
|
// could be made to collide. With length-prefix they cannot.
|
||||||
|
let k1 = derive_master_key(p1, &img, &salt, &fast_params()).unwrap();
|
||||||
|
let k2 = derive_master_key(p2, &img, &salt, &fast_params()).unwrap();
|
||||||
|
assert_ne!(*k1, *k2);
|
||||||
|
}
|
||||||
89
crates/relicario-core/tests/generators.rs
Normal file
89
crates/relicario-core/tests/generators.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//! Generator integration tests — unbiased sampling (smoke), BIP39 sanity,
|
||||||
|
//! zxcvbn strength gate.
|
||||||
|
//!
|
||||||
|
//! # Note on length cap
|
||||||
|
//!
|
||||||
|
//! `generate_password` enforces `length <= 128`. The task originally specified
|
||||||
|
//! `length: 10_000` in a single call, but that would error at runtime.
|
||||||
|
//!
|
||||||
|
//! We use **Option 1 (aggregation)**: call `generate_password` 80 times with
|
||||||
|
//! `length: 128` to gather 10,240 characters total, then aggregate per-class
|
||||||
|
//! counts before asserting proportions. The ±5pp tolerance is unchanged because
|
||||||
|
//! sample size is the same (~10k chars).
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
Capitalization, CharClasses, GeneratorRequest, SymbolCharset,
|
||||||
|
generate_passphrase, generate_password, validate_passphrase_strength,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_password_class_balance_is_reasonable() {
|
||||||
|
// Aggregate 80 × 128 = 10,240 chars so we have enough for tight statistics.
|
||||||
|
// (generate_password caps at length 128, so we cannot do a single 10,000-char call.)
|
||||||
|
let req = GeneratorRequest::Random {
|
||||||
|
length: 128,
|
||||||
|
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: SymbolCharset::SafeOnly,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lower = 0usize;
|
||||||
|
let mut upper = 0usize;
|
||||||
|
let mut digits = 0usize;
|
||||||
|
let mut total = 0usize;
|
||||||
|
|
||||||
|
for _ in 0..80 {
|
||||||
|
let pw = generate_password(&req).unwrap();
|
||||||
|
lower += pw.chars().filter(|c| c.is_ascii_lowercase()).count();
|
||||||
|
upper += pw.chars().filter(|c| c.is_ascii_uppercase()).count();
|
||||||
|
digits += pw.chars().filter(|c| c.is_ascii_digit()).count();
|
||||||
|
total += pw.len();
|
||||||
|
}
|
||||||
|
let symbols = total - lower - upper - digits;
|
||||||
|
|
||||||
|
// Charset sizes: lower 26 + upper 26 + digits 10 + safe_symbols 12 = 74
|
||||||
|
// Expected proportions: 26/74 ≈ 35.1%, 10/74 ≈ 13.5%, 12/74 ≈ 16.2%
|
||||||
|
// Allow ±5pp slop.
|
||||||
|
let t = total as f64;
|
||||||
|
let assert_pct = |label: &str, actual: usize, expected_pct: f64| {
|
||||||
|
let pct = (actual as f64) / t * 100.0;
|
||||||
|
assert!(
|
||||||
|
(pct - expected_pct).abs() < 5.0,
|
||||||
|
"{label}: actual {pct:.1}% vs expected {expected_pct:.1}%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
assert_pct("lower", lower, 26.0 / 74.0 * 100.0);
|
||||||
|
assert_pct("upper", upper, 26.0 / 74.0 * 100.0);
|
||||||
|
assert_pct("digits", digits, 10.0 / 74.0 * 100.0);
|
||||||
|
assert_pct("symbols", symbols, 12.0 / 74.0 * 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bip39_5_word_passphrase_passes_zxcvbn_gate() {
|
||||||
|
let req = GeneratorRequest::Bip39 {
|
||||||
|
word_count: 5,
|
||||||
|
separator: " ".into(),
|
||||||
|
capitalization: Capitalization::Lower,
|
||||||
|
};
|
||||||
|
let pw = generate_passphrase(&req).unwrap();
|
||||||
|
validate_passphrase_strength(&pw).expect("5-word bip39 should pass score >= 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn common_weak_passphrases_fail_gate() {
|
||||||
|
for weak in &["password", "12345678", "letmein", "qwertyui", "hunter2"] {
|
||||||
|
assert!(
|
||||||
|
validate_passphrase_strength(weak).is_err(),
|
||||||
|
"expected '{weak}' to fail gate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_passwords_are_unique_across_calls() {
|
||||||
|
let req = GeneratorRequest::default();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for _ in 0..1000 {
|
||||||
|
let pw = generate_password(&req).unwrap();
|
||||||
|
assert!(seen.insert(pw.as_str().to_owned()));
|
||||||
|
}
|
||||||
|
}
|
||||||
276
crates/relicario-core/tests/import_lastpass.rs
Normal file
276
crates/relicario-core/tests/import_lastpass.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//! LastPass CSV importer — parser coverage.
|
||||||
|
|
||||||
|
use relicario_core::import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
use relicario_core::item_types::{TotpAlgorithm, TotpKind};
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
|
||||||
|
const HEADER: &str = "url,username,password,totp,extra,name,grouping,fav";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_login_row_round_trips() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://github.com/login,alice,hunter2,,,GitHub,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 1, "one item expected");
|
||||||
|
assert!(warnings.is_empty(), "no warnings expected");
|
||||||
|
|
||||||
|
let item = &items[0];
|
||||||
|
assert_eq!(item.title, "GitHub");
|
||||||
|
assert!(!item.favorite);
|
||||||
|
assert!(item.group.is_none());
|
||||||
|
match &item.core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
assert_eq!(l.username.as_deref(), Some("alice"));
|
||||||
|
assert_eq!(l.password.as_deref().map(String::as_str), Some("hunter2"));
|
||||||
|
assert_eq!(l.url.as_ref().map(|u| u.as_str()), Some("https://github.com/login"));
|
||||||
|
assert!(l.totp.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_id_is_freshly_minted() {
|
||||||
|
// Decision D12: title collisions don't dedupe; each row gets a fresh ID.
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Same,,\nhttps://x,u,p,,,Same,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_ne!(items[0].id, items[1].id, "IDs must be unique even for identical names");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertion helper used by later tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn first_warning_message(warnings: &[ImportWarning]) -> String {
|
||||||
|
warnings.first().expect("expected at least one warning").message.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grouping_maps_to_item_group() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,Finance,");
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(items[0].group.as_deref(), Some("Finance"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_grouping_yields_none() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(items[0].group.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fav_one_marks_favorite() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Bank,,1");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(items[0].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fav_zero_or_blank_not_favorite() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,,,Zero,,0\n\
|
||||||
|
https://x,u,p,,,Blank,,",
|
||||||
|
);
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert!(!items[0].favorite);
|
||||||
|
assert!(!items[1].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extra_becomes_notes_for_login() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,a hint,Bank,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items[0].notes.as_deref(), Some("a hint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiline_extra_round_trips_via_quoting() {
|
||||||
|
// CSV double-quotes escape embedded newlines.
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,,\"line1\nline2\nline3\",Bank,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty(), "multi-line extra should parse cleanly");
|
||||||
|
assert_eq!(items[0].notes.as_deref(), Some("line1\nline2\nline3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_valid_totp_secret_attaches_config() {
|
||||||
|
// RFC 4648 base32 of b"12345678901234567890" → "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ".
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://github.com/login,alice,hunter2,GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ,,GitHub,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => {
|
||||||
|
let totp = l.totp.as_ref().expect("expected TOTP config");
|
||||||
|
assert_eq!(totp.algorithm, TotpAlgorithm::Sha1);
|
||||||
|
assert_eq!(totp.digits, 6);
|
||||||
|
assert_eq!(totp.period_seconds, 30);
|
||||||
|
assert_eq!(totp.kind, TotpKind::Totp);
|
||||||
|
assert_eq!(totp.secret.as_slice(), b"12345678901234567890");
|
||||||
|
}
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_bad_totp_secret_imports_without_totp_and_warns() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://github.com/login,alice,hunter2,!!!!not-base32!!!!,,GitHub,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 1, "login should still import");
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => assert!(l.totp.is_none(), "TOTP must be dropped"),
|
||||||
|
other => panic!("expected Login, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert_eq!(warnings.len(), 1);
|
||||||
|
let w = &warnings[0];
|
||||||
|
assert_eq!(w.title.as_deref(), Some("GitHub"));
|
||||||
|
assert!(w.message.contains("TOTP"), "message: {}", w.message);
|
||||||
|
assert!(w.message.contains("invalid") || w.message.contains("base32"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_lowercase_base32_totp_is_accepted() {
|
||||||
|
// RFC 4648 is case-insensitive; LastPass exports may use either case.
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,gezdgnbvgy3tqojqgezdgnbvgy3tqojq,,Acme,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty(), "lowercase base32 must parse");
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => assert!(l.totp.is_some()),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn url_http_sn_maps_to_secure_note() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
http://sn,,,,The body of the note,My Note,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].title, "My Note");
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), "The body of the note"),
|
||||||
|
other => panic!("expected SecureNote, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_does_not_require_password() {
|
||||||
|
// SecureNote rows have empty password; that must not trigger the
|
||||||
|
// `missing password` skip path (which is Login-only).
|
||||||
|
let csv = format!("{HEADER}\nhttp://sn,,,,note text,Title,,");
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty(), "{:?}", warnings);
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_passes_through_grouping_and_favorite() {
|
||||||
|
let csv = format!("{HEADER}\nhttp://sn,,,,body,Title,Personal,1");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items[0].group.as_deref(), Some("Personal"));
|
||||||
|
assert!(items[0].favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_note_preserves_structured_extra_verbatim() {
|
||||||
|
// LastPass packs structured note data (e.g. credit cards) into `extra`
|
||||||
|
// using their own key:value format. We do NOT auto-parse it — verbatim
|
||||||
|
// pass-through, per spec D10.
|
||||||
|
let csv_body = "NoteType:Credit Card\nNumber:4111111111111111\nCVV:123";
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
http://sn,,,,\"{csv_body}\",Visa,,",
|
||||||
|
csv_body = csv_body,
|
||||||
|
);
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::SecureNote(sn) => assert_eq!(sn.body.as_str(), csv_body),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_with_unparseable_url_imports_with_url_none_and_warns() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
not-a-real-url,alice,hunter2,,,Site,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
match &items[0].core {
|
||||||
|
ItemCore::Login(l) => assert!(l.url.is_none()),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
assert_eq!(warnings.len(), 1);
|
||||||
|
assert!(warnings[0].message.contains("URL"), "msg: {}", warnings[0].message);
|
||||||
|
assert_eq!(warnings[0].title.as_deref(), Some("Site"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_with_extra_column_is_rejected() {
|
||||||
|
let bad = "url,username,password,totp,extra,name,grouping,fav,EXTRA\nhttps://x,u,p,,,T,,";
|
||||||
|
let err = parse_lastpass_csv(bad.as_bytes()).unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
assert!(msg.contains("LastPass") || msg.contains("expected"), "msg: {msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_with_wrong_column_order_is_rejected() {
|
||||||
|
let swapped = "name,url,username,password,totp,extra,grouping,fav\nT,https://x,u,p,,,,";
|
||||||
|
let err = parse_lastpass_csv(swapped.as_bytes()).unwrap_err();
|
||||||
|
assert!(format!("{err}").contains("expected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoted_comma_in_extra_parses() {
|
||||||
|
let csv = format!(
|
||||||
|
"{HEADER}\n\
|
||||||
|
https://x,u,p,,\"hint with, a comma\",Site,,",
|
||||||
|
);
|
||||||
|
let (items, warnings) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert_eq!(items[0].notes.as_deref(), Some("hint with, a comma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_title_round_trips() {
|
||||||
|
let csv = format!("{HEADER}\nhttps://x,u,p,,,Müllerstraße — café ☕,,");
|
||||||
|
let (items, _) = parse_lastpass_csv(csv.as_bytes()).unwrap();
|
||||||
|
assert_eq!(items[0].title, "Müllerstraße — café ☕");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_csv_after_header_returns_empty_vecs() {
|
||||||
|
let (items, warnings) = parse_lastpass_csv(HEADER.as_bytes()).unwrap();
|
||||||
|
assert!(items.is_empty());
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_header_is_rejected() {
|
||||||
|
// Empty input — csv reader treats first row as header (which doesn't exist).
|
||||||
|
let err = parse_lastpass_csv(b"").unwrap_err();
|
||||||
|
let msg = format!("{err}");
|
||||||
|
// Either ImportCsvHeader (header didn't match) or ImportCsvFormat (read
|
||||||
|
// failed). Both are acceptable; we just need a clear error.
|
||||||
|
assert!(msg.contains("LastPass") || msg.contains("CSV"), "msg: {msg}");
|
||||||
|
}
|
||||||
111
crates/relicario-core/tests/integration.rs
Normal file
111
crates/relicario-core/tests/integration.rs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
//! End-to-end integration tests for the typed-item core.
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
crypto::KdfParams,
|
||||||
|
derive_master_key, encrypt_item, decrypt_item,
|
||||||
|
encrypt_manifest, decrypt_manifest,
|
||||||
|
encrypt_settings, decrypt_settings,
|
||||||
|
Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings,
|
||||||
|
};
|
||||||
|
use relicario_core::item_types::{LoginCore, SecureNoteCore};
|
||||||
|
use url::Url;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
fn fast_params() -> KdfParams {
|
||||||
|
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_workflow_login_and_note() {
|
||||||
|
let salt = [0xAAu8; 32];
|
||||||
|
let img = [0xBBu8; 32];
|
||||||
|
let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap();
|
||||||
|
|
||||||
|
let mut manifest = Manifest::new();
|
||||||
|
let settings = VaultSettings::default();
|
||||||
|
|
||||||
|
// Add a Login
|
||||||
|
let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore {
|
||||||
|
username: Some("alice".into()),
|
||||||
|
password: Some(Zeroizing::new("hunter2".into())),
|
||||||
|
url: Some(Url::parse("https://github.com").unwrap()),
|
||||||
|
totp: None,
|
||||||
|
}));
|
||||||
|
manifest.upsert(&login);
|
||||||
|
let login_blob = encrypt_item(&login, &key).unwrap();
|
||||||
|
|
||||||
|
// Add a SecureNote
|
||||||
|
let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore {
|
||||||
|
body: Zeroizing::new("recovery codes go here".into()),
|
||||||
|
}));
|
||||||
|
manifest.upsert(¬e);
|
||||||
|
let note_blob = encrypt_item(¬e, &key).unwrap();
|
||||||
|
|
||||||
|
// Encrypt manifest + settings
|
||||||
|
let manifest_blob = encrypt_manifest(&manifest, &key).unwrap();
|
||||||
|
let settings_blob = encrypt_settings(&settings, &key).unwrap();
|
||||||
|
|
||||||
|
// Decrypt + verify
|
||||||
|
let m = decrypt_manifest(&manifest_blob, &key).unwrap();
|
||||||
|
assert_eq!(m.items.len(), 2);
|
||||||
|
|
||||||
|
let l: Item = decrypt_item(&login_blob, &key).unwrap();
|
||||||
|
let n: Item = decrypt_item(¬e_blob, &key).unwrap();
|
||||||
|
let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(l.title, "GitHub");
|
||||||
|
assert_eq!(n.title, "recovery");
|
||||||
|
assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_factor_independence() {
|
||||||
|
// Same passphrase, different image_secret → different keys.
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let img_a = [0x01u8; 32];
|
||||||
|
let img_b = [0x02u8; 32];
|
||||||
|
|
||||||
|
let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap();
|
||||||
|
let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap();
|
||||||
|
assert_ne!(*key_a, *key_b);
|
||||||
|
|
||||||
|
// Different passphrase, same image_secret → different keys.
|
||||||
|
let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap();
|
||||||
|
assert_ne!(*key_a, *key_c);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_history_persists_through_round_trip() {
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let img = [0u8; 32];
|
||||||
|
let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap();
|
||||||
|
|
||||||
|
let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default()));
|
||||||
|
let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into())));
|
||||||
|
let fid = f.id.clone();
|
||||||
|
item.sections.push(Section { name: None, fields: vec![f] });
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap();
|
||||||
|
item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap();
|
||||||
|
|
||||||
|
let blob = encrypt_item(&item, &key).unwrap();
|
||||||
|
let decoded = decrypt_item(&blob, &key).unwrap();
|
||||||
|
let hist = decoded.field_history.get(&fid).unwrap();
|
||||||
|
assert_eq!(hist.len(), 2);
|
||||||
|
assert_eq!(hist[0].value.as_str(), "v0");
|
||||||
|
assert_eq!(hist[1].value.as_str(), "v1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_fails_with_opaque_decrypt() {
|
||||||
|
use relicario_core::RelicarioError;
|
||||||
|
|
||||||
|
let salt = [0u8; 32];
|
||||||
|
let img = [0u8; 32];
|
||||||
|
let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap();
|
||||||
|
let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap();
|
||||||
|
|
||||||
|
let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
|
||||||
|
let blob = encrypt_item(&item, &right).unwrap();
|
||||||
|
let err = decrypt_item(&blob, &wrong);
|
||||||
|
assert!(matches!(err, Err(RelicarioError::Decrypt)));
|
||||||
|
}
|
||||||
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
60
crates/relicario-core/tests/recovery_qr.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use relicario_core::{
|
||||||
|
crypto::KdfParams,
|
||||||
|
generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn fast_params() -> KdfParams {
|
||||||
|
KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_secret() -> [u8; 32] {
|
||||||
|
let mut s = [0u8; 32];
|
||||||
|
for (i, b) in s.iter_mut().enumerate() { *b = i as u8; }
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_recovers_image_secret() {
|
||||||
|
let passphrase = "correct-horse-battery-staple";
|
||||||
|
let secret = test_secret();
|
||||||
|
let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params())
|
||||||
|
.expect("generate ok");
|
||||||
|
let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params())
|
||||||
|
.expect("unwrap ok");
|
||||||
|
assert_eq!(recovered.as_ref(), &secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_passphrase_fails_decrypt() {
|
||||||
|
let secret = test_secret();
|
||||||
|
let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params())
|
||||||
|
.expect("generate ok");
|
||||||
|
let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_is_109_bytes() {
|
||||||
|
let secret = test_secret();
|
||||||
|
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
|
||||||
|
.expect("generate ok");
|
||||||
|
assert_eq!(payload.as_bytes().len(), 109);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn svg_output_is_non_empty_xml() {
|
||||||
|
let secret = test_secret();
|
||||||
|
let payload = generate_recovery_qr_with_params("test", &secret, &fast_params())
|
||||||
|
.expect("generate ok");
|
||||||
|
let svg = recovery_qr_to_svg(&payload);
|
||||||
|
assert!(svg.contains("<svg"), "SVG output should contain <svg tag");
|
||||||
|
assert!(!svg.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_magic_returns_error() {
|
||||||
|
let mut bad = [0u8; 109];
|
||||||
|
bad[0..4].copy_from_slice(b"NOPE");
|
||||||
|
let result = unwrap_recovery_qr_with_params(&bad, "pass", &fast_params());
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tar::{Builder, Header, EntryType};
|
||||||
|
use relicario_core::safe_unpack_git_archive;
|
||||||
|
|
||||||
|
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
|
||||||
|
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
|
||||||
|
/// manually to produce truly malicious archives.
|
||||||
|
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; 512]; // one header block
|
||||||
|
|
||||||
|
// Bytes 0-99: name field (null-padded)
|
||||||
|
let name_len = raw_path.len().min(100);
|
||||||
|
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
|
||||||
|
|
||||||
|
// Bytes 100-107: mode = "0000644\0"
|
||||||
|
buf[100..108].copy_from_slice(b"0000644\0");
|
||||||
|
|
||||||
|
// Bytes 108-115: uid
|
||||||
|
buf[108..116].copy_from_slice(b"0000000\0");
|
||||||
|
|
||||||
|
// Bytes 116-123: gid
|
||||||
|
buf[116..124].copy_from_slice(b"0000000\0");
|
||||||
|
|
||||||
|
// Bytes 124-135: size (octal, 11 digits + null)
|
||||||
|
let size_str = format!("{:011o}\0", content.len());
|
||||||
|
buf[124..136].copy_from_slice(size_str.as_bytes());
|
||||||
|
|
||||||
|
// Bytes 136-147: mtime
|
||||||
|
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||||
|
|
||||||
|
// Bytes 148-155: checksum placeholder (spaces during compute)
|
||||||
|
buf[148..156].copy_from_slice(b" ");
|
||||||
|
|
||||||
|
// Byte 156: typeflag = '0' (regular file)
|
||||||
|
buf[156] = b'0';
|
||||||
|
|
||||||
|
// Bytes 257-262: magic "ustar\0"
|
||||||
|
buf[257..263].copy_from_slice(b"ustar\0");
|
||||||
|
// Bytes 263-264: version "00"
|
||||||
|
buf[263..265].copy_from_slice(b"00");
|
||||||
|
|
||||||
|
// Compute checksum (sum of all bytes, checksum field treated as spaces).
|
||||||
|
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||||
|
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||||
|
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||||
|
|
||||||
|
// Append padded content blocks.
|
||||||
|
let mut out = buf;
|
||||||
|
if !content.is_empty() {
|
||||||
|
out.extend_from_slice(content);
|
||||||
|
// Pad to 512-byte boundary.
|
||||||
|
let remainder = content.len() % 512;
|
||||||
|
if remainder != 0 {
|
||||||
|
out.extend(vec![0u8; 512 - remainder]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two zero blocks = end-of-archive.
|
||||||
|
out.extend(vec![0u8; 1024]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a tar with a raw symlink entry (typeflag = '2').
|
||||||
|
fn raw_symlink_tar() -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; 512];
|
||||||
|
|
||||||
|
// name
|
||||||
|
buf[..9].copy_from_slice(b"evil_link");
|
||||||
|
// mode
|
||||||
|
buf[100..108].copy_from_slice(b"0000755\0");
|
||||||
|
// uid/gid
|
||||||
|
buf[108..116].copy_from_slice(b"0000000\0");
|
||||||
|
buf[116..124].copy_from_slice(b"0000000\0");
|
||||||
|
// size = 0
|
||||||
|
buf[124..136].copy_from_slice(b"00000000000\0");
|
||||||
|
// mtime
|
||||||
|
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||||
|
// checksum placeholder
|
||||||
|
buf[148..156].copy_from_slice(b" ");
|
||||||
|
// typeflag = '2' (symlink)
|
||||||
|
buf[156] = b'2';
|
||||||
|
// linkname
|
||||||
|
let target = b"/etc/passwd";
|
||||||
|
buf[157..157 + target.len()].copy_from_slice(target);
|
||||||
|
// magic
|
||||||
|
buf[257..263].copy_from_slice(b"ustar\0");
|
||||||
|
buf[263..265].copy_from_slice(b"00");
|
||||||
|
|
||||||
|
// Compute checksum.
|
||||||
|
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||||
|
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||||
|
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||||
|
|
||||||
|
let mut out = buf;
|
||||||
|
out.extend(vec![0u8; 1024]); // end-of-archive
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_normal_tar() -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut buf);
|
||||||
|
let content = b"hello";
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_entry_type(EntryType::Regular);
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
|
||||||
|
.unwrap();
|
||||||
|
builder.finish().unwrap();
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_oversize_tar() -> Vec<u8> {
|
||||||
|
// Actual 2048-byte body; test will use cap=1024
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut buf);
|
||||||
|
let content = vec![0u8; 2048];
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_entry_type(EntryType::Regular);
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, "bigfile.bin", content.as_slice())
|
||||||
|
.unwrap();
|
||||||
|
builder.finish().unwrap();
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_path_traversal() {
|
||||||
|
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
|
||||||
|
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("path traversal") || msg.contains(".."),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_absolute_path() {
|
||||||
|
// Craft a tar with "/etc/escaped.txt" using raw bytes.
|
||||||
|
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("path traversal") || msg.contains("absolute"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_symlink() {
|
||||||
|
let bytes = raw_symlink_tar();
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("symlink") || msg.contains("link"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_size_bomb() {
|
||||||
|
let bytes = build_oversize_tar(); // actual 2048-byte entry
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_accepts_normal_files() {
|
||||||
|
let buf = build_normal_tar();
|
||||||
|
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
|
||||||
|
assert_eq!(entries[0].1, b"hello");
|
||||||
|
}
|
||||||
18
crates/relicario-server/Cargo.toml
Normal file
18
crates/relicario-server/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "relicario-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
relicario-core = { path = "../relicario-core" }
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
189
crates/relicario-server/src/main.rs
Normal file
189
crates/relicario-server/src/main.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
//! relicario-server -- pre-receive hook for signature verification.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use relicario_core::device::{DeviceEntry, RevokedEntry};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "relicario-server")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Verify a commit's signature against devices.json.
|
||||||
|
VerifyCommit {
|
||||||
|
/// The commit SHA to verify.
|
||||||
|
commit: String,
|
||||||
|
},
|
||||||
|
/// Generate a pre-receive hook script.
|
||||||
|
GenerateHook,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::VerifyCommit { commit } => verify_commit(&commit),
|
||||||
|
Commands::GenerateHook => generate_hook(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_commit(commit: &str) -> Result<()> {
|
||||||
|
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||||
|
.context("parse devices.json")?;
|
||||||
|
|
||||||
|
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// True bootstrap: no devices ever registered and none revoked.
|
||||||
|
if devices.is_empty() && revoked.is_empty() {
|
||||||
|
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build temp allowed-signers file from registered devices.
|
||||||
|
let tmp = tempfile::tempdir().context("create tempdir")?;
|
||||||
|
let allowed_path = tmp.path().join("allowed_signers");
|
||||||
|
let mut allowed_body = String::new();
|
||||||
|
for d in &devices {
|
||||||
|
allowed_body.push_str("relicario ");
|
||||||
|
allowed_body.push_str(d.public_key.trim());
|
||||||
|
allowed_body.push('\n');
|
||||||
|
}
|
||||||
|
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
|
||||||
|
|
||||||
|
// Run git verify-commit --raw. Capture both exit code and stderr.
|
||||||
|
// NOTE: we do NOT short-circuit on non-zero exit here because even for
|
||||||
|
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["verify-commit", "--raw", commit])
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||||
|
.output()
|
||||||
|
.context("git verify-commit")?;
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
// Parse the SHA-256 fingerprint from stderr.
|
||||||
|
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
|
||||||
|
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||||
|
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||||
|
Some(m) => m.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
// No fingerprint in stderr = unsigned or completely malformed signature.
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: commit {commit} — no valid signature found (stderr: {})",
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build fingerprint → entry maps.
|
||||||
|
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for d in &devices {
|
||||||
|
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
|
||||||
|
device_by_fp.insert(fp, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for r in &revoked {
|
||||||
|
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
|
||||||
|
revoked_by_fp.insert(fp, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get committer date (NOT author date).
|
||||||
|
let ct_out = Command::new("git")
|
||||||
|
.args(["show", "-s", "--format=%ct", commit])
|
||||||
|
.output()
|
||||||
|
.context("git show committer date")?;
|
||||||
|
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.context("parse committer timestamp")?;
|
||||||
|
|
||||||
|
// Check revocation FIRST (revoked entries may not be in devices anymore).
|
||||||
|
if let Some(r) = revoked_by_fp.get(&signing_fp) {
|
||||||
|
if committer_ts >= r.revoked_at {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: commit {commit} — signed by revoked device '{}' \
|
||||||
|
(committer ts {committer_ts} >= revoked_at {})",
|
||||||
|
r.name, r.revoked_at
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
// Historical commit: committer_ts < revoked_at → was valid when signed.
|
||||||
|
eprintln!(
|
||||||
|
"OK: commit {commit} — historical commit signed by '{}' before revocation",
|
||||||
|
r.name
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not revoked — must be in active devices.
|
||||||
|
if !device_by_fp.contains_key(&signing_fp) {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_hook() -> Result<()> {
|
||||||
|
print!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
# Relicario pre-receive hook -- verify all commits are signed by registered devices
|
||||||
|
|
||||||
|
while read oldrev newrev refname; do
|
||||||
|
[ "$newrev" = "0000000000000000000000000000000000000000" ] && continue
|
||||||
|
|
||||||
|
if [ "$oldrev" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
commits=$(git rev-list "$newrev")
|
||||||
|
else
|
||||||
|
commits=$(git rev-list "$oldrev..$newrev")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for commit in $commits; do
|
||||||
|
relicario-server verify-commit "$commit" || exit 1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_show(commit: &str, path: &str) -> Result<String> {
|
||||||
|
let output = Command::new("git")
|
||||||
|
.args(["show", &format!("{}:{}", commit, path)])
|
||||||
|
.output()
|
||||||
|
.context("git show")?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!("git show {}:{} failed", commit, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8(output.stdout)?)
|
||||||
|
}
|
||||||
230
crates/relicario-server/tests/verify_commit.rs
Normal file
230
crates/relicario-server/tests/verify_commit.rs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
//! Acceptance tests for `relicario-server verify-commit`.
|
||||||
|
//!
|
||||||
|
//! Four scenarios from audit S1:
|
||||||
|
//! 1. Registered non-revoked key → exit 0
|
||||||
|
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
|
||||||
|
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
|
||||||
|
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
|
||||||
|
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||||
|
let priv_path = dir.join(format!("{name}.key"));
|
||||||
|
let pub_path = dir.join(format!("{name}.pub"));
|
||||||
|
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||||
|
fs::write(&pub_path, &pub_line).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
}
|
||||||
|
(priv_path, pub_path, pub_line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.current_dir(repo).args(args);
|
||||||
|
for (k, v) in extra_env {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
let status = cmd.status().expect("spawn git");
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_repo(repo: &Path) {
|
||||||
|
git(repo, &["init", "-q", "-b", "main"], &[]);
|
||||||
|
git(repo, &["config", "user.email", "test@test"], &[]);
|
||||||
|
git(repo, &["config", "user.name", "test"], &[]);
|
||||||
|
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_commit(
|
||||||
|
repo: &Path,
|
||||||
|
signing_key: &Path,
|
||||||
|
allowed_signers: &Path,
|
||||||
|
committer_unix: i64,
|
||||||
|
msg: &str,
|
||||||
|
file_path: &str,
|
||||||
|
file_content: &str,
|
||||||
|
) -> String {
|
||||||
|
fs::write(repo.join(file_path), file_content).unwrap();
|
||||||
|
git(repo, &["add", file_path], &[]);
|
||||||
|
let date = format!("@{committer_unix} +0000");
|
||||||
|
git(
|
||||||
|
repo,
|
||||||
|
&[
|
||||||
|
"-c", "gpg.format=ssh",
|
||||||
|
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||||
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
|
||||||
|
"commit", "-S", "-q", "-m", msg,
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("GIT_AUTHOR_DATE", &date),
|
||||||
|
("GIT_COMMITTER_DATE", &date),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["rev-parse", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
|
||||||
|
let dir = repo.join(".relicario");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
|
||||||
|
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
|
||||||
|
git(repo, &["add", ".relicario"], &[]);
|
||||||
|
git(repo, &["commit", "-q", "-m", "device files"], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_non_revoked_key_accepted() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[DeviceEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
added_at: 1_700_000_000,
|
||||||
|
added_by: "bootstrap".into(),
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||||
|
|
||||||
|
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_key_rejected() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (_, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
|
||||||
|
|
||||||
|
// Only Alice is registered.
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[DeviceEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
added_at: 1_700_000_000,
|
||||||
|
added_by: "bootstrap".into(),
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evil signs against a file containing both keys so git commit signing works,
|
||||||
|
// but the binary's allowed-signers (from devices.json) only has Alice.
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(
|
||||||
|
&allowed,
|
||||||
|
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("unregistered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_key_after_revoked_at_rejected() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
|
||||||
|
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[],
|
||||||
|
&[RevokedEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
revoked_at: 1_705_000_000,
|
||||||
|
revoked_by: "admin".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||||
|
|
||||||
|
// Commit dated AFTER revocation.
|
||||||
|
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("revoked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_key_before_revoked_at_accepted_historical() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
|
||||||
|
// Same as above: Alice only in revoked.json.
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[],
|
||||||
|
&[RevokedEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
revoked_at: 1_705_000_000,
|
||||||
|
revoked_by: "admin".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||||
|
|
||||||
|
// Commit dated BEFORE revocation -- historical case must pass.
|
||||||
|
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
26
crates/relicario-wasm/Cargo.toml
Normal file
26
crates/relicario-wasm/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "relicario-wasm"
|
||||||
|
version = "0.5.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "WASM bindings for relicario password manager"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
relicario-core = { path = "../relicario-core" }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
serde-wasm-bindgen = "0.6"
|
||||||
|
serde_json = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
zeroize = "1"
|
||||||
|
getrandom = { version = "0.2", features = ["js"] }
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
hex = "0.4"
|
||||||
|
rand = "0.8"
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||||
71
crates/relicario-wasm/src/device.rs
Normal file
71
crates/relicario-wasm/src/device.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//! WASM device key management -- private keys never cross to JS.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::device as core_device;
|
||||||
|
|
||||||
|
/// In-memory device key storage (private keys held in WASM linear memory).
|
||||||
|
static DEVICE_STATE: Lazy<Mutex<Option<DeviceState>>> = Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
|
struct DeviceState {
|
||||||
|
name: String,
|
||||||
|
signing_private: Zeroizing<String>,
|
||||||
|
signing_public: String,
|
||||||
|
/// Deploy key stored for future SSH git operations; not yet used for signing.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
deploy_private: Zeroizing<String>,
|
||||||
|
deploy_public: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new device, storing the keypairs internally and returning
|
||||||
|
/// only the public keys. Private keys never leave WASM memory.
|
||||||
|
pub fn register_device(name: &str) -> Result<(String, String), String> {
|
||||||
|
let (signing_priv, signing_pub) =
|
||||||
|
core_device::generate_keypair().map_err(|e| e.to_string())?;
|
||||||
|
let (deploy_priv, deploy_pub) =
|
||||||
|
core_device::generate_keypair().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let state = DeviceState {
|
||||||
|
name: name.to_string(),
|
||||||
|
signing_private: signing_priv,
|
||||||
|
signing_public: signing_pub.clone(),
|
||||||
|
deploy_private: deploy_priv,
|
||||||
|
deploy_public: deploy_pub.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
*DEVICE_STATE.lock().unwrap() = Some(state);
|
||||||
|
|
||||||
|
Ok((signing_pub, deploy_pub))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign `data` using the registered device's signing key.
|
||||||
|
/// Returns a base64-encoded signature.
|
||||||
|
pub fn sign_for_git(data: &[u8]) -> Result<String, String> {
|
||||||
|
let guard = DEVICE_STATE.lock().unwrap();
|
||||||
|
let state = guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "no device registered".to_string())?;
|
||||||
|
|
||||||
|
core_device::sign(&state.signing_private, data).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return current device info: (name, signing_public_key, deploy_public_key).
|
||||||
|
/// Returns None if no device has been registered in this session.
|
||||||
|
pub fn get_device_info() -> Option<(String, String, String)> {
|
||||||
|
let guard = DEVICE_STATE.lock().unwrap();
|
||||||
|
guard.as_ref().map(|s| {
|
||||||
|
(
|
||||||
|
s.name.clone(),
|
||||||
|
s.signing_public.clone(),
|
||||||
|
s.deploy_public.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear device state (call on logout or before re-registration).
|
||||||
|
pub fn clear_device() {
|
||||||
|
*DEVICE_STATE.lock().unwrap() = None;
|
||||||
|
}
|
||||||
627
crates/relicario-wasm/src/lib.rs
Normal file
627
crates/relicario-wasm/src/lib.rs
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
//! WASM bindings for relicario.
|
||||||
|
//!
|
||||||
|
//! The bridge exposes an opaque `SessionHandle` API: the master key is held
|
||||||
|
//! entirely in WASM linear memory, wrapped in `Zeroizing<[u8; 32]>`, and
|
||||||
|
//! looked up per call via a u32 handle. JS cannot read key bytes.
|
||||||
|
|
||||||
|
mod session;
|
||||||
|
mod device;
|
||||||
|
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use relicario_core::{derive_master_key, imgsecret, KdfParams};
|
||||||
|
|
||||||
|
/// Handle returned from `unlock`. Backed by a `u32`; opaque to JS.
|
||||||
|
///
|
||||||
|
/// Dropping the handle (or invoking `.free()` from JS) removes the entry from
|
||||||
|
/// the session registry, zeroizing the wrapped master key and image_secret.
|
||||||
|
/// `lock(handle)` remains available as the explicit early-cleanup path; the
|
||||||
|
/// `Drop` impl is the safety net that catches code paths which forget to call
|
||||||
|
/// `lock` before letting the handle go out of scope.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct SessionHandle(u32);
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl SessionHandle {
|
||||||
|
#[wasm_bindgen(getter)]
|
||||||
|
pub fn value(&self) -> u32 { self.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SessionHandle {
|
||||||
|
fn drop(&mut self) { let _ = session::remove(self.0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn __test_make_handle() -> SessionHandle {
|
||||||
|
SessionHandle(session::insert(
|
||||||
|
Zeroizing::new([0x77u8; 32]),
|
||||||
|
Zeroizing::new([0u8; 32]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn __test_session_exists(handle: u32) -> bool {
|
||||||
|
session::with(handle, |_| ()).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unlock(
|
||||||
|
passphrase: &str,
|
||||||
|
image_bytes: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
params_json: &str,
|
||||||
|
) -> Result<SessionHandle, JsError> {
|
||||||
|
let params: KdfParams = serde_json::from_str(params_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("params: {e}")))?;
|
||||||
|
let image_secret = imgsecret::extract(image_bytes)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let salt_arr: &[u8; 32] = salt.try_into()
|
||||||
|
.map_err(|_| JsError::new("salt must be exactly 32 bytes"))?;
|
||||||
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let stored_secret = Zeroizing::new(image_secret);
|
||||||
|
let handle = session::insert(master_key, stored_secret);
|
||||||
|
Ok(SessionHandle(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn lock(handle: &SessionHandle) -> bool {
|
||||||
|
session::remove(handle.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent wasm_bindgen fns added in Tasks 19-21.
|
||||||
|
|
||||||
|
use serde_wasm_bindgen::Serializer;
|
||||||
|
use relicario_core::{
|
||||||
|
decrypt_item, decrypt_manifest, decrypt_settings,
|
||||||
|
encrypt_item, encrypt_manifest, encrypt_settings,
|
||||||
|
Item, Manifest, VaultSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn need_key(handle: &SessionHandle) -> Result<(), JsError> {
|
||||||
|
if session::with(handle.0, |_| ()).is_some() { Ok(()) }
|
||||||
|
else { Err(JsError::new("invalid or locked session handle")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_value_for<T: serde::Serialize>(v: &T) -> Result<JsValue, JsError> {
|
||||||
|
let ser = Serializer::new().serialize_maps_as_objects(true);
|
||||||
|
v.serialize(&ser).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let out = session::with(handle.0, |k| decrypt_manifest(encrypted, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
js_value_for(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result<Vec<u8>, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let m: Manifest = serde_json::from_str(manifest_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("manifest json: {e}")))?;
|
||||||
|
session::with(handle.0, |k| encrypt_manifest(&m, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn item_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let out = session::with(handle.0, |k| decrypt_item(encrypted, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
js_value_for(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn item_encrypt(handle: &SessionHandle, item_json: &str) -> Result<Vec<u8>, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let item: Item = serde_json::from_str(item_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
|
||||||
|
session::with(handle.0, |k| encrypt_item(&item, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn settings_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result<JsValue, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let out = session::with(handle.0, |k| decrypt_settings(encrypted, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
js_value_for(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn settings_encrypt(handle: &SessionHandle, settings_json: &str) -> Result<Vec<u8>, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let s: VaultSettings = serde_json::from_str(settings_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("settings json: {e}")))?;
|
||||||
|
session::with(handle.0, |k| encrypt_settings(&s, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the JSON for `VaultSettings::default()`. Used by the setup
|
||||||
|
/// wizard to encrypt and write a default settings.enc on new-vault setup.
|
||||||
|
/// Keeping this in WASM (instead of hand-encoding in TS) prevents drift
|
||||||
|
/// when the default VaultSettings shape changes in Rust.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn default_vault_settings_json() -> Result<String, JsError> {
|
||||||
|
let s = VaultSettings::default();
|
||||||
|
serde_json::to_string(&s).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 20: attachment / generator / imgsecret / ID / TOTP bridges ─────────
|
||||||
|
|
||||||
|
use relicario_core::{decrypt_attachment, encrypt_attachment, FieldId, ItemId};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct EncryptedAttachment {
|
||||||
|
aid: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl EncryptedAttachment {
|
||||||
|
#[wasm_bindgen(getter)] pub fn aid(&self) -> String { self.aid.clone() }
|
||||||
|
#[wasm_bindgen(getter)] pub fn bytes(&self) -> Vec<u8> { self.bytes.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn attachment_encrypt(
|
||||||
|
handle: &SessionHandle,
|
||||||
|
plaintext: &[u8],
|
||||||
|
max_bytes: u64,
|
||||||
|
) -> Result<EncryptedAttachment, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let enc = session::with(handle.0, |k| encrypt_attachment(plaintext, k, max_bytes))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(EncryptedAttachment { aid: enc.id.as_str().to_owned(), bytes: enc.bytes })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn attachment_decrypt(
|
||||||
|
handle: &SessionHandle,
|
||||||
|
encrypted: &[u8],
|
||||||
|
) -> Result<Vec<u8>, JsError> {
|
||||||
|
need_key(handle)?;
|
||||||
|
let plain = session::with(handle.0, |k| decrypt_attachment(encrypted, k))
|
||||||
|
.unwrap()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(plain.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen] pub fn new_item_id() -> String { ItemId::new().as_str().to_owned() }
|
||||||
|
#[wasm_bindgen] pub fn new_field_id() -> String { FieldId::new().as_str().to_owned() }
|
||||||
|
|
||||||
|
use relicario_core::{
|
||||||
|
generate_passphrase as core_generate_passphrase,
|
||||||
|
generate_password as core_generate_password,
|
||||||
|
rate_passphrase as core_rate_passphrase,
|
||||||
|
GeneratorRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn generate_password(request_json: &str) -> Result<String, JsError> {
|
||||||
|
let req: GeneratorRequest = serde_json::from_str(request_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
|
||||||
|
let out = core_generate_password(&req).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(out.as_str().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn generate_passphrase(request_json: &str) -> Result<String, JsError> {
|
||||||
|
let req: GeneratorRequest = serde_json::from_str(request_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("generator request: {e}")))?;
|
||||||
|
let out = core_generate_passphrase(&req).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(out.as_str().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn rate_passphrase(p: &str) -> Result<JsValue, JsError> {
|
||||||
|
let est = core_rate_passphrase(p);
|
||||||
|
js_value_for(&serde_json::json!({
|
||||||
|
"score": est.score,
|
||||||
|
"guesses_log10": est.guesses_log10,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new device, generating ed25519 keypairs for signing and deploy.
|
||||||
|
/// Returns JSON: { "signing_public_key": "ssh-ed25519 ...", "deploy_public_key": "ssh-ed25519 ..." }
|
||||||
|
/// Private keys are kept internal to WASM and never cross to JS.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn register_device(name: &str) -> Result<JsValue, JsError> {
|
||||||
|
let (signing_pub, deploy_pub) =
|
||||||
|
device::register_device(name).map_err(|e| JsError::new(&e))?;
|
||||||
|
|
||||||
|
js_value_for(&serde_json::json!({
|
||||||
|
"signing_public_key": signing_pub,
|
||||||
|
"deploy_public_key": deploy_pub,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign `data` using the registered device's signing key.
|
||||||
|
/// Returns JSON: { "signature": "<base64>" }
|
||||||
|
/// Errors if no device has been registered via register_device().
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn sign_for_git(data: &[u8]) -> Result<JsValue, JsError> {
|
||||||
|
let signature = device::sign_for_git(data).map_err(|e| JsError::new(&e))?;
|
||||||
|
|
||||||
|
js_value_for(&serde_json::json!({
|
||||||
|
"signature": signature,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current device's name and public keys.
|
||||||
|
/// Returns JSON: { "name": "...", "signing_public_key": "...", "deploy_public_key": "..." }
|
||||||
|
/// Returns null if no device is registered in this session.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_device_info() -> Result<JsValue, JsError> {
|
||||||
|
match device::get_device_info() {
|
||||||
|
Some((name, signing_pub, deploy_pub)) => js_value_for(&serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"signing_public_key": signing_pub,
|
||||||
|
"deploy_public_key": deploy_pub,
|
||||||
|
})),
|
||||||
|
None => Ok(JsValue::NULL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the in-memory device state (call on logout or before re-registration).
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn clear_device() {
|
||||||
|
device::clear_device();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract field history from a decrypted item JSON.
|
||||||
|
/// Returns JSON array of { field_id, field_name, current_value, entries: [{ value, changed_at }] }
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_field_history(item_json: &str) -> Result<JsValue, JsError> {
|
||||||
|
let item: Item = serde_json::from_str(item_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("item json: {e}")))?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Only section fields are tracked in field_history (set_field_value operates on sections).
|
||||||
|
for section in &item.sections {
|
||||||
|
for field in §ion.fields {
|
||||||
|
if field.value.is_history_tracked() {
|
||||||
|
if let Some(entries) = item.field_history.get(&field.id) {
|
||||||
|
if !entries.is_empty() {
|
||||||
|
let current = match &field.value {
|
||||||
|
relicario_core::FieldValue::Password(v) => v.as_str().to_owned(),
|
||||||
|
relicario_core::FieldValue::Concealed(v) => v.as_str().to_owned(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
results.push(serde_json::json!({
|
||||||
|
"field_id": field.id.as_str(),
|
||||||
|
"field_name": &field.label,
|
||||||
|
"current_value": current,
|
||||||
|
"entries": entries.iter().map(|e| serde_json::json!({
|
||||||
|
"value": e.value.as_str(),
|
||||||
|
"changed_at": e.replaced_at,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
js_value_for(&results)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn extract_image_secret(image_bytes: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||||
|
let s = imgsecret::extract(image_bytes).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(s.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||||
|
let s: &[u8; 32] = secret.try_into()
|
||||||
|
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
|
||||||
|
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
use relicario_core::item_types::{TotpConfig, compute_totp_code};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub struct TotpCode {
|
||||||
|
code: String,
|
||||||
|
expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
impl TotpCode {
|
||||||
|
#[wasm_bindgen(getter)] pub fn code(&self) -> String { self.code.clone() }
|
||||||
|
#[wasm_bindgen(getter)] pub fn expires_at(&self) -> u64 { self.expires_at }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn totp_compute(
|
||||||
|
config_json: &str,
|
||||||
|
now_unix_seconds: u64,
|
||||||
|
) -> Result<TotpCode, JsError> {
|
||||||
|
let cfg: TotpConfig = serde_json::from_str(config_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("totp config: {e}")))?;
|
||||||
|
let code = compute_totp_code(&cfg, now_unix_seconds)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let period = cfg.period_seconds as u64;
|
||||||
|
let expires_at = ((now_unix_seconds / period) + 1) * period;
|
||||||
|
Ok(TotpCode { code, expires_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backup container bridge ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
use relicario_core::backup::{
|
||||||
|
pack_backup as core_pack_backup,
|
||||||
|
unpack_backup as core_unpack_backup,
|
||||||
|
BackupInput, BackupItem, BackupAttachment,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pack a vault into a `.relbak` byte vector.
|
||||||
|
///
|
||||||
|
/// `input_json` shape:
|
||||||
|
/// ```json
|
||||||
|
/// {
|
||||||
|
/// "salt": "<base64>",
|
||||||
|
/// "params_json": "...",
|
||||||
|
/// "devices_json": "...",
|
||||||
|
/// "manifest_enc": "<base64>",
|
||||||
|
/// "settings_enc": "<base64>",
|
||||||
|
/// "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
|
||||||
|
/// "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
|
||||||
|
/// "reference_jpg": "<base64>" | null,
|
||||||
|
/// "git_archive": "<base64>" | null
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct InJson {
|
||||||
|
salt: String,
|
||||||
|
params_json: String,
|
||||||
|
devices_json: String,
|
||||||
|
manifest_enc: String,
|
||||||
|
settings_enc: String,
|
||||||
|
items: Vec<InItem>,
|
||||||
|
attachments: Vec<InAttachment>,
|
||||||
|
reference_jpg: Option<String>,
|
||||||
|
git_archive: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct InItem { id: String, ciphertext: String }
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct InAttachment { item_id: String, attachment_id: String, ciphertext: String }
|
||||||
|
|
||||||
|
let parsed: InJson = serde_json::from_str(input_json)
|
||||||
|
.map_err(|e| JsError::new(&format!("backup input: {e}")))?;
|
||||||
|
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let items_bytes: Vec<(String, Vec<u8>)> = parsed.items.iter()
|
||||||
|
.map(|i| {
|
||||||
|
let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok((i.id.clone(), ct))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, JsError>>()?;
|
||||||
|
let attach_bytes: Vec<(String, String, Vec<u8>)> = parsed.attachments.iter()
|
||||||
|
.map(|a| {
|
||||||
|
let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok((a.item_id.clone(), a.attachment_id.clone(), ct))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, JsError>>()?;
|
||||||
|
|
||||||
|
let ref_bytes = parsed.reference_jpg.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
let git_bytes = parsed.git_archive.as_deref()
|
||||||
|
.map(|s| b64.decode(s))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let items_refs: Vec<BackupItem> = items_bytes.iter()
|
||||||
|
.map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
|
||||||
|
.collect();
|
||||||
|
let attach_refs: Vec<BackupAttachment> = attach_bytes.iter()
|
||||||
|
.map(|(iid, aid, ct)| BackupAttachment {
|
||||||
|
item_id: iid.clone(),
|
||||||
|
attachment_id: aid.clone(),
|
||||||
|
ciphertext: ct,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let input = BackupInput {
|
||||||
|
salt: &salt,
|
||||||
|
params_json: &parsed.params_json,
|
||||||
|
devices_json: &parsed.devices_json,
|
||||||
|
manifest_enc: &manifest,
|
||||||
|
settings_enc: &settings,
|
||||||
|
items: items_refs,
|
||||||
|
attachments: attach_refs,
|
||||||
|
reference_jpg: ref_bytes.as_deref(),
|
||||||
|
git_archive: git_bytes.as_deref(),
|
||||||
|
};
|
||||||
|
core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`,
|
||||||
|
/// with binary fields base64-encoded.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result<String, JsError> {
|
||||||
|
let out = core_unpack_backup(bytes, passphrase)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"salt": b64.encode(out.salt),
|
||||||
|
"params_json": out.params_json,
|
||||||
|
"devices_json": out.devices_json,
|
||||||
|
"manifest_enc": b64.encode(&out.manifest_enc),
|
||||||
|
"settings_enc": b64.encode(&out.settings_enc),
|
||||||
|
"items": out.items.iter().map(|i| serde_json::json!({
|
||||||
|
"id": i.id,
|
||||||
|
"ciphertext": b64.encode(&i.ciphertext),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"attachments": out.attachments.iter().map(|a| serde_json::json!({
|
||||||
|
"item_id": a.item_id,
|
||||||
|
"attachment_id": a.attachment_id,
|
||||||
|
"ciphertext": b64.encode(&a.ciphertext),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)),
|
||||||
|
"git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)),
|
||||||
|
"created_at": out.created_at,
|
||||||
|
});
|
||||||
|
Ok(json.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LastPass CSV importer bridge ────────────────────────────────────────────
|
||||||
|
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv as core_parse_lastpass_csv;
|
||||||
|
|
||||||
|
/// Parse a LastPass CSV into `{ items: [Item], warnings: [ImportWarning] }`.
|
||||||
|
///
|
||||||
|
/// Items are returned as full `Item` JSON objects with freshly-minted IDs
|
||||||
|
/// and timestamps already populated. The SW caller is responsible for
|
||||||
|
/// encrypting + writing them; this bridge stays pure so the preview UI
|
||||||
|
/// can render counts without committing anything.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn parse_lastpass_csv_json(csv_bytes: &[u8]) -> Result<String, JsError> {
|
||||||
|
let (items, warnings) = core_parse_lastpass_csv(csv_bytes)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"items": items,
|
||||||
|
"warnings": warnings,
|
||||||
|
});
|
||||||
|
Ok(json.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recovery QR bindings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr};
|
||||||
|
|
||||||
|
/// Generate a recovery QR SVG for the current session.
|
||||||
|
/// Returns the SVG string. The passphrase wraps the image_secret under a
|
||||||
|
/// separate key (domain-separated from the master key derivation).
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn wasm_generate_recovery_qr(
|
||||||
|
handle: &SessionHandle,
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<String, JsError> {
|
||||||
|
let payload = session::with_image_secret(handle.0, |s| generate_recovery_qr(passphrase, s))
|
||||||
|
.ok_or_else(|| JsError::new("invalid or locked session handle"))?
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(recovery_qr_to_svg(&payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase.
|
||||||
|
/// Returns the raw image_secret bytes (32 bytes).
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn wasm_unwrap_recovery_qr(
|
||||||
|
payload_b64: &str,
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<Vec<u8>, JsError> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
let bytes = STANDARD.decode(payload_b64)
|
||||||
|
.map_err(|e| JsError::new(&format!("base64: {e}")))?;
|
||||||
|
let recovered = unwrap_recovery_qr(&bytes, passphrase)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))?;
|
||||||
|
Ok(recovered.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod session_tests {
|
||||||
|
use super::*;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_then_remove_clears_entry() {
|
||||||
|
session::clear();
|
||||||
|
let h = session::insert(Zeroizing::new([0x11u8; 32]), Zeroizing::new([0u8; 32]));
|
||||||
|
assert_ne!(h, 0);
|
||||||
|
assert!(session::remove(h));
|
||||||
|
assert!(!session::remove(h)); // second remove false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_session_handle_clears_registry_entry() {
|
||||||
|
session::clear();
|
||||||
|
let handle = SessionHandle(session::insert(
|
||||||
|
Zeroizing::new([0x33u8; 32]),
|
||||||
|
Zeroizing::new([0u8; 32]),
|
||||||
|
));
|
||||||
|
let id = handle.value();
|
||||||
|
assert!(session::with(id, |_| ()).is_some());
|
||||||
|
drop(handle);
|
||||||
|
assert!(session::with(id, |_| ()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_yields_key_only_while_session_lives() {
|
||||||
|
session::clear();
|
||||||
|
let h = session::insert(Zeroizing::new([0x22u8; 32]), Zeroizing::new([0u8; 32]));
|
||||||
|
let byte = session::with(h, |k| k[0]);
|
||||||
|
assert_eq!(byte, Some(0x22));
|
||||||
|
session::remove(h);
|
||||||
|
let byte = session::with(h, |k| k[0]);
|
||||||
|
assert_eq!(byte, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_round_trip_via_handle() {
|
||||||
|
use relicario_core::{Manifest, decrypt_manifest};
|
||||||
|
session::clear();
|
||||||
|
let h = session::insert(Zeroizing::new([0x55u8; 32]), Zeroizing::new([0u8; 32]));
|
||||||
|
let handle = SessionHandle(h);
|
||||||
|
let key = Zeroizing::new([0x55u8; 32]);
|
||||||
|
let empty = Manifest::new();
|
||||||
|
let bytes = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
// Decrypt via core directly (avoids js-sys on native).
|
||||||
|
let parsed: Manifest = decrypt_manifest(&bytes, &key).unwrap();
|
||||||
|
assert_eq!(parsed.items.len(), 0);
|
||||||
|
// Random nonces mean two encryptions of the same plaintext differ.
|
||||||
|
let bytes2 = manifest_encrypt(&handle, &serde_json::to_string(&empty).unwrap()).unwrap();
|
||||||
|
assert_ne!(bytes, bytes2, "nonces must differ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_lastpass_csv_json_returns_items_and_warnings() {
|
||||||
|
// Row 1 imports cleanly; row 2 has an empty `name` and is skipped
|
||||||
|
// with a warning.
|
||||||
|
let csv = "url,username,password,totp,extra,name,grouping,fav\n\
|
||||||
|
https://x,alice,hunter2,,,GitHub,Work,1\n\
|
||||||
|
https://y,bob,hunter2,,,,,";
|
||||||
|
let json = super::parse_lastpass_csv_json(csv.as_bytes()).unwrap();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(v["items"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(v["warnings"].as_array().unwrap().len(), 1);
|
||||||
|
assert!(v["warnings"][0]["message"].as_str().unwrap().contains("name"));
|
||||||
|
// The item's title round-trips as a plain JSON string.
|
||||||
|
assert_eq!(v["items"][0]["title"].as_str().unwrap(), "GitHub");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_lastpass_csv_json_propagates_header_errors() {
|
||||||
|
// Test the underlying core function directly since native tests
|
||||||
|
// can't call wasm_bindgen functions.
|
||||||
|
use relicario_core::import_lastpass::parse_lastpass_csv;
|
||||||
|
let bad = "name,user,pass\nA,u,p\n";
|
||||||
|
let err = parse_lastpass_csv(bad.as_bytes());
|
||||||
|
// Should fail with a header validation error.
|
||||||
|
assert!(err.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
60
crates/relicario-wasm/src/session.rs
Normal file
60
crates/relicario-wasm/src/session.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//! Opaque session-handle bridge. The master key never leaves WASM linear
|
||||||
|
//! memory; JS receives only a u32 handle that it passes back on every
|
||||||
|
//! subsequent call.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub struct SessionData {
|
||||||
|
pub master_key: Zeroizing<[u8; 32]>,
|
||||||
|
pub image_secret: Zeroizing<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SESSIONS: RefCell<HashMap<u32, SessionData>> = RefCell::new(HashMap::new());
|
||||||
|
static NEXT_HANDLE: RefCell<u32> = const { RefCell::new(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 {
|
||||||
|
let handle = NEXT_HANDLE.with(|n| {
|
||||||
|
let mut n = n.borrow_mut();
|
||||||
|
let h = *n;
|
||||||
|
*n = n.wrapping_add(1);
|
||||||
|
if *n == 0 { *n = 1; } // avoid reserving 0 as a valid handle
|
||||||
|
h
|
||||||
|
});
|
||||||
|
SESSIONS.with(|s| {
|
||||||
|
s.borrow_mut().insert(handle, SessionData { master_key, image_secret });
|
||||||
|
});
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the master key for a handle. Preserves original `with` signature for all existing callers.
|
||||||
|
pub fn with<F, R>(handle: u32, f: F) -> Option<R>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||||
|
{
|
||||||
|
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the image_secret for a handle (used by recovery QR).
|
||||||
|
pub fn with_image_secret<F, R>(handle: u32, f: F) -> Option<R>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Zeroizing<[u8; 32]>) -> R,
|
||||||
|
{
|
||||||
|
SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a session entry. Called by both `lock(handle)` (the explicit
|
||||||
|
/// path) and `impl Drop for SessionHandle` (the safety net). Returns
|
||||||
|
/// `true` if an entry was removed, `false` if the handle was already gone.
|
||||||
|
pub fn remove(handle: u32) -> bool {
|
||||||
|
SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For tests only — empty the table and wipe all sessions.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn clear() {
|
||||||
|
SESSIONS.with(|s| s.borrow_mut().clear());
|
||||||
|
}
|
||||||
16
crates/relicario-wasm/tests/session_drop.rs
Normal file
16
crates/relicario-wasm/tests/session_drop.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//! Belt-and-suspenders companion to the native `dropping_session_handle_clears_registry_entry`
|
||||||
|
//! test in `lib.rs`. This file exists for `wasm-pack test --node` symmetry; the
|
||||||
|
//! native test in the same crate is what gates CI.
|
||||||
|
|
||||||
|
use wasm_bindgen_test::wasm_bindgen_test;
|
||||||
|
|
||||||
|
use relicario_wasm::{__test_make_handle, __test_session_exists};
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn dropping_session_handle_clears_registry_entry() {
|
||||||
|
let handle = __test_make_handle();
|
||||||
|
let id = handle.value();
|
||||||
|
assert!(__test_session_exists(id));
|
||||||
|
drop(handle);
|
||||||
|
assert!(!__test_session_exists(id));
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# idfoto — Architecture
|
# Relicario — Architecture
|
||||||
|
|
||||||
## System Overview
|
## System Overview
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
│ CLIENT DEVICE (trusted) │
|
│ CLIENT DEVICE (trusted) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||||
│ │ Reference │ │ Passphrase │ │ idfoto-cli │ │
|
│ │ Reference │ │ Passphrase │ │ relicario-cli │ │
|
||||||
│ │ JPEG │ │ (typed) │ │ or browser ext │ │
|
│ │ JPEG │ │ (typed) │ │ or browser ext │ │
|
||||||
│ │ (on disk) │ │ │ │ │ │
|
│ │ (on disk) │ │ │ │ │ │
|
||||||
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
||||||
@@ -42,15 +42,19 @@
|
|||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ GIT SERVER (untrusted) │
|
│ GIT SERVER (untrusted) │
|
||||||
│ │
|
│ │
|
||||||
│ idfoto-vault.git/ │
|
│ relicario-vault.git/ │
|
||||||
│ ├── manifest.enc ← opaque ciphertext │
|
│ ├── manifest.enc ← opaque ciphertext │
|
||||||
│ ├── entries/ │
|
│ ├── settings.enc ← opaque ciphertext │
|
||||||
│ │ ├── a1b2c3d4.enc ← opaque ciphertext │
|
│ ├── items/ │
|
||||||
│ │ └── e5f6a7b8.enc ← opaque ciphertext │
|
│ │ ├── a1b2c3d4e5f6a7b8.enc ← opaque ciphertext │
|
||||||
│ └── .idfoto/ │
|
│ │ └── … │
|
||||||
|
│ ├── attachments/ │
|
||||||
|
│ │ └── <item-id>/<aid>.enc ← opaque ciphertext │
|
||||||
|
│ └── .relicario/ │
|
||||||
│ ├── salt ← 32 bytes (not secret) │
|
│ ├── salt ← 32 bytes (not secret) │
|
||||||
│ ├── params.json ← KDF params (not secret) │
|
│ ├── params.json ← KDF params (not secret) │
|
||||||
│ └── devices.json ← device public keys (not secret) │
|
│ ├── devices.json ← device public keys (not secret) │
|
||||||
|
│ └── revoked.json ← revoked device records (not secret) │
|
||||||
│ │
|
│ │
|
||||||
│ The server sees NOTHING useful. No keys, no plaintext, │
|
│ The server sees NOTHING useful. No keys, no plaintext, │
|
||||||
│ no metadata about what's inside. │
|
│ no metadata about what's inside. │
|
||||||
@@ -79,8 +83,9 @@ vault_salt ────────►│ │
|
|||||||
|
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
||||||
empty manifest ────►│ Poly1305 │
|
empty manifest ────►│ Poly1305 │ settings.enc
|
||||||
└──────────────────┘
|
default settings ──►│ encrypt (×2) │ (parallel artifacts;
|
||||||
|
└──────────────────┘ independent nonces)
|
||||||
|
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
│ git init │──────► vault repo
|
│ git init │──────► vault repo
|
||||||
@@ -88,6 +93,14 @@ empty manifest ────►│ Poly1305 │
|
|||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
|
||||||
|
attachment encryption, and field-history tracking are not shown above —
|
||||||
|
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
|
||||||
|
The flow above covers only the crypto-pipeline shape that vault init
|
||||||
|
establishes; the per-item lifecycle reuses the same `master_key` +
|
||||||
|
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
|
||||||
|
`attachments/<item-id>/<aid>.enc`.
|
||||||
|
|
||||||
## Unlock Flow (every vault operation)
|
## Unlock Flow (every vault operation)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -209,29 +222,31 @@ Input JPEG (possibly re-encoded or cropped)
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────┐
|
||||||
│ idfoto-cli │
|
│ relicario-cli │
|
||||||
│ Filesystem, git (shelling out), terminal I/O, clipboard │
|
│ Filesystem, git (shelling out), terminal I/O, clipboard │
|
||||||
│ │
|
│ │
|
||||||
│ Depends on: idfoto-core, clap, anyhow, rpassword, arboard │
|
│ Depends on: relicario-core, clap, anyhow, rpassword, arboard │
|
||||||
└──────────────────────┬─────────────────────────────────────┘
|
└──────────────────────┬─────────────────────────────────────┘
|
||||||
│ uses
|
│ uses
|
||||||
▼
|
▼
|
||||||
┌────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────┐
|
||||||
│ idfoto-core │
|
│ relicario-core │
|
||||||
│ Platform-agnostic: bytes in, bytes out │
|
│ Platform-agnostic: bytes in, bytes out │
|
||||||
│ No filesystem, no network, no git │
|
│ No filesystem, no network, no git │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌────────────┐ │
|
||||||
│ │ crypto │ │ imgsecret│ │ entry │ │ vault │ │
|
│ │ crypto │ │ imgsecret│ │ item + │ │ vault │ │
|
||||||
│ │ │ │ │ │ │ │ │ │
|
│ │ │ │ │ │ types │ │ │ │
|
||||||
│ │ KDF │ │ DCT │ │ Entry │ │ encrypt_ │ │
|
│ │ KDF │ │ DCT │ │ Item │ │ encrypt_ │ │
|
||||||
│ │ encrypt │ │ embed │ │ Manifest│ │ entry() │ │
|
│ │ encrypt │ │ embed │ │ Manifest│ │ item() │ │
|
||||||
│ │ decrypt │ │ extract │ │ search │ │ decrypt_ │ │
|
│ │ decrypt │ │ extract │ │ Settings│ │ decrypt_ │ │
|
||||||
│ │ │ │ QIM │ │ │ │ manifest() │ │
|
│ │ │ │ QIM │ │ Backup │ │ manifest() │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
│ │ │ │ │ │ Device │ │ ... │ │
|
||||||
|
│ └──────────┘ └──────────┘ └─────────┘ └────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ Future: idfoto-wasm wraps this for browser extension │
|
│ Consumed by: relicario-cli, relicario-wasm (extension), │
|
||||||
│ Future: JNI/Swift wrappers for Android/iOS │
|
│ relicario-server (pre-receive hook). │
|
||||||
|
│ Future: JNI/Swift wrappers for Android/iOS. │
|
||||||
└────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
104
docs/SECURITY.md
Normal file
104
docs/SECURITY.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Relicario Security Model
|
||||||
|
|
||||||
|
## Cryptographic Protection
|
||||||
|
|
||||||
|
Relicario uses two-factor vault decryption:
|
||||||
|
1. **Passphrase** — user-memorized, zxcvbn score ≥3 required
|
||||||
|
2. **Reference image** — JPEG carrying 256-bit secret via DCT steganography
|
||||||
|
|
||||||
|
Key derivation: Argon2id (64 MiB memory, 3 iterations, 4 parallelism)
|
||||||
|
Encryption: XChaCha20-Poly1305 (192-bit nonce, 256-bit key)
|
||||||
|
|
||||||
|
## Manifest Integrity
|
||||||
|
|
||||||
|
The manifest (`manifest.enc`) is encrypted with AEAD, which provides:
|
||||||
|
|
||||||
|
- **Confidentiality**: Contents unreadable without master key
|
||||||
|
- **Integrity**: Any modification detected and rejected on decrypt
|
||||||
|
- **Authenticity**: Only master key holders can create valid ciphertexts
|
||||||
|
|
||||||
|
### What AEAD Does NOT Protect
|
||||||
|
|
||||||
|
- **Item deletion**: An attacker with write access can delete `.enc` files
|
||||||
|
or git-revert commits. The manifest decrypts successfully but won't
|
||||||
|
contain the deleted items.
|
||||||
|
|
||||||
|
- **Rollback attacks**: An attacker can replace `manifest.enc` with an
|
||||||
|
older valid version. AEAD accepts any ciphertext created with the key.
|
||||||
|
|
||||||
|
### Mitigation
|
||||||
|
|
||||||
|
Item deletion and rollback are detectable via **git history**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline items/
|
||||||
|
```
|
||||||
|
|
||||||
|
For environments where git history could be rewritten (force-push):
|
||||||
|
|
||||||
|
1. Enable device authentication (commit signing + pre-receive hook)
|
||||||
|
2. Use a git server that rejects non-fast-forward pushes
|
||||||
|
3. Regular backups with `relicario backup export`
|
||||||
|
|
||||||
|
## Device Authentication
|
||||||
|
|
||||||
|
When enabled, device authentication provides:
|
||||||
|
|
||||||
|
- **Commit authorship**: All commits signed by registered device keys
|
||||||
|
- **Push access control**: Deploy keys managed via Gitea API
|
||||||
|
- **Instant revocation**: One command cuts off both signing and push
|
||||||
|
|
||||||
|
Enforcement requires deploying the `relicario-server` pre-receive hook
|
||||||
|
on the vault remote. The crate provides two subcommands:
|
||||||
|
|
||||||
|
- `relicario-server generate-hook` — emits the hook script to install at
|
||||||
|
`<repo>/hooks/pre-receive`
|
||||||
|
- `relicario-server verify-commit <sha>` — checks one commit's signature
|
||||||
|
against `.relicario/devices.json` and `.relicario/revoked.json` as of
|
||||||
|
that commit; the hook calls this for every pushed ref
|
||||||
|
|
||||||
|
Without the server hook, signed commits provide authorship metadata only
|
||||||
|
— any process with push access can land an unsigned commit, since
|
||||||
|
verification is otherwise advisory.
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
Without device authentication, access control is transport-layer only:
|
||||||
|
|
||||||
|
- **CLI**: SSH key authentication to git remote
|
||||||
|
- **Extension**: Git credentials in browser storage
|
||||||
|
|
||||||
|
Device registration is optional but recommended for shared vaults.
|
||||||
|
|
||||||
|
## Configuration env vars
|
||||||
|
|
||||||
|
Relicario reads the following environment variables. Each is a trust
|
||||||
|
boundary: an attacker who can set them in the user's environment can
|
||||||
|
influence Relicario's behavior. They are listed here for security
|
||||||
|
reviewers to audit the surface in one place.
|
||||||
|
|
||||||
|
### User-facing (active in all builds)
|
||||||
|
|
||||||
|
| Variable | Purpose | Trust |
|
||||||
|
|---|---|---|
|
||||||
|
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||||
|
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
||||||
|
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
||||||
|
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
||||||
|
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
|
||||||
|
|
||||||
|
### Debug-only (compiled out of `cargo build --release`)
|
||||||
|
|
||||||
|
The following variables are gated behind `cfg(debug_assertions)` and
|
||||||
|
are **no-ops** in release builds. The env-var lookup is removed by the
|
||||||
|
optimiser from any binary built without debug assertions (i.e. the
|
||||||
|
standard `--release` profile).
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
|
||||||
|
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
||||||
|
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
||||||
|
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
||||||
213
docs/architecture/overview.md
Normal file
213
docs/architecture/overview.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Architecture overview — Relicario
|
||||||
|
|
||||||
|
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
||||||
|
|
||||||
|
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
||||||
|
>
|
||||||
|
> - [crates/relicario-core/ARCHITECTURE.md](../../crates/relicario-core/ARCHITECTURE.md)
|
||||||
|
> - [crates/relicario-cli/ARCHITECTURE.md](../../crates/relicario-cli/ARCHITECTURE.md)
|
||||||
|
> - [extension/ARCHITECTURE.md](../../extension/ARCHITECTURE.md)
|
||||||
|
>
|
||||||
|
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||||
|
|
||||||
|
## The four codebases
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ relicario-core │
|
||||||
|
│ (Rust, no I/O) │
|
||||||
|
│ crypto · items │
|
||||||
|
│ manifest · stego │
|
||||||
|
│ device keys + fp │
|
||||||
|
└──┬───────────┬──────┘
|
||||||
|
│ │
|
||||||
|
┌────────────────┼───────────┴──────┬────────────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||||
|
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
|
||||||
|
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
|
||||||
|
│ │ │ │ │ bindings) │
|
||||||
|
│ filesystem + │ │ pre-receive hook │ │ │
|
||||||
|
│ git + │ │ verify-commit + │ │ compiled to WASM │
|
||||||
|
│ clap UX │ │ generate-hook │ │ for the extension │
|
||||||
|
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ extension │
|
||||||
|
│ (TypeScript) │
|
||||||
|
│ popup · vault │
|
||||||
|
│ setup · content │
|
||||||
|
│ service worker │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Codebase | Language | Role | Key boundary |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
||||||
|
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
|
||||||
|
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
|
||||||
|
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
|
||||||
|
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
|
||||||
|
|
||||||
|
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
|
||||||
|
|
||||||
|
## Inter-codebase contracts
|
||||||
|
|
||||||
|
There are four boundaries where the codebases agree on a wire format. Each is versioned independently.
|
||||||
|
|
||||||
|
### 1. Core → WASM ABI (Rust / JS edge)
|
||||||
|
|
||||||
|
The `relicario-wasm` crate is the JS/Rust contract. Every WASM export takes `JsValue` / `&[u8]` / `&str` and returns the same. Strings on the wire are JSON-encoded for any structured data; raw bytes for ciphertext / images / attachments.
|
||||||
|
|
||||||
|
Adding a new core capability for the extension requires:
|
||||||
|
1. Add the capability to `relicario-core/src/`.
|
||||||
|
2. Re-export through `lib.rs`.
|
||||||
|
3. Add a thin `#[wasm_bindgen]` wrapper to `relicario-wasm/src/lib.rs`.
|
||||||
|
4. Run `wasm-pack build` (via `npm run build:wasm` in `extension/`).
|
||||||
|
5. Use it from the extension's service worker (or setup wizard).
|
||||||
|
|
||||||
|
The `SessionHandle` is the cross-language opaque token: WASM owns the `Zeroizing<[u8;32]>` master key behind a numeric handle; JS only ever holds the number. JS calling `wasm.lock(handle)` zeroes the WASM-side memory and invalidates the handle.
|
||||||
|
|
||||||
|
### 2. Service worker ↔ popup / vault tab / content script (chrome.runtime messages)
|
||||||
|
|
||||||
|
All extension bundles other than the SW communicate with the SW exclusively via `chrome.runtime.sendMessage`. The protocol is defined in `extension/src/shared/messages.ts`:
|
||||||
|
|
||||||
|
- `PopupMessage` — sent by popup, vault tab, or setup wizard
|
||||||
|
- `ContentMessage` — sent by content scripts injected into web pages
|
||||||
|
- `Response` — returned by the SW: `{ ok: true, data?: ... } | { ok: false, error: string }`
|
||||||
|
|
||||||
|
Two **capability sets** in `messages.ts` gate which sender can issue which message:
|
||||||
|
|
||||||
|
- `POPUP_ONLY_TYPES` — accepted only from popup.html, vault.html, or setup.html
|
||||||
|
- `CONTENT_CALLABLE_TYPES` — accepted only from content scripts
|
||||||
|
|
||||||
|
The router (`service-worker/router/index.ts`) dispatches by sender. Adding a new message type requires adding it to one of the capability sets, **or it is silently rejected**. Vault tab parity (commit `a7dbf35`) is implemented by recognizing `vault.html` as a popup-class sender at the router level.
|
||||||
|
|
||||||
|
### 3. Vault on disk (shared by CLI and extension)
|
||||||
|
|
||||||
|
Every relicario vault — whether on disk for the CLI or in a git remote read by the extension — has the same layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
<vault root>/
|
||||||
|
├── .relicario/
|
||||||
|
│ ├── salt # 32 bytes, random per vault, stays constant
|
||||||
|
│ ├── params.json # KdfParams: argon2_m, argon2_t, argon2_p
|
||||||
|
│ └── devices.json # [{ name, public_key }, ...]
|
||||||
|
├── manifest.enc # encrypted Manifest (browse-without-decrypt index)
|
||||||
|
├── settings.enc # encrypted VaultSettings
|
||||||
|
├── items/
|
||||||
|
│ └── <id>.enc # encrypted Item, one per file
|
||||||
|
└── attachments/
|
||||||
|
└── <item-id>/
|
||||||
|
└── <aid>.enc # encrypted attachment blob; aid is content-addressed SHA-256
|
||||||
|
```
|
||||||
|
|
||||||
|
The reference image (`reference.jpg`) lives **outside** the vault by convention — it is the second factor and the user's responsibility to safeguard. It is not in `.relicario/`, not in `items/`, and never committed to git.
|
||||||
|
|
||||||
|
This layout is not formally versioned — the **content** within each `.enc` file carries its own version byte (see § Versioning below). The directory layout itself is conventional and changes would be breaking.
|
||||||
|
|
||||||
|
### 4. Git remote API (extension's `GitHost`)
|
||||||
|
|
||||||
|
The extension cannot shell out to `git`; it talks to the remote via the host's REST API. Two implementations live in `extension/src/service-worker/`:
|
||||||
|
|
||||||
|
- `gitea.ts` — Gitea / Forgejo API
|
||||||
|
- `github.ts` — GitHub API
|
||||||
|
|
||||||
|
Both implement the `GitHost` interface in `git-host.ts`. Adding a third host (GitLab, Bitbucket, custom) means implementing that interface — the rest of the extension is host-agnostic.
|
||||||
|
|
||||||
|
The CLI does not use `GitHost`; it shells out to `git` directly via the hardened wrapper in `relicario-cli/src/helpers.rs:46`.
|
||||||
|
|
||||||
|
## Versioning strategy
|
||||||
|
|
||||||
|
There is no single "relicario format version." Each piece of the format is versioned independently so we can evolve without coordinated upgrades.
|
||||||
|
|
||||||
|
| Artifact | Where versioned | Current value | Failure mode on read |
|
||||||
|
|---|---|---|---|
|
||||||
|
| AEAD ciphertext | First byte of every `.enc` blob | `VERSION_BYTE = 0x02` (in `relicario-core/src/crypto.rs`) | `RelicarioError::Format` — refuses to attempt decryption |
|
||||||
|
| Manifest schema | `Manifest.schema_version` field | `2` (set in `relicario-core/src/manifest.rs`) | v1 manifests are explicitly rejected with a clear error |
|
||||||
|
| KDF parameters | `.relicario/params.json` | Vault-specific (initially m=64MiB, t=3, p=4) | Read at unlock; stored alongside the vault |
|
||||||
|
| Backup container | First 5 bytes of `.relbak`: magic `"RBAK"` + version byte | `0x01` (designed; see import/export spec) | Format-version error if newer-version backup is read by older binary |
|
||||||
|
| Device entry | `devices.json` array of `{ name, public_key }` | Unversioned (extend by adding optional fields) | — |
|
||||||
|
|
||||||
|
The intentional design: **no big-bang upgrades**. A user can run an older CLI against a newer vault as long as the AEAD version, manifest schema, and KDF params are still compatible.
|
||||||
|
|
||||||
|
## Where secrets live
|
||||||
|
|
||||||
|
The threat model differs by codebase. This is the per-secret per-codebase residence map:
|
||||||
|
|
||||||
|
| Secret | relicario-core | relicario-cli | extension SW | extension popup/vault/content/setup |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Passphrase (UTF-8 bytes) | `Zeroizing<String>` only during a single `derive_master_key` call | Same, in `UnlockedVault::unlock_interactive` | Same, used briefly to derive master key inside WASM | Never seen — entered into a `<input type="password">`, sent to SW via `unlock` message, immediately forgotten |
|
||||||
|
| Reference image bytes | Held by caller; core only reads | Held by `UnlockedVault::unlock_interactive` long enough to extract the secret | Same | Setup wizard holds the bytes briefly during create/attach modes |
|
||||||
|
| Image secret (32 B) | `Zeroizing<[u8;32]>` during KDF | Same | Same | Never sees it |
|
||||||
|
| Master key | `Zeroizing<[u8;32]>` returned by `derive_master_key` | `UnlockedVault.master_key` for the lifetime of one CLI invocation | WASM-side memory behind an opaque `SessionHandle`; JS never sees the bytes | Never sees it |
|
||||||
|
| Item secret (password, card number, etc.) | `Zeroizing<String>` / `Zeroizing<Vec<u8>>` | Same | Briefly held in WASM during `item_decrypt`; results passed to popup as plaintext for display | Held in DOM (the user is staring at it); cleared when view changes |
|
||||||
|
| Device private key | — | Filesystem under `~/.config/relicario/devices/<name>.key` (mode 0600) | `chrome.storage.local.device_private_key` | — |
|
||||||
|
|
||||||
|
The popup / vault / content surfaces of the extension cannot decrypt an item independently — they all message the SW. Content scripts in particular get back already-prepared payloads (e.g. `{ username, password }`) from `fill_credentials` after the SW resolved everything.
|
||||||
|
|
||||||
|
The CLI keeps its master key in process memory; if the process exits or crashes, the key is gone (Zeroize on drop). There is no CLI session daemon. The `lock` subcommand exists only for UX parity with the extension and is a no-op.
|
||||||
|
|
||||||
|
## Build matrix
|
||||||
|
|
||||||
|
| Target | Tool | Output | When to run |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
||||||
|
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
|
||||||
|
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
|
||||||
|
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
|
||||||
|
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
|
||||||
|
| Firefox extension | `webpack --config webpack.firefox.config.js` (`npm run build:firefox`) | `extension/dist-firefox/` | After TS or WASM changes; for Firefox distribution |
|
||||||
|
| All extension targets | `npm run build:all` | Both `dist/` and `dist-firefox/` plus rebuilt WASM | Pre-release |
|
||||||
|
| Extension tests | `npm test` (vitest, happy-dom) | — | After TS changes |
|
||||||
|
|
||||||
|
The WASM build sequence matters: `wasm-pack` writes the binary into `extension/wasm/` before `webpack` picks it up. `npm run build:all` runs them in order. Manual builds need the same order.
|
||||||
|
|
||||||
|
## Test strategy at the workspace level
|
||||||
|
|
||||||
|
| Layer | Tool | Where | What it covers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Core unit tests | `cargo test -p relicario-core` | `crates/relicario-core/src/**/#[cfg(test)]` and `tests/*.rs` | Crypto round-trip, item serialization, manifest schema, generators, imgsecret embed/extract, format-v2 parsing |
|
||||||
|
| CLI integration tests | `cargo test -p relicario-cli` | `crates/relicario-cli/tests/*.rs` | End-to-end via `TestVault::init()` harness with synthetic JPEGs and `RELICARIO_TEST_*` env-var escape hatches; covers basic flows, edit + history (incl. TOTP), attachments, settings, vault detection |
|
||||||
|
| Extension unit tests | `npm test` (vitest) | `extension/src/**/__tests__/*.test.ts` | Component render + click handlers (mocked SW), router sender dispatch, SW handler logic (mocked WASM + chrome.storage) |
|
||||||
|
| End-to-end | none | — | No real-browser tests; mocks stand in. Build-vs-test gap is documented in extension/ARCHITECTURE.md |
|
||||||
|
|
||||||
|
Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take forever; the production path is the same code with real params. The CLI's `init` command always uses production-grade params even under tests.
|
||||||
|
|
||||||
|
## Conventions that span all three codebases
|
||||||
|
|
||||||
|
| Rule | Where enforced | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Master key only in `Zeroizing<[u8;32]>` | core types; CLI follows; extension WASM follows | Drop-on-scope-exit zeroization; never leaves stack |
|
||||||
|
| AEAD ciphertext starts with version byte | `core/crypto.rs` | Format identification; reject v1 blobs cleanly |
|
||||||
|
| Item IDs are random 16-char hex (64 bits) | `core/ids.rs` | Stable, short, no information leak |
|
||||||
|
| Attachment IDs are content-addressed (first 32 hex chars / 128 bits of SHA-256) | `core/ids.rs` | Dedup; integrity check |
|
||||||
|
| KDF input is length-prefixed | `core/crypto.rs` | Prevents `passphrase || image_secret` collisions |
|
||||||
|
| Git history is preserved as audit log; never squash | CLI commits; SW commits | Per-action history is a feature |
|
||||||
|
| Per-action git commits with structured messages | `cli` (via `commit_paths`); SW (via vault.ts helpers) | Greppable, useful as audit log |
|
||||||
|
| Hardened git invocations (`-c core.hooksPath=/dev/null` etc.) | CLI's `helpers::git_command`; SW does not shell out | Prevent hostile hooks; no GPG prompt holding key alive |
|
||||||
|
| Atomic writes (write `.tmp` → rename) | CLI's `session::atomic_write`; SW's vault.ts equivalents | Partial-write safety |
|
||||||
|
| Tests use synthesized JPEGs (`make_test_jpeg`), not committed binaries | Both Rust and TS test harnesses | Repo stays small; reproducible |
|
||||||
|
| Test-only env vars (`RELICARIO_TEST_*`) have no production fall-through | Verified in `relicario-cli` audit | Escape hatches don't leak into builds |
|
||||||
|
|
||||||
|
## Where to look next
|
||||||
|
|
||||||
|
| If you're working on... | Start with |
|
||||||
|
|---|---|
|
||||||
|
| Crypto, item types, manifest format | [`crates/relicario-core/ARCHITECTURE.md`](../../crates/relicario-core/ARCHITECTURE.md) |
|
||||||
|
| A new CLI command or a CLI bug | [`crates/relicario-cli/ARCHITECTURE.md`](../../crates/relicario-cli/ARCHITECTURE.md) |
|
||||||
|
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
||||||
|
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
||||||
|
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
|
||||||
|
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
|
||||||
|
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
|
||||||
|
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
|
||||||
|
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
|
||||||
|
| Running the full test suite | `cargo test && (cd extension && npm test)` |
|
||||||
|
| Bumping the WASM module after a core change | `cd extension && npm run build:wasm` |
|
||||||
|
|
||||||
|
## Stale spec docs
|
||||||
|
|
||||||
|
The `docs/superpowers/specs/` tree is **historical** — it captures the design decisions made at planning time. Some specs (e.g. `Plan 1A`, `1B`, `1C-α`/`β`/`γ`) describe work that has shipped. Do not edit them as if they were the architecture docs; instead update the appropriate `ARCHITECTURE.md`. The specs are valuable for *why* (why XChaCha20-Poly1305, why central-embed DCT, why two-factor with steganography); the architecture docs are valuable for *what* (current invariants, current flows, current contracts).
|
||||||
165
docs/superpowers/MULTI-AGENT.md
Normal file
165
docs/superpowers/MULTI-AGENT.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Multi-Agent Development Paradigm
|
||||||
|
|
||||||
|
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||||
|
|
||||||
|
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Role | Terminal | Branch | Responsibilities |
|
||||||
|
|------|----------|--------|-----------------|
|
||||||
|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||||
|
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||||
|
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||||
|
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||||
|
|
||||||
|
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starting a lift
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||||
|
- [ ] No uncommitted changes in main that would confuse the devs
|
||||||
|
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||||
|
|
||||||
|
### Launch sequence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start the relay server (this terminal becomes the relay log)
|
||||||
|
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||||
|
|
||||||
|
# Optional: use a multiplexer to auto-open all four terminals
|
||||||
|
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||||
|
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||||
|
|
||||||
|
| Kind | Block header | When used |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||||
|
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||||
|
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||||
|
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||||
|
|
||||||
|
A well-formed `status` block:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Time: 2026-05-02T14:30:00-07:00
|
||||||
|
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||||
|
Task: P4 / error-copy map
|
||||||
|
Status: DONE
|
||||||
|
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||||
|
Tests: green
|
||||||
|
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the relay tools
|
||||||
|
|
||||||
|
All three Claude Code sessions have these tools available when the relay server is running:
|
||||||
|
|
||||||
|
```
|
||||||
|
post_message(from, to, kind, body) → { id }
|
||||||
|
read_messages(for) → RelayMessage[] (drains inbox)
|
||||||
|
list_pending(for) → { count, kinds } (non-destructive)
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical dev flow per task:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. read_messages(for="dev-b") # check for directives before starting
|
||||||
|
2. ... do the work ...
|
||||||
|
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical PM flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. read_messages(for="pm") # see what devs posted
|
||||||
|
2. ... review ...
|
||||||
|
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If the relay server isn't running
|
||||||
|
|
||||||
|
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||||
|
|
||||||
|
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||||
|
|
||||||
|
To restart a crashed server mid-lift:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generating kickoff prompts
|
||||||
|
|
||||||
|
### Full workflow (spec → plans → kickoff)
|
||||||
|
|
||||||
|
**Step 1 — Write a spec**
|
||||||
|
|
||||||
|
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
|
||||||
|
|
||||||
|
**Step 2 — Invoke the kickoff skill**
|
||||||
|
|
||||||
|
Say anything like:
|
||||||
|
- "kick off the multi-agent thing for v0.6.0"
|
||||||
|
- "spin up PM and devs for this release"
|
||||||
|
- "set up the three-terminal paradigm"
|
||||||
|
|
||||||
|
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
|
||||||
|
|
||||||
|
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
|
||||||
|
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
|
||||||
|
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
|
||||||
|
4. Commit the prompts and print launch instructions
|
||||||
|
|
||||||
|
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
|
||||||
|
|
||||||
|
**Step 3 — Launch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh # prints prompt file paths, starts relay server
|
||||||
|
# open N+1 terminals, paste each prompt below its '---' line
|
||||||
|
```
|
||||||
|
|
||||||
|
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending a lift
|
||||||
|
|
||||||
|
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||||
|
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||||
|
3. PM tags the release (only after explicit user `yes`)
|
||||||
|
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles and boundaries (quick reference)
|
||||||
|
|
||||||
|
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||||
|
|
||||||
|
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||||
|
|
||||||
|
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user