crates/veilvoice-crypto/src/hoard.rs
veilvoice-crypto · 1099 lines · read the source here · or on GitHub
The obfuscated program folder: what VeilVoice keeps on disk, under names that mean nothing and beside files that hold nothing.
What this buys, stated before anything else
Somebody who opens VeilVoice's folder without the app-lock passphrase sees a few dozen files with names like k7Qa1mXv9pLd0RtYbN3zHwFe, all of them full of bytes that look random, all of them one of a handful of sizes. They cannot tell which files hold settings, which hold measurements, which hold anything at all, and which are junk this module wrote precisely so that the question has no answer from outside.
That is the whole claim. It is worth having and it is smaller than it sounds, so here is the other half, in the same breath:
- It does not hide that you use VeilVoice. The folder is called
veilvoice, the lock file sits in it under its own name, and the application is on disk. Anybody looking knows. - It does not hide how much you have. File count and the bucket sizes are visible. Decoys blur that number; they do not erase it.
- It is not protection from someone who has your passphrase, and it is not protection while the application is open and unlocked. At that moment everything here is readable, because it has to be.
- It does not stop deletion. Anybody who can read this folder can empty it. What they cannot do is empty it quietly: see the roster below.
- Somebody who knows VeilVoice knows what these files are. The format is public, this file is the specification, and a forensic examiner who recognises it will recognise it here. Obfuscation is not steganography and this module does not pretend otherwise.
What it does buy is the thing the app lock could not previously offer: a reason to exist beyond a password prompt. Before this, the lock verified a passphrase and guarded a window; the files behind it sat in the clear under their own names, and deleting the lock file removed the whole obstacle. Now the passphrase derives the key that names and opens these records, so deleting the lock does not reveal them -- it destroys the only copy of the salt they were derived through, and takes them with it. That is a real change in what the lock is worth, and also a real way to lose your data, which is why crate::lock keeps a second copy and the interface says so.
How a record is found
Every record has a logical name that only the program uses: settings, measured, tour. The file it lives in is named
base64url(weave(HMAC-SHA256(store_key, "veilvoice/hoard/name" || logical)[..18]))
where weave is one of a dozen byte-level encodings chosen from the name's own bytes, so it is stable across launches. The result is twenty-four characters of base64 with no padding and no extension.
Only length-preserving encodings are allowed there, and that restriction is load-bearing: a name that came out longer or shorter would announce which encoding produced it, and would separate records from decoys at a glance. Eighteen bytes rather than a round sixteen so the encoding comes out exact: twenty-four characters with nothing to pad, which is one less thing to tell a name apart from a decoy.
The derivation is deterministic, so the program does not search: it computes the name it wants and opens that file. This is what makes the selection cryptographic rather than a lookup table. There is no index mapping settings to a filename, because an index is exactly the thing an attacker would want. Without the key there is no way to run the derivation, and with the key there is no need to store it.
What is inside one
[24-byte nonce][ChaCha20-Poly1305 over: [2-byte marker][4-byte length][data][junk]]
The data is first put through one of twenty-seven encodings drawn at random on every write -- base91, z-base-32, yEnc, a move-to-front transform, and two dozen others -- and the marker says which, from inside the sealed region so the choice is not visible either. crate::weave carries the full argument; the short version is that it adds no cryptographic strength, because the AEAD already makes this indistinguishable from random. What it adds is that plaintext escaping by some route that is not the cipher -- a core dump, a swap file, a future bug in this framing -- does not read as anything.
The padding is computed from the original length rather than the encoded one, so a file's size never depends on which encoding was drawn. Otherwise a record rewritten repeatedly would move between buckets and the smallest one ever seen would pin its true length, which is exactly what the padding is there to prevent.
The junk pads every record up to one of a few fixed sizes, so a file's length says which bucket it fell in and nothing finer. The additional data for the AEAD is the filename itself, which binds a record to its name: two real files cannot be swapped without the swap being detected, because each one authenticates the name it is supposed to be under.
The per-record key is a separate HKDF branch, so one record's key says nothing about another's.
The roster, and the one deletion claim this can honestly make
A record can be modified, and the AEAD catches that: any edit fails to authenticate and Hoard::audit reports the record as tampered with.
Deletion is harder, because a file that is not there looks exactly like a file that was never written. So the hoard keeps one more record, the roster, listing the logical names that should exist. It is stored like any other record, under a derived name, encrypted and padded, so it is not identifiable from outside either.
That gives a real answer for deletion of some of the folder: a record in the roster whose file is gone was deleted, and audit says so. It gives no answer for deletion of all of it, including the roster, and nothing stored in this folder ever could -- at that point the only evidence is that the folder is empty, which you can see for yourself. The roster is missing while the lock exists is itself reported, which is the closest honest approximation, and it is stated as what it is rather than dressed up.
In plain words
VeilVoice's own files are encrypted and given meaningless names, and a pile of decoy files sits among them so nobody can tell which is which. Only the program, once you have unlocked it, can work out which file is which.
Anybody looking at the folder still knows you use VeilVoice, and can still delete the lot. What they cannot do is read any of it, work out how much of it there is, or change any of it without VeilVoice telling you next time you unlock.
WHAT THIS FILE CONTAINS
1099 lines defining 19 functions (11 public), 3 types and 9 constants. Everything below is read out of the source, so it cannot disagree with the code.
The types it owns.
struct StoreKeyline 193 · The key that names and opens everything in the hoard.struct Auditline 249 · What an audit found.struct Hoardline 271 · An obfuscated store rooted at a directory.
What happens when it runs. These are the ways in: public, and nothing else in this file calls them, so they are what an outside caller reaches first.
StoreKey::from_secretline 197 · Wrap raw key material.Audit::is_cleanline 265 · Whether anything was found that a user should be told about.Hoard::openline 278 · Open the hoard in dir.Hoard::writeline 316 · Encrypt and store data under logical, padded and named so that neither its content nor its purpose is visible from outside.
reachesroster,save_roster,write_raw,open_bytes,path_for,fill_random,name_for,base64urlHoard::readline 425 · Read a record back, or None if it was never written.
reachesopen_bytes,path_for,name_for,base64urlHoard::removeline 479 · Remove a record and drop it from the roster.
reachespath_for,roster,save_roster,name_for,open_bytes,write_raw,base64url,fill_randomHoard::sow_decoysline 534 · Write decoy files: names of the same shape, contents of the same character, holding nothing.
reachesbase64url,fill_randomHoard::auditline 565 · Check every record the roster knows about, and count what else is here.
reachesis_hoard_shaped,name_for,open_bytes,roster,base64url,path_for
WHAT CALLS WHAT
The functions this file defines, and the calls between them. An edge means the callee's name appears, called, inside the caller's body. This is a syntactic reading, not a type-resolved one.
The same graph as Mermaid source
%%{init: {"theme":"base","themeVariables":{"background":"#1a1b26","primaryColor":"#1f2335","primaryTextColor":"#c0caf5","primaryBorderColor":"#7aa2f7","secondaryColor":"#16161e","tertiaryColor":"#16161e","lineColor":"#737aa2","textColor":"#c0caf5","mainBkg":"#1f2335","nodeBorder":"#7aa2f7","clusterBkg":"#16161e","clusterBorder":"#2f3549","fontFamily":"ui-monospace, SFMono-Regular, Consolas, monospace","fontSize":"14px"}}}%%
flowchart TD
n_from_secret(["StoreKey::from_secret<br/>line 197"])
n_expand["StoreKey::expand<br/>line 202"]
n_base64url["base64url<br/>line 214"]
n_bucket_for["bucket_for<br/>line 235"]
n_is_clean(["Audit::is_clean<br/>line 265"])
n_open(["Hoard::open<br/>line 278"])
n_name_for["Hoard::name_for<br/>line 289"]
n_path_for["Hoard::path_for<br/>line 308"]
n_write(["Hoard::write<br/>line 316"])
n_write_raw["Hoard::write_raw<br/>line 332"]
n_read(["Hoard::read<br/>line 425"])
n_open_bytes["Hoard::open_bytes<br/>line 439"]
n_remove(["Hoard::remove<br/>line 479"])
n_roster["Hoard::roster<br/>line 494"]
n_save_roster["Hoard::save_roster<br/>line 515"]
n_sow_decoys(["Hoard::sow_decoys<br/>line 534"])
n_audit(["Hoard::audit<br/>line 565"])
n_is_hoard_shaped["is_hoard_shaped<br/>line 602"]
n_fill_random["fill_random<br/>line 614"]
n_audit --> n_is_hoard_shaped
n_audit --> n_name_for
n_audit --> n_open_bytes
n_audit --> n_roster
n_name_for --> n_base64url
n_open_bytes --> n_name_for
n_path_for --> n_name_for
n_read --> n_open_bytes
n_read --> n_path_for
n_remove --> n_path_for
n_remove --> n_roster
n_remove --> n_save_roster
n_roster --> n_open_bytes
n_roster --> n_path_for
n_save_roster --> n_write_raw
n_sow_decoys --> n_base64url
n_sow_decoys --> n_fill_random
n_write --> n_roster
n_write --> n_save_roster
n_write --> n_write_raw
n_write_raw --> n_fill_random
n_write_raw --> n_name_for
click n_from_secret href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L197" "open the source"
click n_expand href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L202" "open the source"
click n_base64url href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L214" "open the source"
click n_bucket_for href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L235" "open the source"
click n_is_clean href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L265" "open the source"
click n_open href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L278" "open the source"
click n_name_for href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L289" "open the source"
click n_path_for href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L308" "open the source"
click n_write href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L316" "open the source"
click n_write_raw href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L332" "open the source"
click n_read href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L425" "open the source"
click n_open_bytes href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L439" "open the source"
click n_remove href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L479" "open the source"
click n_roster href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L494" "open the source"
click n_save_roster href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L515" "open the source"
click n_sow_decoys href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L534" "open the source"
click n_audit href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L565" "open the source"
click n_is_hoard_shaped href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L602" "open the source"
click n_fill_random href "https://github.com/tilas01/veilvoice/blob/main/crates/veilvoice-crypto/src/hoard.rs#L614" "open the source"
classDef entry fill:#1f2335,stroke:#7aa2f7,color:#c0caf5
class n_from_secret,n_is_clean,n_open,n_write,n_read,n_remove,n_sow_decoys,n_audit entry
classDef api fill:#1f2335,stroke:#7dcfff,color:#c0caf5
class n_name_for,n_path_for,n_roster api
classDef helper fill:#1f2335,stroke:#bb9af7,color:#c0caf5
class n_expand,n_base64url,n_bucket_for,n_write_raw,n_open_bytes,n_save_roster,n_is_hoard_shaped,n_fill_random helper
This site loads no third-party script, so it cannot run Mermaid; the diagram above is the same nodes and edges drawn by the generator instead. GitHub renders the source below directly.
ITEMS
| Item | Line | Documentation |
|---|---|---|
INFO_NAME const | 138 | HKDF label for the filename key. |
INFO_REC const | 140 | HKDF label prefix for a record's own encryption key. |
NAME_BYTES const | 147 | How many bytes of the name HMAC end up in the filename. |
LEN_PREFIX const | 150 | The length prefix inside the padded plaintext. |
MAX_EXPANSION const | 164 | The most any encoding in crate::weave can grow its input. |
MARKER const | 170 | The inner encoding marker, which sits before the length. |
OUTER_MARKER const | 177 | The outer encoding marker, the first two bytes of the file. |
BUCKETS const | 183 | The sizes a record is padded up to, in bytes of plaintext. |
ROSTER const | 186 | The logical name of the roster record. |
StoreKey pub struct | 193 | The key that names and opens everything in the hoard. |
StoreKey::from_secret pub fn | 197 | Wrap raw key material. |
StoreKey::expand fn | 202 | Derive a subkey under a label. |
base64url fn | 214 | Base64url, no padding. |
bucket_for fn | 235 | The bucket a payload of this length pads up to. |
Audit pub struct | 249 | What an audit found. |
Audit::is_clean pub fn | 265 | Whether anything was found that a user should be told about. |
Hoard pub struct | 271 | An obfuscated store rooted at a directory. |
Hoard::open pub fn | 278 | Open the hoard in dir. |
Hoard::name_for pub fn | 289 | The filename a logical record lives under. |
Hoard::path_for pub fn | 308 | The full path of a logical record. |
Hoard::write pub fn | 316 | Encrypt and store data under logical, padded and named so that neither its content nor its purpose is visible from outside. |
Hoard::write_raw fn | 332 | Encode, seal and write one record under its obfuscated name. |
Hoard::read pub fn | 425 | Read a record back, or None if it was never written. |
Hoard::open_bytes fn | 439 | Undo Hoard::write_raw for bytes already read off the disk. |
Hoard::remove pub fn | 479 | Remove a record and drop it from the roster. |
Hoard::roster pub fn | 494 | The logical names the roster says should exist. |
Hoard::save_roster fn | 515 | Write the list of logical names, itself as an ordinary record. |
Hoard::sow_decoys pub fn | 534 | Write decoy files: names of the same shape, contents of the same character, holding nothing. |
Hoard::audit pub fn | 565 | Check every record the roster knows about, and count what else is here. |
is_hoard_shaped fn | 602 | Whether a filename has the shape this module writes. |
fill_random fn | 614 | Fill buf from the operating system, treating a refusal as an error rather than falling back to anything. |