├── .github
└── FUNDING.yml
├── .gitignore
├── CHANGELOG.md
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── bacon.toml
├── build.sh
├── compile-all-targets.sh
├── doc
├── clear-drawer.png
├── drawer-creation.png
├── drawer-opening.png
├── help.png
├── multiline.png
├── new-closet.png
├── new-drawer.png
├── search.png
└── typing-entry.png
├── fmt.sh
├── release.sh
├── rustfmt.toml
├── src
├── cli
│ ├── args.rs
│ └── mod.rs
├── core
│ ├── closed_drawer.rs
│ ├── closet.rs
│ ├── core_error.rs
│ ├── drawer_content.rs
│ ├── drawer_id.rs
│ ├── drawer_settings.rs
│ ├── entry.rs
│ ├── mod.rs
│ ├── open_closet.rs
│ ├── open_drawer.rs
│ └── random.rs
├── csv
│ └── mod.rs
├── error.rs
├── import
│ ├── import_set.rs
│ └── mod.rs
├── main.rs
├── search
│ ├── fuzzy_pattern.rs
│ ├── mod.rs
│ ├── name_match.rs
│ └── pos.rs
├── timer
│ └── mod.rs
└── tui
│ ├── action.rs
│ ├── app.rs
│ ├── app_state.rs
│ ├── cmd_result.rs
│ ├── comments_editor
│ ├── comments_editor_state.rs
│ ├── comments_editor_view.rs
│ └── mod.rs
│ ├── content_view.rs
│ ├── dialog.rs
│ ├── drawer_drawing_layout.rs
│ ├── drawer_focus.rs
│ ├── drawer_state.rs
│ ├── entry_ref.rs
│ ├── file_selector
│ ├── file_selector_state.rs
│ ├── file_selector_view.rs
│ ├── file_type.rs
│ └── mod.rs
│ ├── global_view.rs
│ ├── help.rs
│ ├── help_content.rs
│ ├── import
│ ├── choices.rs
│ ├── decide_origin_kind.rs
│ ├── import_state.rs
│ ├── import_view.rs
│ └── mod.rs
│ ├── keys.rs
│ ├── matched_string.rs
│ ├── menu
│ ├── action_menu.rs
│ ├── confirm.rs
│ ├── inform.rs
│ ├── menu.rs
│ ├── menu_state.rs
│ ├── menu_view.rs
│ └── mod.rs
│ ├── message.rs
│ ├── mod.rs
│ ├── password_dialog
│ ├── mod.rs
│ ├── password_dialog_purpose.rs
│ ├── password_dialog_state.rs
│ └── password_dialog_view.rs
│ ├── scroll.rs
│ ├── search_state.rs
│ ├── skin
│ ├── app_skin.rs
│ ├── content_skin.rs
│ ├── dialog_skin.rs
│ ├── mod.rs
│ └── status_skin.rs
│ ├── status_view.rs
│ ├── task.rs
│ ├── title_view.rs
│ └── view.rs
├── version.sh
└── website
├── .gitignore
├── README.md
├── custom_theme
├── main.html
└── toc.html
├── deploy.sh
├── docs
├── README.md
├── community.md
├── css
│ ├── extra.css
│ └── link-to-dystroy.css
├── features.md
├── img
│ ├── clear-drawer.png
│ ├── drawer-creation.png
│ ├── drawer-opening.png
│ ├── dystroy-rust-white.svg
│ ├── favicon.ico
│ ├── favicon.png
│ ├── help.png
│ ├── logo-safecloset.png
│ ├── lyon.jpg
│ ├── menu.png
│ ├── multiline.png
│ ├── new-closet.png
│ ├── new-drawer.png
│ ├── search-ca.png
│ ├── search.png
│ └── typing-entry.png
├── index.md
├── install.md
├── js
│ ├── autoclose-tree.js
│ └── link-to-dystroy.js
└── usage.md
└── mkdocs.yml
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [Canop]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bacon-locations
2 | /target
3 | /*.closet
4 | /*.old
5 | /*.zip
6 | /build
7 | /releases
8 | /deploy.sh
9 | /*-deploy.sh
10 | /*-test.sh
11 | /trav
12 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ### v1.4.0 - 2025-05-31
3 | - change double-click behavior to select all non-space characters around instead of selecting only "word characters" - This is more convenient when the text you want to select is an email or a password
4 |
5 |
6 | ### v1.3.2 - 2023-11-11
7 | - update some dependencies
8 |
9 |
10 | ### v1.3.1 - 2023-09-06
11 | - fix shift-character extending the selection
12 |
13 |
14 | ### v1.3.0 - 2023-06-15
15 | - Sort, accessible through the menu - Fix #31
16 | - Group matching entries, available in the menu when there's a search and more than one matching entries
17 | - ctrl-up and ctrl-down now available for moving entries even when the list is filtered - Fix #34
18 |
19 |
20 | ### v1.2.3 - 2023-04-06
21 | Fix a crash on some combinations of scroll+search
22 |
23 |
24 | ### v1.2.2 - 2023-04-03
25 | Better clipboard support on Mac - Fix #30
26 |
27 |
28 | ### v1.2.1 - 2023-03-10
29 | Values aren't rendered as markdown by default anymore. Markdown rendering is now opt-in, through the drawer menu - Fix #27,#28
30 |
31 |
32 | ### v1.1.0 - 2023-02-28
33 | Import feature: lets you import from another drawer, from a drawer of another closet file, or from a CSV file.
34 |
35 |
36 | ### v1.0.0 - 2023-01-13
37 | There's no reason to wait more for a 1.0.0: SafeCloset is stable and complete.
38 |
39 |
40 | ### v0.7.0 - 2022-10-18
41 | - shift-n key combination for adding an entry immediately after the selected one
42 |
43 |
44 | ### v0.6.4 - 2022-09-20
45 | - fix backslash character ('\') not rendered in values - Fix #22
46 | - colorize bold so that it's visible in terminal not rendering bold as bold
47 | - I don't provide the Raspberry precompiled version anymore due to a difficulty with cargo cross
48 |
49 |
50 | ### v0.6.3 - 2022-08-30
51 | - increase delay before auto closing to 120 seconds
52 | - fix tab key active behind open menu - Fix #21
53 |
54 |
55 | ### v0.6.2 - 2022-05-05
56 | - update termimad to solve a potential crash
57 |
58 |
59 | ### v0.6.1 - 2022-01-16
60 | - more relevant contextual hints - Fix #20
61 |
62 |
63 | ### v0.6.0 - 2021-12-03
64 | - support for more mouse interactions
65 | - clear comments at start of closet file (example purpose is holding the name of the soft to find it if you have as bad a memory as me)
66 | - closet clear comments editor
67 |
68 |
69 | ### v0.5.3 - 2021-11-28
70 | - Fix crash on Windows Terminal on some mouse operations - Fix #17
71 | - better support of wide characters - Fix #18
72 | - update status when waiting for long tasks
73 |
74 |
75 | ### v0.5.2 - 2021-11-19
76 | - fix rendering problems on sides on Windows (eg duplicate status line) - Fix #14
77 |
78 |
79 | ### v0.5.1 - 2021-11-17
80 | - fix documentation on values folding in help screen
81 |
82 |
83 | ### v0.5.0 - 2021-11-17
84 | - change drawer password - Fix #5
85 |
86 |
87 | ### v0.4.0 - 2021-11-11
88 | - the esc key opens a menu displaying relevant commands and their keys
89 | - ctrl-A toggles having all values always open (choice is kept in drawer settings) - Fix #8
90 | - various improvements of ergonomics
91 |
92 |
93 | ### v0.3.0 - 2021-10-30
94 | - ctrl-x no longer saves and quits
95 | - clipboard feature now default
96 | - support of selection in inputs (with shift arrow keys or mouse drag)
97 | - ctrl-x, ctrl-c, ctrl-v are shortcuts for cutting, copying, pasting in inputs
98 | - more mouse support (for example mouse wheel in inputs)
99 |
100 |
101 | ### v0.2.6 - 2021-10-22
102 | - various improvements on focusing and unfocusing the search input
103 | - when editing a multiline value, ctrl-down and ctrl-up swap lines
104 | - improve suggestions in status bar
105 |
106 |
107 | ### v0.2.5 - 2021-10-05
108 | - closing a drawer (and going to the upper drawer) is now done with ctrl-u
109 | - on some platforms, ctrl-c copies the selected cell (if safecloset is compiled with "clipbpoard" feature)
110 |
111 |
112 | ### v0.2.4 - 2021-10-03
113 | - now both ctrl-enter and alt-enter can be used to insert a new line in a value (but many terminals support only one of them)
114 |
115 |
116 | ### v0.2.4 - 2021-10-03
117 | - now both ctrl-enter and alt-enter can be used to insert a new line in a value (but many terminals support only one of them)
118 |
119 |
120 | ### v0.2.3 - 2021-10-01
121 | - fix a crash on rendering with an empty value
122 |
123 |
124 | ### v0.2.2 - 2021-09-29
125 | - `-o` option to immediately prompt for password for drawer opening
126 | - ctrl-v pastes the content of the clipboard (if safecloset is compiled with "clipbpoard" feature)
127 | - mouse wheel support
128 |
129 |
130 | ### v0.2.1 - 2021-09-26
131 | - help screen
132 | - ctrl-c to close a drawer or the help screen
133 | - 'a' key edits a field, cursor at end, while 'i' puts the cursor at start
134 |
135 |
136 | ### v0.2.0 - 2021-09-19
137 | - mouse support in inputs and for cell selection
138 | - sub-drawers (and breaking change in closet format)
139 |
140 |
141 | ### v0.1.3 - 2021-09-08
142 | - quit on inactivity
143 | - swap entries with ctrl-up and ctrl-down
144 | - multi-line values
145 |
146 |
147 | ### v0.1.2 - 2021-08-24
148 | - fuzzy search
149 |
150 |
151 | ### v0.1.1 - 2021-08-24
152 | - password characters visibility toggle (hidden initially)
153 | - unselected values visibility toggle (preference kept in drawer, and automatic hiding if launched with --hide)
154 | - switched from JSON to MessagePack as serialization format (breaking change)
155 | - entry removal with the 'd' key
156 |
157 |
158 | ### v0.1.0 - 2021-08-23
159 | Yes it has a version, but it doesn't mean you can use it. Wait for the 0.2 at least!
160 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "safecloset"
3 | version = "1.4.0"
4 | edition = "2021"
5 | authors = ["dystroy "]
6 | repository = "https://github.com/Canop/safecloset"
7 | description = "Secret Keeper"
8 | keywords = ["secret", "tui", "cryptography", "password"]
9 | license = "AGPL-3.0"
10 | categories = ["command-line-interface", "cryptography"]
11 | readme = "README.md"
12 | rust-version = "1.62"
13 |
14 | [features]
15 | default = ["clipboard"]
16 | clipboard = ["terminal-clipboard"]
17 |
18 | [dependencies]
19 | aes-gcm-siv = "=0.11.1"
20 | argh = "=0.1.12"
21 | char_reader = "=0.1.1"
22 | cli-log = "=2.1.0"
23 | crokey = "1.2"
24 | crossbeam = "=0.8.4"
25 | once_cell = "1.21"
26 | rand = "=0.9.1"
27 | rmp-serde = "=1.3.0"
28 | rust-argon2 = "=0.8.3"
29 | secular = { version = "1.0.1", features = ["normalization"] }
30 | serde = { version = "1.0.219", features = ["derive"] }
31 | termimad = "=0.33.0"
32 | terminal-clipboard = { version = "=0.4.1", optional = true }
33 | thiserror = "=2.0.12"
34 | unicode-width = "=0.2.0"
35 |
36 | [dev-dependencies]
37 | tempfile = "=3.2.0"
38 |
39 | [profile.release]
40 | lto = true
41 | strip = true
42 |
43 | [patch.crates-io]
44 | # crokey = { path = "../crokey" }
45 | # coolor = { path = "../coolor" }
46 | # minimad = { path = "../minimad" }
47 | # termimad = { path = "../termimad" }
48 | # terminal-clipboard = { path = "../terminal-clipboard" }
49 |
50 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [![Latest Version][s1]][l1] [![Chat on Miaou][s2]][l2]
2 |
3 | [s1]: https://img.shields.io/crates/v/safecloset.svg
4 | [l1]: https://crates.io/crates/safecloset
5 |
6 | [s2]: https://miaou.dystroy.org/static/shields/room.svg
7 | [l2]: https://miaou.dystroy.org/3768?rust
8 |
9 | **SafeCloset** keeps your secrets in password protected files.
10 | **SafeCloset** is designed to be convenient and avoid common weaknesses like external editing or temporary files written on disk.
11 |
12 |
13 | **[SafeCloset documentation](https://dystroy.org/safecloset)**
14 |
15 | # Warning
16 |
17 | **SafeCloset** hasn't been independently audited and comes with **absolutely** no guarantee.
18 | And I can do nothing for you if you lose the secrets you stored in SafeCloset.
19 |
20 | # Overview
21 |
22 | A closet is stored in a file that you can backup, keep with you on an USB key, etc.
23 |
24 | A closet contains drawers, each one is found and open with its own password.
25 |
26 | A drawer contains a list of (key, value). Values are texts in which you can store a code, a password, comments, a poem, some data, etc.
27 |
28 | A drawer can also contain deeper crypted drawers.
29 |
30 | 
31 |
32 | # Features
33 |
34 | * The closet contains several drawers, some of them automatically created with an unknown password so that nobody can determine which drawers you're able to open, or even how many
35 | * Each drawer is separately crypted with AES-GCM-SIV, with a random one-use nonce and the password/key of your choice. This gives an inherently long to test decrypt algorithm (but you should still use long passphrases for your drawers)
36 | * You can have one or several drawers with real content. You can be forced to open a drawer at gun point and still keep other drawers secret without any trace, either at the top level or deeper in the drawer you opened
37 | * When you open a drawer, with its password, you can read it, search it, edit it, close it
38 | * In an open drawer you can create new drawers, or open deeper drawers if you know their password
39 | * SafeCloset automatically quits on inactivity
40 | * The size of the drawer's content isn't observable
41 | * No clear file is ever created, edition is done directly in the TUI (external editors are usually the weakest point)
42 | * No clear data is ever given to any external library, widget, etc.
43 | * All data is viewed and edited in the TUI application
44 | * You can compile SafeCloset yourself. Its code is small and auditable
45 | * The code is 100% in Rust. I wouldn't trust anything else today for such a program
46 | * The format of the closet file is described so that another application could be written to decode your closet files in the future (assuming you have the password)
47 | * SafeCloset can't be queried by other applications, like browsers. This is a feature.
48 | * You may have all your secrets in one file easy to keep with you and backup
49 | * No company can die and lose your secrets: you keep everything, with as many copies as necessary, where you want
50 | * No company can be forced to add some secret stealing code: SafeCloset is small, open-source and repleacable
51 | * Fast and convenient to use - This is where the focus of the design was
52 | * Cross-platform because you don't know where you'll have to use your closet
53 | * "I'm being watched" mode in which unselected values are hidden. This mode is kept per drawer, always activated when you launch SafeCloset with the `--hide` option, and toggled with ctrlh
54 |
55 | # Non features
56 |
57 | * SafeCloset doesn't protect you against keyloggers
58 | * SafeCloset doesn't protect you from somebody watching your screen while a secret value is displayed (but the rest of the drawer can be kept hidden)
59 |
60 | # Usage
61 |
62 | *Those screenshots are small, to fit here, but you may use SafeCloset full screen if you want.*
63 |
64 | ## Create your closet file
65 |
66 | Run
67 |
68 | ```bash
69 | safecloset some/name.closet
70 | ```
71 |
72 | 
73 |
74 | ## Have a glance at the help
75 |
76 | Hit ? to go to the help screen, where you'll find the complete list of commands.
77 |
78 | 
79 |
80 | Hit esc to get back to the previous screen.
81 |
82 | ## Create your first drawer
83 |
84 | Hit ctrln
85 |
86 | 
87 |
88 | 
89 |
90 | If you want, you can create a deeper drawer there, at any time, by hitting ctrln.
91 |
92 | Or hit n to create a new entry, starting with its name then hitting tab to go fill its value.
93 |
94 | 
95 |
96 | Change the selection with the arrow keys.
97 | Go from input to input with the tab key. Or edit the currently selected field with a.
98 |
99 | Reorder entries with ctrl🠕 and ctrl🠗.
100 |
101 | In SafeCloset, when editing, searching, opening, etc., the enter key validates the operation while the esc key cancels or closes.
102 |
103 | You may add newlines in values with ctrlenter or altenter:
104 |
105 | 
106 |
107 | *You may notice the values are rendered as Markdown.*
108 |
109 | Don't hesitate to store hundreds of secrets in the same drawer as you'll easily find them with the fuzzy search.
110 |
111 | Search with the / key:
112 |
113 | 
114 |
115 | When in the search input, remove the search with esc, freeze it with enter.
116 |
117 | ## Save and quit
118 |
119 | Hit ctrls to save, then ctrlq to quit.
120 |
121 | ## Reopen
122 |
123 | The same command is used later on to open the closet again:
124 |
125 | ```bash
126 | safecloset some/name.closet
127 | ```
128 |
129 | It may be a good idea to define an alias so that you have your secrets easily available.
130 | You could for example have this in you `.bashrc`:
131 |
132 | ```bash
133 | function xx {
134 | safecloset -o ~/some/name.closet
135 | }
136 | ```
137 |
138 | The `-o` argument makes safecloset immediately prompt for drawer password, so that you don't have to type ctrlo.
139 |
140 | On opening, just type the password of the drawer you want to open (all will be tested until the right one opens):
141 |
142 | 
143 |
144 | # Storage format
145 |
146 | The storage format is described to ensure it's possible to replace SafeCloset with another software if needed.
147 |
148 | The closet file is a [MessagePack](https://msgpack.org/index.html) encoded structure `Closet` with the following fields:
149 |
150 | * `comments`: a string
151 | * `salt`: a string
152 | * `drawers`: an array of `ClosedDrawer`
153 |
154 | The MessagePack serialization preserves field names and allows future additions.
155 |
156 | An instance of `ClosedDrawer` is a structure with the following fields:
157 |
158 | * `id`: a byte array
159 | * `nonce`: a byte array
160 | * `content`: a byte array
161 |
162 | The `content` is the AES-GCM-SIV encryption of the serializied drawer with the included `nonce`.
163 | The key used for this encryption is a 256 bits Argon2 hash of the password with the closet's salt.
164 |
165 | The serialized drawer is a MessagePack encoded structure with the following fields:
166 |
167 | * `id`: a byte array
168 | * `entries`: an array of `Entry`
169 | * `settings`: an instance of `DrawerSettings`
170 | * `closet`: a deeper closet, containing drawers, etc.
171 | * `garbage`: a random byte array
172 |
173 | Instances of `Entry` contain the following fields:
174 |
175 | * `name`: a string
176 | * `value`: a string
177 |
178 | Instances of `DrawerSettings` contain the following fields:
179 |
180 | * `hide_values`: a boolean
181 | * `open_all_values`: a boolean (optional, false if not present)
182 |
--------------------------------------------------------------------------------
/bacon.toml:
--------------------------------------------------------------------------------
1 | # This is a configuration file for the bacon tool
2 | # More info at https://github.com/Canop/bacon
3 |
4 | default_job = "check-all"
5 |
6 | [jobs]
7 |
8 | [jobs.check]
9 | command = ["cargo", "check", "--color", "always"]
10 | need_stdout = false
11 |
12 | [jobs.check-all]
13 | command = [
14 | "cargo", "check",
15 | "--all-targets",
16 | "--color", "always",
17 | "--features", "clipboard",
18 | ]
19 | need_stdout = false
20 | watch = ["tests", "benches", "examples"]
21 |
22 | [jobs.clippy]
23 | command = [
24 | "cargo", "clippy",
25 | "--color", "always",
26 | "--",
27 | "-A", "clippy::bool_to_int_with_if",
28 | "-A", "clippy::collapsible_else_if",
29 | "-A", "clippy::collapsible_if",
30 | "-A", "clippy::manual_clamp",
31 | "-A", "clippy::match_like_matches_macro",
32 | "-A", "clippy::module_inception",
33 | ]
34 | need_stdout = false
35 |
36 | [jobs.test]
37 | command = ["cargo", "test", "--color", "always"]
38 | need_stdout = true
39 | watch = ["tests"]
40 |
41 | [jobs.doc]
42 | command = ["cargo", "doc", "--color", "always", "--no-deps"]
43 | need_stdout = false
44 |
45 | [keybindings]
46 | c = "job:clippy"
47 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | # This script compiles safecloset for the local system
2 | #
3 | # After compilation, safecloset can be found in target/release
4 | #
5 | # If you're not a developer but just want to install safecloset to use it,
6 | # you'll probably prefer one of the options listed at
7 | # https://dystroy.org/safecloset/install
8 | #
9 | # The line below can be safely executed on systems which don't
10 | # support sh scripts.
11 |
12 | cargo build --release --features "clipboard"
13 |
14 | # If the line above didn't work, you may use this one which won't
15 | # have the "clipboard" feature:
16 | #
17 | # cargo build --release --locked
18 |
--------------------------------------------------------------------------------
/compile-all-targets.sh:
--------------------------------------------------------------------------------
1 | # WARNING: This script is NOT meant for normal installation, it's dedicated
2 | # to the compilation of all supported targets, from a linux machine.
3 | # This is a long process and it involves specialized toolchains.
4 | # For usual compilation do
5 | # cargo build --release
6 |
7 | H1="\n\e[30;104;1m\e[2K\n\e[A" # style first header
8 | H2="\n\e[30;104m\e[1K\n\e[A" # style second header
9 | EH="\e[00m\n\e[2K" # end header
10 | NAME=safecloset
11 | version=$(./version.sh)
12 |
13 | echo -e "${H1}Compilation of all targets for $NAME $version${EH}"
14 |
15 | # clean previous build
16 | rm -rf build
17 | mkdir build
18 | echo " build cleaned"
19 |
20 | # Build versions for other platforms using cargo cross
21 | cross_build() {
22 | target_name="$1"
23 | target="$2"
24 | features="$3"
25 | echo -e "${H2}Compiling the $target_name version (target=$target, features='$features')${EH}"
26 | cargo clean
27 | if [[ -n $features ]]
28 | then
29 | cross build --target "$target" --release --features "$features"
30 | else
31 | cross build --target "$target" --release
32 | fi
33 | mkdir "build/$target"
34 | if [[ $target_name == 'Windows' ]]
35 | then
36 | exec="$NAME.exe"
37 | else
38 | exec="$NAME"
39 | fi
40 | cp "target/$target/release/$exec" "build/$target/"
41 | }
42 |
43 | # cross_build "x86-64 GLIBC" "x86_64-unknown-linux-gnu" ""
44 | # cross_build "MUSL" "x86_64-unknown-linux-musl" ""
45 | # cross_build "ARM 32" "armv7-unknown-linux-gnueabihf" ""
46 | # cross_build "ARM 32 MUSL" "armv7-unknown-linux-musleabi" ""
47 | # cross_build "ARM 64" "aarch64-unknown-linux-gnu" ""
48 | # cross_build "ARM 64 MUSL" "aarch64-unknown-linux-musl" ""
49 | cross_build "Windows" "x86_64-pc-windows-gnu" "clipboard"
50 | cross_build "Android" "aarch64-linux-android" "clipboard"
51 |
52 | # Build the default X86_64 linux version (with clipboard support, needing a recent GLIBC)
53 | # recent glibc
54 | echo -e "${H2}Compiling the standard linux version${EH}"
55 | cargo build --release --features "clipboard"
56 | strip "target/release/$NAME"
57 | mkdir build/x86_64-linux/
58 | cp "target/release/$NAME" build/x86_64-linux/
59 |
60 | # add a summary of content
61 | echo '
62 | This archive contains pre-compiled binaries.
63 |
64 | For more information, or if you prefer to compile yourself, see https://dystroy.org/safecloset/install
65 | ' > build/install.md
66 |
67 | echo -e "${H1}FINISHED${EH}"
68 |
--------------------------------------------------------------------------------
/doc/clear-drawer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/clear-drawer.png
--------------------------------------------------------------------------------
/doc/drawer-creation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/drawer-creation.png
--------------------------------------------------------------------------------
/doc/drawer-opening.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/drawer-opening.png
--------------------------------------------------------------------------------
/doc/help.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/help.png
--------------------------------------------------------------------------------
/doc/multiline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/multiline.png
--------------------------------------------------------------------------------
/doc/new-closet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/new-closet.png
--------------------------------------------------------------------------------
/doc/new-drawer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/new-drawer.png
--------------------------------------------------------------------------------
/doc/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/search.png
--------------------------------------------------------------------------------
/doc/typing-entry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/typing-entry.png
--------------------------------------------------------------------------------
/fmt.sh:
--------------------------------------------------------------------------------
1 | cargo +nightly fmt
2 |
--------------------------------------------------------------------------------
/release.sh:
--------------------------------------------------------------------------------
1 | # build a new release of safecloset
2 | # This isn't used for normal compilation but for the building of the official releases
3 | version=$(sed 's/version = "\([0-9.]\{1,\}\)"/\1/;t;d' Cargo.toml | head -1)
4 |
5 | echo "Building release $version"
6 |
7 | # make the build directory and compile for all targets
8 | ./compile-all-targets.sh
9 |
10 | # add the readme and changelog in the build directory
11 | echo "This is safecloset. More info and installation instructions on https://github.com/Canop/safecloset" > build/README.md
12 | cp CHANGELOG.md build
13 |
14 | # publish version number
15 | echo "$version" > build/version
16 |
17 | # prepare the release archive
18 | rm safecloset_*.zip
19 | zip -r "safecloset_$version.zip" build/*
20 |
21 | # copy it to releases folder
22 | mkdir releases
23 | cp "safecloset_$version.zip" releases
24 |
--------------------------------------------------------------------------------
/rustfmt.toml:
--------------------------------------------------------------------------------
1 | edition = "2021"
2 | style_edition = "2024"
3 | imports_granularity = "one"
4 | imports_layout = "Vertical"
5 | fn_params_layout = "Vertical"
6 |
--------------------------------------------------------------------------------
/src/cli/args.rs:
--------------------------------------------------------------------------------
1 | use {
2 | argh::FromArgs,
3 | std::path::PathBuf,
4 | };
5 |
6 | #[derive(Debug, FromArgs)]
7 | /// SafeCloset keeps your secrets -
8 | /// Documentation and source code at https://dystroy.org/safecloset
9 | pub struct Args {
10 | /// print the version
11 | #[argh(switch, short = 'v')]
12 | pub version: bool,
13 |
14 | /// hide unselected values
15 | #[argh(switch, short = 'h')]
16 | pub hide: bool,
17 |
18 | /// immediately prompt for a password to open a drawer
19 | #[argh(switch, short = 'o')]
20 | pub open: bool,
21 |
22 | #[argh(positional)]
23 | /// the closet file to open or create
24 | pub path: Option,
25 | }
26 |
--------------------------------------------------------------------------------
/src/cli/mod.rs:
--------------------------------------------------------------------------------
1 | mod args;
2 |
3 | pub use args::Args;
4 |
5 | use crate::{
6 | core::OpenCloset,
7 | error::SafeClosetError,
8 | tui,
9 | };
10 |
11 | /// run the command line application.
12 | ///
13 | /// Starts the TUI if a path to a closet is given
14 | pub fn run() -> Result<(), SafeClosetError> {
15 | let args: Args = argh::from_env();
16 | if args.version {
17 | println!("SafeCloset {}", env!("CARGO_PKG_VERSION"),);
18 | return Ok(());
19 | }
20 | info!("args: {:#?}", &args);
21 |
22 | if let Some(path) = &args.path {
23 | let closet = OpenCloset::open_or_create(path.clone())?;
24 | tui::run(closet, &args)?;
25 | } else {
26 | println!(
27 | "Please provide as argument the path to the closet file to create or open, \
28 | or use --help for help."
29 | );
30 | }
31 |
32 | Ok(())
33 | }
34 |
--------------------------------------------------------------------------------
/src/core/closed_drawer.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::*,
3 | aes_gcm_siv::{
4 | Nonce,
5 | aead::Aead,
6 | },
7 | serde::{
8 | Deserialize,
9 | Serialize,
10 | },
11 | };
12 |
13 | /// a closed, crypted, drawer
14 | #[derive(Serialize, Deserialize)]
15 | pub struct ClosedDrawer {
16 | id: DrawerId,
17 |
18 | nonce: Box<[u8]>,
19 |
20 | /// crypted serialized DrawerContent
21 | content: Box<[u8]>,
22 | }
23 |
24 | impl Identified for ClosedDrawer {
25 | fn get_id(&self) -> &DrawerId {
26 | &self.id
27 | }
28 | }
29 |
30 | impl ClosedDrawer {
31 | pub fn new(
32 | id: DrawerId,
33 | nonce: Box<[u8]>,
34 | content: Box<[u8]>,
35 | ) -> Self {
36 | Self { id, nonce, content }
37 | }
38 |
39 | /// Try to decrypt the content with the provided password
40 | /// and the closet's salt, then return the open drawer with
41 | /// clear data and the password to allow reencrypting.
42 | ///
43 | /// This function can also be used to check drawer existence.
44 | pub fn open(
45 | &self,
46 | depth: usize,
47 | password: String,
48 | closet: &Closet,
49 | ) -> Result {
50 | let cipher = closet.cipher(&password)?;
51 | let nonce = Nonce::from_slice(&self.nonce);
52 | let clear_content = cipher
53 | .decrypt(nonce, self.content.as_ref())
54 | .map_err(|_| CoreError::Aead)?;
55 | let content: DrawerContent = rmp_serde::from_read(&*clear_content)?;
56 | if content.id != self.id {
57 | Err(CoreError::UnconsistentData)
58 | } else {
59 | Ok(OpenDrawer {
60 | depth,
61 | password,
62 | content,
63 | })
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/core/closet.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::*,
3 | aes_gcm_siv::{
4 | Aes256GcmSiv,
5 | Key,
6 | KeyInit,
7 | },
8 | rand::{
9 | Rng,
10 | rng,
11 | seq::SliceRandom,
12 | },
13 | serde::{
14 | Deserialize,
15 | Serialize,
16 | },
17 | std::{
18 | fs,
19 | path::Path,
20 | },
21 | };
22 |
23 | /// The closet containing all the crypted drawers
24 | #[derive(Serialize, Deserialize)]
25 | pub struct Closet {
26 | /// Clear comments, which can be read with a standard binary/hex editor
27 | #[serde(default = "default_clear_comments")]
28 | pub comments: String,
29 |
30 | /// The salt used to generate the cipher keys from the passwords
31 | pub salt: String,
32 |
33 | /// The crypted drawers
34 | pub drawers: Vec,
35 | }
36 |
37 | /// Return the default comments which are written in the clear
38 | /// part of the file. The rationale for writing the name of the
39 | /// soft is that you may forget it and need to find the software
40 | /// to open your closet file, while attackers are the ones who
41 | /// may the most easily guess it.
42 | fn default_clear_comments() -> String {
43 | "Closet file written with SafeCloset\nhttps://dystroy.org/safecloset".to_string()
44 | }
45 |
46 | /// compute the number of decoy drawers we must create for
47 | /// the given depth
48 | fn random_decoy_drawers_count(depth: usize) -> usize {
49 | let mut n = match depth {
50 | 0 => rng().random_range(3..6),
51 | 1 => rng().random_range(1..3),
52 | 2 => rng().random_range(0..2),
53 | _ => 0,
54 | };
55 | while rng().random_bool(0.2) {
56 | n += 1;
57 | }
58 | n
59 | }
60 |
61 | impl Closet {
62 | pub fn new(depth: usize) -> Result {
63 | let comments = default_clear_comments();
64 | let salt = random_password();
65 | let drawers = Vec::new();
66 | let mut closet = Self {
67 | comments,
68 | salt,
69 | drawers,
70 | };
71 | // creating decoy drawers
72 | for _ in 0..random_decoy_drawers_count(depth) {
73 | closet.create_drawer_unchecked(depth, random_password())?;
74 | }
75 | Ok(closet)
76 | }
77 |
78 | /// Save the closet to a file
79 | pub fn save(
80 | &self,
81 | path: &Path,
82 | ) -> Result<(), CoreError> {
83 | if path.exists() {
84 | let backup_path = path.with_extension("old");
85 | if backup_path.exists() {
86 | fs::remove_file(&backup_path)?;
87 | }
88 | fs::rename(path, &backup_path)?;
89 | }
90 | self.write_to_file(path)?;
91 | Ok(())
92 | }
93 |
94 | pub fn write_to_file(
95 | &self,
96 | path: &Path,
97 | ) -> Result<(), CoreError> {
98 | if path.exists() {
99 | return Err(CoreError::FileExists(path.to_path_buf()));
100 | }
101 | let mut file = fs::File::create(path)?;
102 | rmp_serde::encode::write_named(&mut file, &self)?;
103 | Ok(())
104 | }
105 |
106 | /// read a closet from a file
107 | pub fn from_file(path: &Path) -> Result {
108 | let file = fs::File::open(path)?;
109 | let closet = rmp_serde::decode::from_read(file)?;
110 | Ok(closet)
111 | }
112 |
113 | /// Create a drawer without checking first the password isn't used by
114 | /// another drawer, or that the password meets minimal requirements,
115 | /// add it to the closed drawers of the closet.
116 | ///
117 | /// This is fast but dangerous, and should not be used on user action.
118 | fn create_drawer_unchecked(
119 | &mut self,
120 | depth: usize,
121 | password: String,
122 | ) -> Result {
123 | let drawer_content = DrawerContent::new(depth)?;
124 | let mut open_drawer = OpenDrawer::new(depth, password, drawer_content);
125 | let closed_drawer = open_drawer.close(self)?;
126 | self.drawers.push(closed_drawer);
127 | Ok(open_drawer)
128 | }
129 |
130 | /// Create a drawer, add it to the closet.
131 | ///
132 | /// Return an error if the password is already used by
133 | /// another drawer (which probably means the user wanted
134 | /// to open a drawer and not create one).
135 | pub fn create_drawer(
136 | &mut self,
137 | depth: usize,
138 | password: String,
139 | ) -> Result {
140 | if password.len() < MIN_PASSWORD_LENGTH {
141 | return Err(CoreError::PasswordTooShort);
142 | }
143 | if self.is_password_taken(depth, &password) {
144 | return Err(CoreError::PasswordAlreadyUsed);
145 | }
146 | self.create_drawer_unchecked(depth, password)
147 | }
148 |
149 | /// Open the drawer responding to this password and return it.
150 | ///
151 | /// Return None when no drawer can be opened with this password.
152 | pub fn open_drawer(
153 | &self,
154 | depth: usize,
155 | password: &str,
156 | ) -> Option {
157 | for closed_drawer in &self.drawers {
158 | let open_drawer = time!(
159 | "closed_drawer.open",
160 | closed_drawer.open(depth, password.to_string(), self,)
161 | );
162 | if let Ok(open_drawer) = open_drawer {
163 | return Some(open_drawer);
164 | }
165 | }
166 | None
167 | }
168 |
169 | pub fn is_password_taken(
170 | &self,
171 | depth: usize,
172 | password: &str,
173 | ) -> bool {
174 | self.open_drawer(depth, password).is_some()
175 | }
176 |
177 | /// Close the passed drawer, put it back among closed ones
178 | pub fn close_drawer(
179 | &mut self,
180 | mut open_drawer: OpenDrawer,
181 | ) -> Result {
182 | let closed_drawer = open_drawer.close(self)?;
183 | Ok(self.push_drawer_back(closed_drawer))
184 | }
185 |
186 | fn push_drawer_back(
187 | &mut self,
188 | drawer: ClosedDrawer,
189 | ) -> bool {
190 | for idx in 0..self.drawers.len() {
191 | if self.drawers[idx].has_same_id(&drawer) {
192 | self.drawers[idx] = drawer;
193 | return true;
194 | }
195 | }
196 | false
197 | }
198 |
199 | /// Change the order of drawers
200 | pub fn shuffle_drawers(&mut self) {
201 | self.drawers.shuffle(&mut rng());
202 | }
203 |
204 | /// Close the drawer then reopen it.
205 | ///
206 | /// After this operation, the closet contains the content of the given
207 | /// drawer but the closet isn't saved.
208 | #[allow(dead_code)]
209 | pub fn close_then_reopen(
210 | &mut self,
211 | drawer: OpenDrawer,
212 | ) -> Result {
213 | let depth = drawer.depth;
214 | let password = drawer.password.clone();
215 | self.close_drawer(drawer)?;
216 | self.open_drawer(depth, &password).ok_or_else(|| {
217 | // shouldn't happen
218 | CoreError::InternalError("can't reopen just closed drawer".to_string())
219 | })
220 | }
221 |
222 | pub fn cipher(
223 | &self,
224 | password: &str,
225 | ) -> Result {
226 | let config = argon2::Config {
227 | hash_length: 32,
228 | ..Default::default()
229 | };
230 | //config.variant = argon2::Variant::Argon2i;
231 | //config.version = argon2::Version::Version13;
232 | let hash = argon2::hash_raw(password.as_bytes(), self.salt.as_bytes(), &config)?;
233 | let key = Key::::from_slice(&hash);
234 | Ok(Aes256GcmSiv::new(key))
235 | }
236 | }
237 |
--------------------------------------------------------------------------------
/src/core/core_error.rs:
--------------------------------------------------------------------------------
1 | /// Core error type
2 | #[derive(thiserror::Error, Debug)]
3 | pub enum CoreError {
4 | #[error("IO error: {0}")]
5 | IO(#[from] std::io::Error),
6 |
7 | #[error("Passphrase too short")]
8 | PasswordTooShort,
9 |
10 | #[error("MessagePack Encode error: {0}")]
11 | MessagePackEncode(#[from] rmp_serde::encode::Error),
12 |
13 | #[error("MessagePack Decode error: {0}")]
14 | MessagePackDecode(#[from] rmp_serde::decode::Error),
15 |
16 | #[error("AEAD error")]
17 | Aead, // The AEAD error type is opaque
18 |
19 | #[error("File {0} already exists")]
20 | FileExists(std::path::PathBuf),
21 |
22 | #[error("Argon2 password hash error: {0}")]
23 | Argon2(#[from] argon2::Error),
24 |
25 | #[error("Unconsistent data")]
26 | UnconsistentData,
27 |
28 | #[error("Internal error: {0}")]
29 | InternalError(String),
30 |
31 | #[error("Passphrase already used for an existing drawer")]
32 | PasswordAlreadyUsed,
33 |
34 | #[error("No open drawer")]
35 | NoOpenDrawer,
36 |
37 | #[error("Invalid Push Back")]
38 | InvalidPushBack,
39 |
40 | #[error("Invalid Delete")]
41 | InvalidDelete,
42 |
43 | #[error("Operation only permitted at max depth")]
44 | OperationOnlyPermittedAtMaxDepth,
45 | }
46 |
--------------------------------------------------------------------------------
/src/core/drawer_content.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::*,
3 | serde::{
4 | Deserialize,
5 | Serialize,
6 | },
7 | };
8 |
9 | /// What's inside a drawer
10 | #[derive(Serialize, Deserialize)]
11 | pub struct DrawerContent {
12 | pub id: DrawerId,
13 |
14 | /// the entries of this depth
15 | pub entries: Vec,
16 |
17 | /// user settings related to that drawer
18 | pub settings: DrawerSettings,
19 |
20 | /// the crypted sub-drawers
21 | pub closet: Closet,
22 |
23 | /// some random bytes, rewritten before every save
24 | garbage: Box<[u8]>,
25 | }
26 |
27 | impl Identified for DrawerContent {
28 | fn get_id(&self) -> &DrawerId {
29 | &self.id
30 | }
31 | }
32 |
33 | impl DrawerContent {
34 | pub fn new(depth: usize) -> Result {
35 | let id = DrawerId::new();
36 | let entries = Vec::new();
37 | let settings = DrawerSettings::default();
38 | let closet = Closet::new(depth + 1)?;
39 | let garbage = Vec::new().into(); // will be (re)filled for save
40 | Ok(Self {
41 | id,
42 | entries,
43 | settings,
44 | closet,
45 | garbage,
46 | })
47 | }
48 |
49 | /// Return the index of the first empty entry, creating
50 | /// it if necessary
51 | pub fn empty_entry(&mut self) -> usize {
52 | for (idx, entry) in self.entries.iter().enumerate() {
53 | if entry.is_empty() {
54 | return idx;
55 | }
56 | }
57 | self.entries.push(Entry::default());
58 | self.entries.len() - 1
59 | }
60 |
61 | /// Insert a new entry at the given index or before and
62 | /// return the index of the new entry
63 | #[allow(dead_code)]
64 | pub fn insert_before(
65 | &mut self,
66 | idx: usize,
67 | ) -> usize {
68 | let idx = if self.entries.is_empty() {
69 | 0
70 | } else {
71 | idx.min(self.entries.len() - 1)
72 | };
73 | self.entries.insert(idx, Entry::default());
74 | idx
75 | }
76 |
77 | /// Insert a new entry after the new entry if possible.
78 | ///
79 | /// Return the index of the new entry.
80 | pub fn insert_after(
81 | &mut self,
82 | idx: Option,
83 | ) -> usize {
84 | let idx = match (idx, self.entries.is_empty()) {
85 | (Some(idx), false) => (idx + 1).min(self.entries.len()),
86 | _ => 0,
87 | };
88 | self.entries.insert(idx, Entry::default());
89 | idx
90 | }
91 |
92 | /// Shuffle the drawers (thus ensuring the last created one
93 | /// isn't at the end), add some random bytes which makes the
94 | /// content's size undetectable
95 | pub fn add_noise(&mut self) {
96 | self.garbage = random_bytes_random_size(5..5000);
97 | self.closet.shuffle_drawers();
98 | }
99 |
100 | /// Remove entries with both name and value empty
101 | pub fn remove_empty_entries(&mut self) {
102 | self.entries.retain(|e| !e.is_empty());
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/core/drawer_id.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::*,
3 | serde::{
4 | Deserialize,
5 | Serialize,
6 | },
7 | };
8 |
9 | #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
10 | pub struct DrawerId {
11 | bytes: Box<[u8]>,
12 | }
13 |
14 | impl DrawerId {
15 | pub fn new() -> Self {
16 | DrawerId {
17 | bytes: random_bytes(20),
18 | }
19 | }
20 | }
21 |
22 | pub trait Identified {
23 | fn get_id(&self) -> &DrawerId;
24 |
25 | fn has_same_id(
26 | &self,
27 | other: &I,
28 | ) -> bool {
29 | self.get_id() == other.get_id()
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/core/drawer_settings.rs:
--------------------------------------------------------------------------------
1 | use serde::{
2 | Deserialize,
3 | Serialize,
4 | };
5 |
6 | /// settings of a drawer, saved in the drawer
7 | #[derive(Serialize, Deserialize, Default)]
8 | pub struct DrawerSettings {
9 | /// whether to hide unselected entry values
10 | pub hide_values: bool,
11 | /// whether to show the whole content of all values
12 | #[serde(default)]
13 | pub open_all_values: bool,
14 | /// whether to show values as markdown
15 | #[serde(default)]
16 | pub values_as_markdown: bool,
17 | }
18 |
--------------------------------------------------------------------------------
/src/core/entry.rs:
--------------------------------------------------------------------------------
1 | use serde::{
2 | Deserialize,
3 | Serialize,
4 | };
5 |
6 | /// one of the socks in the drawer
7 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
8 | pub struct Entry {
9 | pub name: String,
10 | pub value: String,
11 | }
12 |
13 | impl Entry {
14 | #[allow(dead_code)]
15 | pub fn new, V: Into>(
16 | name: N,
17 | value: V,
18 | ) -> Self {
19 | Self {
20 | name: name.into(),
21 | value: value.into(),
22 | }
23 | }
24 | pub fn is_empty(&self) -> bool {
25 | self.name.is_empty() && self.value.is_empty()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/core/mod.rs:
--------------------------------------------------------------------------------
1 | mod closed_drawer;
2 | mod closet;
3 | mod core_error;
4 | mod drawer_content;
5 | mod drawer_id;
6 | mod drawer_settings;
7 | mod entry;
8 | mod open_closet;
9 | mod open_drawer;
10 | mod random;
11 |
12 | pub use {
13 | closed_drawer::*,
14 | closet::*,
15 | core_error::*,
16 | drawer_content::*,
17 | drawer_id::*,
18 | drawer_settings::*,
19 | entry::*,
20 | open_closet::*,
21 | open_drawer::*,
22 | random::*,
23 | };
24 |
25 | pub const MIN_PASSWORD_LENGTH: usize = 2;
26 |
27 | /// test most opening, saving, reopening, etc. operations in
28 | /// a complex scenario
29 | #[test]
30 | fn test_create_write_read() {
31 | let pwd1 = "some test password (not a hard one but it's a test)";
32 | let pwd2 = "请教别人一次是五分钟的傻子,从不请教别人是一辈子的傻子。";
33 | let pwd3 = "les sanglots lents et violents de l'autre âne";
34 | let entry1 = Entry::new("some key", "some value");
35 | let entry2 = Entry::new("key2", "some value");
36 | let entry2b = Entry::new("another key", "v2.2");
37 | let entry2c = Entry::new("key 2c", "v2.3");
38 | let entry3 = Entry::new("key3", "some other value");
39 | let entry3b = Entry::new("hey", "what's here?");
40 |
41 | // create a temp directory in which to run our tests
42 | let temp_dir = tempfile::tempdir().unwrap();
43 |
44 | // define a path for our closet
45 | let path = temp_dir.path().join("test-create-write-read.safe-closet");
46 |
47 | // create a closet on this path
48 | let mut open_closet = OpenCloset::create(path.to_path_buf()).unwrap();
49 |
50 | // check that there are already several drawers
51 | assert!(open_closet.root_drawers_count() >= 3);
52 |
53 | // create 2 drawers at the same level
54 | open_closet.create_drawer(pwd1).unwrap();
55 | open_closet.close_deepest_drawer().unwrap();
56 | let drawer2 = open_closet.create_drawer(pwd2).unwrap();
57 | drawer2.content.entries.push(entry2.clone());
58 |
59 | // check drawer2 already contains at least a decoy drawer
60 | assert!(!drawer2.content.closet.drawers.is_empty());
61 |
62 | // close the drawer2
63 | open_closet.close_deepest_drawer().unwrap();
64 |
65 | // check we can't reuse a password (from a given node)
66 | assert!(matches!(
67 | open_closet.create_drawer(pwd1),
68 | Err(CoreError::PasswordAlreadyUsed),
69 | ));
70 |
71 | // reopen the first drawer and add an entry
72 | let open_drawer = open_closet.open_drawer(pwd1).unwrap();
73 | open_drawer.content.entries.push(entry1.clone());
74 |
75 | // save the closet
76 | open_closet.close_and_save().unwrap();
77 |
78 | // reopen the closet
79 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap();
80 |
81 | // open the first drawer, check our entry is here
82 | let open_drawer = open_closet.open_drawer(pwd1).unwrap();
83 | assert_eq!(open_drawer.content.entries, vec![entry1.clone()]);
84 |
85 | // open the second drawer, check there's our entry2
86 | // (we don't close the first one before: we want to let
87 | // safecloset find it alone)
88 | let drawer2 = open_closet.open_drawer(pwd2).unwrap();
89 | assert_eq!(drawer2.content.entries, vec![entry2.clone()]);
90 | assert_eq!(drawer2.depth, 0);
91 |
92 | // now, let's create a deep drawer inside the second drawer
93 | let drawer3 = open_closet.create_drawer(pwd3).unwrap();
94 | drawer3.content.entries.push(entry3.clone());
95 | assert_eq!(drawer3.depth, 1);
96 |
97 | // let's save and close everything
98 | open_closet.close_and_save().unwrap();
99 |
100 | // reopen the closet
101 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap();
102 |
103 | // check we can't open drawer3 from the root level
104 | assert!(open_closet.open_drawer(pwd3).is_none());
105 |
106 | // open drawer2 then drawer3 from drawer2
107 | open_closet.open_drawer(pwd2).unwrap();
108 | let drawer3 = open_closet.open_drawer(pwd3).unwrap();
109 |
110 | // check its content
111 | assert_eq!(drawer3.depth, 1);
112 | assert_eq!(drawer3.content.entries, vec![entry3.clone()]);
113 |
114 | // check we can save without closing
115 | let drawer3 = open_closet.save_then_reopen().unwrap().unwrap();
116 | assert_eq!(drawer3.depth, 1);
117 | assert_eq!(drawer3.content.entries, vec![entry3.clone()]);
118 |
119 | // add some content in d3, we'll check again later it's ok
120 | drawer3.content.entries.push(entry3b.clone());
121 |
122 | // close drawer3, we should be beck in the enclosing drawer, d2
123 | open_closet.close_deepest_drawer().unwrap();
124 | let drawer2 = open_closet.deepest_open_drawer().unwrap();
125 | assert_eq!(drawer2.content.entries, vec![entry2.clone()]);
126 | drawer2.content.entries.push(entry2b.clone());
127 |
128 | // now let's make a change but by taking the drawer instead of
129 | // just having a reference
130 | let mut drawer2 = open_closet.take_deepest_open_drawer().unwrap();
131 | drawer2.content.entries.push(entry2c.clone());
132 | open_closet.push_back(drawer2).unwrap();
133 |
134 | // let's save and close everything again to check the content is right
135 | open_closet.close_and_save().unwrap();
136 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap();
137 | let drawer1 = open_closet.open_drawer(pwd1).unwrap();
138 | assert_eq!(drawer1.content.entries, vec![entry1.clone()]);
139 | let drawer2 = open_closet.open_drawer(pwd2).unwrap();
140 | assert_eq!(
141 | drawer2.content.entries,
142 | vec![entry2.clone(), entry2b.clone(), entry2c.clone()],
143 | );
144 | let drawer3 = open_closet.open_drawer(pwd3).unwrap();
145 | assert_eq!(
146 | drawer3.content.entries,
147 | vec![entry3.clone(), entry3b.clone()]
148 | );
149 |
150 | // clean the temporary dir
151 | temp_dir.close().unwrap();
152 | }
153 |
154 | /// test changing the password
155 | #[test]
156 | fn test_password_change() {
157 | let pwd1 = "*p*w*d*1";
158 | let pwd2 = "PWD2";
159 | let pwd3 = "p-w-d-3";
160 | let entry1 = Entry::new("some key", "some value");
161 | let entry2 = Entry::new("key2", "some value");
162 |
163 | // create a temp directory in which to run our tests
164 | let temp_dir = tempfile::tempdir().unwrap();
165 |
166 | // define a path for our closet
167 | let path = temp_dir.path().join("test-pwd-change.closet");
168 |
169 | // create a closet on this path
170 | let mut open_closet = OpenCloset::create(path.to_path_buf()).unwrap();
171 |
172 | // check that there are already several drawers
173 | assert!(open_closet.root_drawers_count() >= 3);
174 |
175 | // create 2 drawers at the same level, with some content
176 | let drawer1 = open_closet.create_drawer(pwd1).unwrap();
177 | drawer1.content.entries.push(entry1.clone());
178 | open_closet.close_deepest_drawer().unwrap();
179 | let drawer2 = open_closet.create_drawer(pwd2).unwrap();
180 | drawer2.content.entries.push(entry2.clone());
181 |
182 | // save the closet
183 | open_closet.close_and_save().unwrap();
184 |
185 | // reopen the closet
186 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap();
187 |
188 | // open the first drawer, check our entry is here
189 | open_closet.open_drawer(pwd1).unwrap();
190 | let mut open_drawer = open_closet.take_deepest_open_drawer().unwrap();
191 | assert_eq!(open_drawer.content.entries, vec![entry1.clone()]);
192 |
193 | // check we can't change the password to an already used one
194 | assert!(open_closet.change_password(&mut open_drawer, pwd1).is_err());
195 |
196 | // change the password
197 | open_closet.change_password(&mut open_drawer, pwd3).unwrap();
198 |
199 | // push back the drawer so that it can be saved
200 | open_closet.push_back(open_drawer).unwrap();
201 |
202 | // save the closet
203 | open_closet.close_and_save().unwrap();
204 |
205 | // reopen the closet
206 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap();
207 |
208 | // check we can't open with the old password
209 | assert!(open_closet.open_drawer(pwd1).is_none());
210 |
211 | // open with the new password
212 | open_closet.open_drawer(pwd3).unwrap();
213 | let drawer1 = open_closet.take_deepest_open_drawer().unwrap();
214 |
215 | // check its content
216 | assert_eq!(drawer1.content.entries, vec![entry1.clone()]);
217 |
218 | // save the closet
219 | open_closet.close_and_save().unwrap();
220 |
221 | // clean the temporary dir
222 | temp_dir.close().unwrap();
223 | }
224 |
--------------------------------------------------------------------------------
/src/core/open_closet.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::*,
3 | std::path::{
4 | Path,
5 | PathBuf,
6 | },
7 | };
8 |
9 | /// The root closet, when open
10 | pub struct OpenCloset {
11 | /// the path in which the closet is persisted
12 | path: PathBuf,
13 |
14 | /// the deserialized content (nothing uncrypted here)
15 | root_closet: Closet,
16 |
17 | // drawers, indexed by depth
18 | open_drawers: Vec,
19 |
20 | // the closet was just created because there no preexisting file
21 | created: bool,
22 | }
23 |
24 | impl OpenCloset {
25 | /// Either create a new closet, or open an existing one, depending
26 | /// on whether the file exists
27 | pub fn open_or_create>(path: P) -> Result {
28 | let path = path.into();
29 | if path.exists() {
30 | Self::open(path)
31 | } else {
32 | Self::create(path)
33 | }
34 | }
35 |
36 | pub fn root_closet(&mut self) -> &mut Closet {
37 | &mut self.root_closet
38 | }
39 |
40 | pub fn just_created(&self) -> bool {
41 | self.created
42 | }
43 |
44 | #[cfg(test)]
45 | pub fn root_drawers_count(&self) -> usize {
46 | self.root_closet.drawers.len()
47 | }
48 |
49 | /// Create a new closet, with a random number of drawers
50 | /// (which won't be openable as you won't have their password)
51 | pub fn create(path: PathBuf) -> Result {
52 | if path.exists() {
53 | return Err(CoreError::FileExists(path));
54 | }
55 | let open_closet = OpenCloset {
56 | path,
57 | root_closet: Closet::new(0)?,
58 | open_drawers: Vec::new(),
59 | created: true,
60 | };
61 | Ok(open_closet)
62 | }
63 |
64 | /// Open a closet from a closet file
65 | pub fn open(path: PathBuf) -> Result {
66 | let root_closet = Closet::from_file(&path)?;
67 | let open_closet = OpenCloset {
68 | path,
69 | root_closet,
70 | open_drawers: Vec::new(),
71 | created: false,
72 | };
73 | Ok(open_closet)
74 | }
75 |
76 | /// Save all the open drawers, then the closet in its file.
77 | pub fn close_and_save(&mut self) -> Result<(), CoreError> {
78 | while !self.open_drawers.is_empty() {
79 | self.close_deepest_drawer()?;
80 | }
81 | self.root_closet.save(&self.path)
82 | }
83 |
84 | /// Save all the open drawers, then the closet in its file,
85 | /// then reopen the drawer which was the deepest one before
86 | /// saving.
87 | ///
88 | /// If nothing was open, nothing is reopened.
89 | pub fn save_then_reopen(&mut self) -> Result