Chunk objects, shard the store, and make filler configurable
File bodies are now cut into 4 MiB objects, the last padded to its power-of-two bucket, so every object is 64 KiB to 4 MiB whatever the file size. Object IDs are HMAC'd from a secret per-record seed, so nothing on disk links a file's pieces, and each object still leads with its own ID, binding it to its record and position. Chunks are encrypted and synced by a small worker pool so their fsyncs overlap. records/ and objects/ are sharded into 256 subdirectories by ID prefix, all created at init, with every shard's mtime pinned to the epoch. vault init --filler SIZE writes at least SIZE of filler. Filler entries are now real entries for a throwaway key, of log-uniform random sizes, so they carry multi-chunk files like a real collection. The marker becomes VAULT 2; earlier vaults are not supported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -12,7 +12,9 @@ root that builds a single `vault` binary. Module `github.com/andydixon/vault`.
|
|||||||
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second
|
XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). The second
|
||||||
slot is either a decoy identity or random bytes; slot order is random.
|
slot is either a decoy identity or random bytes; slot order is random.
|
||||||
- `store.go` - on-disk vault: `records/` holds fixed-size encrypted catalogue
|
- `store.go` - on-disk vault: `records/` holds fixed-size encrypted catalogue
|
||||||
records, `objects/` holds padded encrypted file bodies. Records a key cannot
|
records, `objects/` holds file bodies cut into 4 MiB chunks (last one
|
||||||
|
bucket-padded) whose IDs are HMAC'd from a per-record seed. Both are
|
||||||
|
sharded into 256 `xx/` subdirectories by ID prefix. Records a key cannot
|
||||||
open are skipped, not treated as errors.
|
open are skipped, not treated as errors.
|
||||||
- `lock*.go` - vault lock on `DIR/.lock`: fcntl on unix, LockFileEx on
|
- `lock*.go` - vault lock on `DIR/.lock`: fcntl on unix, LockFileEx on
|
||||||
Windows, an exclusive lock file elsewhere. Writers lock exclusively,
|
Windows, an exclusive lock file elsewhere. Writers lock exclusively,
|
||||||
@@ -46,10 +48,12 @@ export VAULT_PASSPHRASE=test VAULT_KEY=$PWD/k VAULT_DIR=$PWD/s
|
|||||||
|
|
||||||
- Never write a seed or private key unencrypted.
|
- Never write a seed or private key unencrypted.
|
||||||
- Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable.
|
- Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable.
|
||||||
- Every ciphertext's mtime is pinned to the epoch; records stay one fixed
|
- Every ciphertext's and shard directory's mtime is pinned to the epoch;
|
||||||
size; objects stay bucket-padded. These hide metadata from the storage host.
|
records stay one fixed size; objects stay at most 4 MiB and bucket-padded.
|
||||||
- An object's plaintext starts with its own ID, binding it to its record.
|
These hide metadata from the storage host.
|
||||||
- `get` publishes output only after the entire object has authenticated.
|
- An object's plaintext starts with its own ID, binding it to its record
|
||||||
|
and its position in the file.
|
||||||
|
- `get` publishes output only after every object has authenticated.
|
||||||
- Decoy deniability: both key slots stay the same size and are both always
|
- Decoy deniability: both key slots stay the same size and are both always
|
||||||
tried; `init` always writes filler; foreign records are skipped silently.
|
tried; `init` always writes filler; foreign records are skipped silently.
|
||||||
- Every command that changes the vault holds the exclusive lock.
|
- Every command that changes the vault holds the exclusive lock.
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ A post-quantum encrypted file vault in a single binary.
|
|||||||
Files are encrypted with a hybrid FrodoKEM-640 + ML-KEM-768 key encapsulation
|
Files are encrypted with a hybrid FrodoKEM-640 + ML-KEM-768 key encapsulation
|
||||||
and ChaCha20-Poly1305. The vault directory holds only randomly named
|
and ChaCha20-Poly1305. The vault directory holds only randomly named
|
||||||
ciphertexts. Names, sizes, modes and timestamps live inside encrypted
|
ciphertexts. Names, sizes, modes and timestamps live inside encrypted
|
||||||
catalogue records of one fixed size. File bodies are padded to power-of-two
|
catalogue records of one fixed size. File bodies are cut into 4 MiB objects,
|
||||||
buckets.
|
the last padded to a power-of-two bucket, so every object is 64 KiB to 4 MiB
|
||||||
|
whatever the file's size. Object names derive from a secret seed in the
|
||||||
|
record, so nothing on disk links a file's pieces. Records and objects are
|
||||||
|
spread over 256 subfolders (`records/3f/3fa9…`) to keep directories small.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ A full illustrated guide lives in `docs/guide`. Build the PDF with
|
|||||||
```sh
|
```sh
|
||||||
vault genkey # create ~/.vault/key, protected by a passphrase
|
vault genkey # create ~/.vault/key, protected by a passphrase
|
||||||
vault init # create ~/.vault/store
|
vault init # create ~/.vault/store
|
||||||
|
vault init --filler 2G # the same, with at least 2 GiB of filler
|
||||||
vault add report.pdf # stored as report.pdf
|
vault add report.pdf # stored as report.pdf
|
||||||
vault add report.pdf work/2026 # stored as work/2026/report.pdf
|
vault add report.pdf work/2026 # stored as work/2026/report.pdf
|
||||||
vault add ./photos # every regular file under photos/
|
vault add ./photos # every regular file under photos/
|
||||||
@@ -84,10 +88,22 @@ Two measures keep the real files deniable:
|
|||||||
A decoy can only be set when the key is created. Someone who watches the
|
A decoy can only be set when the key is created. Someone who watches the
|
||||||
vault over time can still see it grow when real files are added.
|
vault over time can still see it grow when real files are added.
|
||||||
|
|
||||||
|
Default filler is only a few tens of megabytes, so someone holding the decoy
|
||||||
|
passphrase can see when far more data than that is unopenable. To hide more,
|
||||||
|
create the vault with `vault init --filler SIZE` (`500M`, `2G`, `1T`), at
|
||||||
|
least as large as what you will hide. Filler entries are written by the
|
||||||
|
same code as real files, with a spread of small and multi-chunk sizes, so
|
||||||
|
they look like a real collection. Filler can only be added at `init`;
|
||||||
|
added later it would stand out as a burst of new entries.
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
- The storage host can see how many files exist, their size buckets and when
|
- The storage host can see how many records and objects exist, so roughly
|
||||||
the vault changes.
|
how much is stored, and when the vault changes. Chunking hides a file's
|
||||||
|
size in any single snapshot, but objects written together can be grouped
|
||||||
|
by anyone watching the folder or a sync service's upload log.
|
||||||
|
- Padding still costs space: small files round up to a 64 KiB-or-larger
|
||||||
|
bucket and a big file's last piece to its bucket, at most 4 MiB per file.
|
||||||
- The key file is the only way in. Losing it or its passphrase loses the data.
|
- The key file is the only way in. Losing it or its passphrase loses the data.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -232,7 +232,7 @@ def main():
|
|||||||
t = Term(box)
|
t = Term(box)
|
||||||
t.cmd("vault init")
|
t.cmd("vault init")
|
||||||
t.cmd("ls -A ~/.vault/store/")
|
t.cmd("ls -A ~/.vault/store/")
|
||||||
t.cmd("ls ~/.vault/store/records | wc -l")
|
t.cmd("find ~/.vault/store/records -type f | wc -l")
|
||||||
shoot("init", t)
|
shoot("init", t)
|
||||||
|
|
||||||
t = Term(box)
|
t = Term(box)
|
||||||
@@ -261,7 +261,7 @@ def main():
|
|||||||
shoot("mv-rm", t)
|
shoot("mv-rm", t)
|
||||||
|
|
||||||
t = Term(box, title="sam@laptop: ~/.vault/store")
|
t = Term(box, title="sam@laptop: ~/.vault/store")
|
||||||
t.cmd("cd .vault/store && ls -l records | head -5", cwd="~/.vault/store", shown="ls -l records | head -5")
|
t.cmd("cd .vault/store && ls -l records/*/* | head -5", cwd="~/.vault/store", shown="ls -l records/*/* | head -5")
|
||||||
shoot("on-disk", t)
|
shoot("on-disk", t)
|
||||||
|
|
||||||
# decoy: fill the decoy side, then show both views side by side
|
# decoy: fill the decoy side, then show both views side by side
|
||||||
@@ -275,7 +275,7 @@ def main():
|
|||||||
|
|
||||||
t = Term(box)
|
t = Term(box)
|
||||||
t.cmd("vault get ideas/notes.md -o /tmp/x.md", D, highlight=("not in the vault",))
|
t.cmd("vault get ideas/notes.md -o /tmp/x.md", D, highlight=("not in the vault",))
|
||||||
t.cmd("ls ~/.vault/store/records | wc -l")
|
t.cmd("find ~/.vault/store/records -type f | wc -l")
|
||||||
shoot("decoy-hidden", t)
|
shoot("decoy-hidden", t)
|
||||||
|
|
||||||
# errors
|
# errors
|
||||||
|
|||||||
@@ -93,9 +93,11 @@ decrypts it. That's the core loop. Put in, take out.
|
|||||||
|
|
||||||
The interesting part is what the folder looks like from the outside. Nothing.
|
The interesting part is what the folder looks like from the outside. Nothing.
|
||||||
Well, not \emph{nothing}. A pile of randomly named files, all dated 1 January
|
Well, not \emph{nothing}. A pile of randomly named files, all dated 1 January
|
||||||
1970, in a handful of suspiciously round sizes. No names. No real sizes. No
|
1970, all in a handful of suspiciously round sizes, none bigger than 4\,MiB,
|
||||||
dates. No folders. Someone who steals the folder learns roughly how much stuff
|
scattered across folders named \file{00} to \file{ff}. No names. No real
|
||||||
you have, and that's about it.
|
sizes. No dates. None of \emph{your} folders. Big files are cut into pieces
|
||||||
|
that look like everything else. Someone who steals the folder learns roughly
|
||||||
|
how much stuff you have, and that's about it.
|
||||||
|
|
||||||
\begin{center}
|
\begin{center}
|
||||||
\begin{tikzpicture}[
|
\begin{tikzpicture}[
|
||||||
@@ -436,6 +438,12 @@ protect yet.
|
|||||||
|
|
||||||
\shot{init}{A fresh vault. Two sub-folders, a marker, and\ldots\ records already?!}
|
\shot{init}{A fresh vault. Two sub-folders, a marker, and\ldots\ records already?!}
|
||||||
|
|
||||||
|
Inside \file{records/} and \file{objects/} are 256 folders each, named
|
||||||
|
\file{00} to \file{ff}. Every encrypted file lives in the one matching the
|
||||||
|
first two characters of its name. That just keeps any one folder from
|
||||||
|
getting huge, which some file systems and sync services dislike. All 256
|
||||||
|
exist from the start, so the layout itself gives nothing away.
|
||||||
|
|
||||||
\section{Wait, why is there already stuff in it?}
|
\section{Wait, why is there already stuff in it?}
|
||||||
|
|
||||||
Good catch. A brand new vault holds somewhere between 8 and 32 entries of
|
Good catch. A brand new vault holds somewhere between 8 and 32 entries of
|
||||||
@@ -448,10 +456,33 @@ with your decoy passphrase sees unopenable entries, that's completely
|
|||||||
unremarkable. It's just filler. (Some of it might not be. They can't tell.)
|
unremarkable. It's just filler. (Some of it might not be. They can't tell.)
|
||||||
|
|
||||||
\begin{headsup}
|
\begin{headsup}
|
||||||
The filler takes a few tens of megabytes. That's the price of plausible
|
By default the filler takes a few tens of megabytes. That's the price of plausible
|
||||||
deniability, and it's paid once.
|
deniability, and it's paid once.
|
||||||
\end{headsup}
|
\end{headsup}
|
||||||
|
|
||||||
|
\section{More filler, for bigger secrets}
|
||||||
|
|
||||||
|
A few tens of megabytes of filler can only cover for a few files. If you
|
||||||
|
plan to hide more than that behind a decoy, ask for more when you create the
|
||||||
|
vault:
|
||||||
|
|
||||||
|
\begin{shell}
|
||||||
|
$ vault init --filler 2G
|
||||||
|
\end{shell}
|
||||||
|
|
||||||
|
vault keeps writing filler until there's at least that much. Sizes take
|
||||||
|
\cmd{K}, \cmd{M}, \cmd{G} or \cmd{T}. The filler entries are built exactly
|
||||||
|
like real ones, from a mix of small and large random ``files'', so a big
|
||||||
|
file of yours sits among big files of filler. It costs the disk space and a
|
||||||
|
few seconds per gigabyte, once. It can only be chosen at \cmd{init}: added
|
||||||
|
later, the filler would show up as a burst of new entries, which is exactly
|
||||||
|
what it's meant to hide.
|
||||||
|
|
||||||
|
\begin{quickwin}
|
||||||
|
Rule of thumb: set \cmd{--filler} to at least as much as you ever expect to
|
||||||
|
hide from the decoy passphrase. Without a decoy, you don't need it.
|
||||||
|
\end{quickwin}
|
||||||
|
|
||||||
\section{More than one vault}
|
\section{More than one vault}
|
||||||
|
|
||||||
Want a separate vault for work stuff? Point \cmd{--vault} anywhere:
|
Want a separate vault for work stuff? Point \cmd{--vault} anywhere:
|
||||||
@@ -473,6 +504,7 @@ type \cmd{--vault} again.
|
|||||||
\begin{pocketlist}
|
\begin{pocketlist}
|
||||||
\item \cmd{vault init} makes \file{\tld/.vault/store}. \cmd{--vault DIR} to put it elsewhere.
|
\item \cmd{vault init} makes \file{\tld/.vault/store}. \cmd{--vault DIR} to put it elsewhere.
|
||||||
\item Filler entries are normal. Nobody can open them. That's the point.
|
\item Filler entries are normal. Nobody can open them. That's the point.
|
||||||
|
\item Hiding a lot behind a decoy? \cmd{vault init --filler 2G} (or however much you'll hide).
|
||||||
\item One key, as many vaults as you like.
|
\item One key, as many vaults as you like.
|
||||||
\end{pocketlist}
|
\end{pocketlist}
|
||||||
\end{pocket}
|
\end{pocket}
|
||||||
@@ -677,7 +709,8 @@ The decoy is chosen when the key is made, and only then:
|
|||||||
\begin{steps}
|
\begin{steps}
|
||||||
\item Run \cmd{vault genkey}. Type your \emph{real} passphrase, twice.
|
\item Run \cmd{vault genkey}. Type your \emph{real} passphrase, twice.
|
||||||
\item At \hl{Enter decoy passphrase (empty for none)}, type the decoy. Twice.
|
\item At \hl{Enter decoy passphrase (empty for none)}, type the decoy. Twice.
|
||||||
\item Run \cmd{vault init} as normal.
|
\item Run \cmd{vault init}, adding \cmd{--filler} with at least as much as
|
||||||
|
you plan to hide, say \cmd{--filler 5G}.
|
||||||
\item Add some decoy files, typing the \emph{decoy} passphrase when asked.
|
\item Add some decoy files, typing the \emph{decoy} passphrase when asked.
|
||||||
\item Add your real files, typing the \emph{real} passphrase.
|
\item Add your real files, typing the \emph{real} passphrase.
|
||||||
\end{steps}
|
\end{steps}
|
||||||
@@ -704,8 +737,9 @@ there's nothing to safely ``upgrade''.
|
|||||||
\item \textbf{The vault can't tell.} Real and decoy entries are sealed to
|
\item \textbf{The vault can't tell.} Real and decoy entries are sealed to
|
||||||
different keys, but they're the same size, the same shape, and all
|
different keys, but they're the same size, the same shape, and all
|
||||||
dated 1970. Nothing on disk says which key an entry belongs to.
|
dated 1970. Nothing on disk says which key an entry belongs to.
|
||||||
\item \textbf{The filler covers for you.} Every vault starts with 8 to 32
|
\item \textbf{The filler covers for you.} Every vault starts with at
|
||||||
entries nobody can open. So ``some entries I can't open'' is true of
|
least 8 to 32 entries nobody can open, and as much more as you ask
|
||||||
|
for with \cmd{--filler}. So ``some entries I can't open'' is true of
|
||||||
\emph{every} vault, decoy or not.
|
\emph{every} vault, decoy or not.
|
||||||
\item \textbf{The errors are identical.} A real file asked for by name with
|
\item \textbf{The errors are identical.} A real file asked for by name with
|
||||||
the decoy passphrase gives exactly the same ``not in the vault'' as
|
the decoy passphrase gives exactly the same ``not in the vault'' as
|
||||||
@@ -734,10 +768,14 @@ one with two files called \file{test.txt}, is a neon sign.
|
|||||||
\section{The honest limits}
|
\section{The honest limits}
|
||||||
|
|
||||||
\begin{headsup}
|
\begin{headsup}
|
||||||
\textbf{Numbers can give you away.} Every file is one entry (one file in
|
\textbf{Numbers can give you away.} Someone holding the decoy passphrase
|
||||||
\file{records/}), and filler adds at most 32. If the decoy shows 3 files but
|
can count the records and pieces they can't open. Without \cmd{--filler}
|
||||||
\file{records/} holds 400, the filler can't explain the difference. Keep the decoy collection
|
there are only a few tens of megabytes of filler, so if \file{records/} holds
|
||||||
large enough that the leftovers look like filler.
|
400 entries, or \file{objects/} holds a 300\,MB video's 72 pieces, the
|
||||||
|
filler can't explain the difference. They can't see \emph{what} the
|
||||||
|
leftovers are, but they can see there's more than filler. Create the vault
|
||||||
|
with \cmd{--filler} at least as large as what you'll hide, and keep the
|
||||||
|
decoy collection believable next to it.
|
||||||
\end{headsup}
|
\end{headsup}
|
||||||
|
|
||||||
\begin{headsup}
|
\begin{headsup}
|
||||||
@@ -950,8 +988,9 @@ point \cmd{--vault} at the backup copy and run \cmd{vault list} now and then.
|
|||||||
\begin{tldr}
|
\begin{tldr}
|
||||||
Each file gets its own random 256-bit key, wrapped with a hybrid of FrodoKEM
|
Each file gets its own random 256-bit key, wrapped with a hybrid of FrodoKEM
|
||||||
and ML-KEM, and is encrypted in authenticated 64\,KiB chunks. Catalogue
|
and ML-KEM, and is encrypted in authenticated 64\,KiB chunks. Catalogue
|
||||||
entries are padded to one fixed size, file contents to power-of-two buckets,
|
entries are padded to one fixed size, file contents are cut into 4\,MiB
|
||||||
and every timestamp on disk is set to 1970. An attacker learns roughly how
|
pieces with the last padded to a power-of-two bucket, and every timestamp on
|
||||||
|
disk is set to 1970. An attacker learns roughly how
|
||||||
much you store. They can delete or roll back entries, but not read or forge them.
|
much you store. They can delete or roll back entries, but not read or forge them.
|
||||||
\end{tldr}
|
\end{tldr}
|
||||||
|
|
||||||
@@ -983,26 +1022,60 @@ either one alone isn't enough.
|
|||||||
note/.style={font=\footnotesize\color{vslate},anchor=west},
|
note/.style={font=\footnotesize\color{vslate},anchor=west},
|
||||||
tree/.style={draw=vslate!45,line width=0.8pt}]
|
tree/.style={draw=vslate!45,line width=0.8pt}]
|
||||||
\node[dir] (root) at (0,0) {\tld/.vault/store/};
|
\node[dir] (root) at (0,0) {\tld/.vault/store/};
|
||||||
\node[f] (m) at (0.6,-0.7) {.vault}; \node[note] at (4.2,-0.7) {marker: ``VAULT 1''};
|
\node[f] (m) at (0.6,-0.7) {.vault}; \node[note] at (4.2,-0.7) {marker: ``VAULT 2''};
|
||||||
\node[f] (l) at (0.6,-1.3) {.lock}; \node[note] at (4.2,-1.3) {lock file, always empty};
|
\node[f] (l) at (0.6,-1.3) {.lock}; \node[note] at (4.2,-1.3) {lock file, always empty};
|
||||||
\node[dir] (r) at (0.6,-1.9) {records/}; \node[note] at (4.2,-1.9) {one encrypted catalogue entry per file};
|
\node[dir] (r) at (0.6,-1.9) {records/0f/}; \node[note] at (4.2,-1.9) {one encrypted catalogue entry per file};
|
||||||
\node[f] (r1) at (1.2,-2.5) {0f1170a6\ldots}; \node[note] at (4.2,-2.5) {every one exactly 18,704 bytes};
|
\node[f] (r1) at (1.2,-2.5) {0f1170a6\ldots}; \node[note] at (4.2,-2.5) {every one exactly 18,704 bytes};
|
||||||
\node[dir] (o) at (0.6,-3.1) {objects/}; \node[note] at (4.2,-3.1) {one encrypted file body per file};
|
\node[dir] (o) at (0.6,-3.1) {objects/3e/}; \node[note] at (4.2,-3.1) {file contents, in 4\,MiB pieces};
|
||||||
\node[f] (o1) at (1.2,-3.7) {3e78275e\ldots}; \node[note] at (4.2,-3.7) {64\,KiB, 128\,KiB, 256\,KiB\ldots\ never in between};
|
\node[f] (o1) at (1.2,-3.7) {3e78275e\ldots}; \node[note] at (4.2,-3.7) {64\,KiB, 128\,KiB\ldots\ 4\,MiB, never in between};
|
||||||
\draw[tree] (0.3,-0.25) |- (m.west); \draw[tree] (0.3,-0.7) |- (l.west); \draw[tree] (0.3,-1.3) |- (r.west);
|
\draw[tree] (0.3,-0.25) |- (m.west); \draw[tree] (0.3,-0.7) |- (l.west); \draw[tree] (0.3,-1.3) |- (r.west);
|
||||||
\draw[tree] (0.3,-1.9) |- (o.west);
|
\draw[tree] (0.3,-1.9) |- (o.west);
|
||||||
\draw[tree] (0.9,-2.15) |- (r1.west); \draw[tree] (0.9,-3.35) |- (o1.west);
|
\draw[tree] (0.9,-2.15) |- (r1.west); \draw[tree] (0.9,-3.35) |- (o1.west);
|
||||||
\end{tikzpicture}
|
\end{tikzpicture}
|
||||||
\end{center}
|
\end{center}
|
||||||
|
|
||||||
A \hl{record} holds one file's name, size, permissions, dates and the ID of
|
A \hl{record} holds one file's name, size, permissions, dates and a secret
|
||||||
its object. It's padded to 4096 bytes before encryption, so every record is
|
random \hl{seed}. It's padded to 4096 bytes before encryption, so every
|
||||||
the same size whatever the name. An \hl{object} holds the file's contents,
|
record is the same size whatever the name. The file's contents are cut into
|
||||||
prefixed with its own ID and padded with random bytes up to the next power
|
\hl{objects} of just under 4\,MiB each. Every object starts with its own ID,
|
||||||
of two, at least 64\,KiB.
|
the last one is padded with random bytes up to the next power of two (at
|
||||||
|
least 64\,KiB), and each is encrypted separately. The objects' names are
|
||||||
|
worked out from the seed, so only someone who can open the record can tell
|
||||||
|
which objects belong together.
|
||||||
|
|
||||||
\shot{on-disk}{Random names, identical sizes, and all ``modified'' at the dawn of Unix time}
|
\shot{on-disk}{Random names, identical sizes, and all ``modified'' at the dawn of Unix time}
|
||||||
|
|
||||||
|
\section{Why big files are cut up}
|
||||||
|
|
||||||
|
With one object per file, a 300\,MB video would sit in the folder as a
|
||||||
|
single 512\,MiB blob. Everyone could see there's one large file, roughly how
|
||||||
|
large, and, since filler is never that big, that it can't be filler. Cut
|
||||||
|
into pieces, the same video is 72 objects of exactly the same size as the
|
||||||
|
largest filler and as every other big file's pieces. Nothing on disk says
|
||||||
|
which pieces belong together, or where one file ends and the next begins.
|
||||||
|
|
||||||
|
It's not magic, and some padding and fluff is still doing the work:
|
||||||
|
|
||||||
|
\begin{itemize}
|
||||||
|
\item \textbf{Small files are still bucketed.} A file under 4\,MiB is one
|
||||||
|
object padded to 64\,KiB, 128\,KiB, 256\,KiB and so on, exactly as
|
||||||
|
before. The last piece of a big file is padded the same way, so no
|
||||||
|
file wastes more than 4\,MiB, where a single padded blob could
|
||||||
|
waste nearly half its size.
|
||||||
|
\item \textbf{Filler is still the cover story.} The random entries
|
||||||
|
\cmd{init} writes are the reason ``objects I can't open'' is
|
||||||
|
unremarkable. By default there are only a few tens of megabytes of
|
||||||
|
them, enough to hide a few files, not gigabytes. \cmd{vault init
|
||||||
|
--filler SIZE} writes more, shaped like a real collection of
|
||||||
|
files, at the cost of that much disk.
|
||||||
|
\item \textbf{Totals still show.} The number of objects times 4\,MiB is
|
||||||
|
a fair estimate of how much you store.
|
||||||
|
\item \textbf{Timing still shows.} Someone who watches the folder change,
|
||||||
|
or reads a sync service's upload log, sees 72 objects appear together
|
||||||
|
and can guess they're one file. The pieces hide a file's size from a
|
||||||
|
snapshot, not from someone watching you add it.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
\section{What an attacker learns}
|
\section{What an attacker learns}
|
||||||
|
|
||||||
\begin{vtable}{Y>{\centering\arraybackslash}p{22mm}}
|
\begin{vtable}{Y>{\centering\arraybackslash}p{22mm}}
|
||||||
@@ -1014,7 +1087,8 @@ Learn when a file was created or changed & \textcolor{vgreendeep}{\faIcon{times}
|
|||||||
Tell real entries from decoy or filler & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
Tell real entries from decoy or filler & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
||||||
Alter a file without it being detected & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
Alter a file without it being detected & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
||||||
Swap one file's contents for another's & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
Swap one file's contents for another's & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
||||||
Count entries, and see rough size buckets & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
|
Tell which pieces make up one file, from a single copy & \textcolor{vgreendeep}{\faIcon{times}\ no} \\
|
||||||
|
Count files, and see roughly how much you store & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
|
||||||
See that the folder changed between two copies & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
|
See that the folder changed between two copies & \textcolor{vamberdeep}{\faIcon{check}\ yes} \\
|
||||||
Delete entries & \textcolor{vcoraldeep}{\faIcon{check}\ yes} \\
|
Delete entries & \textcolor{vcoraldeep}{\faIcon{check}\ yes} \\
|
||||||
Put back an older copy of an entry (roll back) & \textcolor{vcoraldeep}{\faIcon{check}\ yes} \\
|
Put back an older copy of an entry (roll back) & \textcolor{vcoraldeep}{\faIcon{check}\ yes} \\
|
||||||
@@ -1029,15 +1103,17 @@ If the storage isn't under your control, keep an independent backup.
|
|||||||
\section{What happens during \texttt{add}}
|
\section{What happens during \texttt{add}}
|
||||||
|
|
||||||
\begin{steps}
|
\begin{steps}
|
||||||
\item Read your file and encrypt its contents, plus random padding, to a
|
\item Pick a random seed. Read your file 4\,MiB at a time and encrypt each
|
||||||
hidden temporary file in \file{objects/}.
|
piece, the last with random padding, to a hidden temporary file in
|
||||||
\item Give it its random name. It's now safely stored, but nothing refers to it.
|
its \file{objects/} folder. Several pieces are written at once.
|
||||||
\item Encrypt a record pointing at it, the same way, into \file{records/}.
|
\item Give each piece the name the seed dictates. They're now safely
|
||||||
|
stored, but nothing refers to them.
|
||||||
|
\item Encrypt a record holding the seed, the same way, into \file{records/}.
|
||||||
\item Only then, delete any older version of the same name.
|
\item Only then, delete any older version of the same name.
|
||||||
\end{steps}
|
\end{steps}
|
||||||
|
|
||||||
Interrupt it anywhere and the worst case is an orphaned encrypted object that
|
Interrupt it anywhere and the worst case is some orphaned encrypted objects
|
||||||
nothing points to. You never end up with a record pointing at nothing, or
|
that nothing points to. You never end up with a record pointing at nothing, or
|
||||||
without either version.
|
without either version.
|
||||||
|
|
||||||
\begin{squirrel}
|
\begin{squirrel}
|
||||||
@@ -1051,7 +1127,8 @@ is how bugs get in.
|
|||||||
\begin{pocket}
|
\begin{pocket}
|
||||||
\begin{pocketlist}
|
\begin{pocketlist}
|
||||||
\item Hybrid post-quantum key wrapping, per-file keys, authenticated streaming encryption.
|
\item Hybrid post-quantum key wrapping, per-file keys, authenticated streaming encryption.
|
||||||
\item Fixed-size records, power-of-two objects, 1970 timestamps: metadata stays private.
|
\item Fixed-size records, 4\,MiB pieces, power-of-two tails, 1970 timestamps: metadata stays private.
|
||||||
|
\item Pieces hide a file's size in any one copy of the folder, not from someone watching it change.
|
||||||
\item Tampering is caught. Deletion and rollback are not, so keep backups.
|
\item Tampering is caught. Deletion and rollback are not, so keep backups.
|
||||||
\end{pocketlist}
|
\end{pocketlist}
|
||||||
\end{pocket}
|
\end{pocket}
|
||||||
@@ -1137,7 +1214,7 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
|
|||||||
\begin{tcbraster}[raster columns=2,raster equal height=rows,raster column skip=8pt,raster row skip=8pt,
|
\begin{tcbraster}[raster columns=2,raster equal height=rows,raster column skip=8pt,raster row skip=8pt,
|
||||||
enhanced,colback=vmist,colframe=vline,boxrule=0.4pt,arc=4pt,left=9pt,right=9pt,top=7pt,bottom=7pt]
|
enhanced,colback=vmist,colframe=vline,boxrule=0.4pt,arc=4pt,left=9pt,right=9pt,top=7pt,bottom=7pt]
|
||||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault genkey}\\[2pt]\small Make a key. Asks for a passphrase and an optional decoy.\end{tcolorbox}
|
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault genkey}\\[2pt]\small Make a key. Asks for a passphrase and an optional decoy.\end{tcolorbox}
|
||||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault init}\\[2pt]\small Make an empty vault, with filler.\end{tcolorbox}
|
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault init}\\[2pt]\small Make an empty vault, with filler. \texttt{--filler 2G} for more.\end{tcolorbox}
|
||||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault add FILE|DIR [FOLDER]}\\[2pt]\small Encrypt in, keeping the name. Replaces an existing name.\end{tcolorbox}
|
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault add FILE|DIR [FOLDER]}\\[2pt]\small Encrypt in, keeping the name. Replaces an existing name.\end{tcolorbox}
|
||||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\end{tcolorbox}
|
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault list [FOLDER]}\\[2pt]\small Size, modified time and name, sorted.\end{tcolorbox}
|
||||||
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox}
|
\begin{tcolorbox}{\ttfamily\color{vtealdeep}\bfseries vault get NAME [-o OUT]}\\[2pt]\small Decrypt out. Never overwrites. Verifies first.\end{tcolorbox}
|
||||||
@@ -1154,6 +1231,7 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
|
|||||||
\textrm{(prompt)} & VAULT\_PASSPHRASE & asked on the terminal \\
|
\textrm{(prompt)} & VAULT\_PASSPHRASE & asked on the terminal \\
|
||||||
\textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\
|
\textrm{(genkey prompt)} & VAULT\_DECOY\_PASSPHRASE & asked; empty means none \\
|
||||||
-o OUT & \textrm{---} & the file's own name, here \\
|
-o OUT & \textrm{---} & the file's own name, here \\
|
||||||
|
--filler SIZE & \textrm{---} & a few tens of MB, for \cmd{init} \\
|
||||||
\end{vtable}
|
\end{vtable}
|
||||||
|
|
||||||
\section*{Numbers worth knowing}
|
\section*{Numbers worth knowing}
|
||||||
@@ -1162,7 +1240,9 @@ passphrase. Nothing in this chapter helps with that. Back up your key.
|
|||||||
Largest file & 1\,TiB \\
|
Largest file & 1\,TiB \\
|
||||||
Longest stored name & 1024 bytes \\
|
Longest stored name & 1024 bytes \\
|
||||||
Smallest object on disk & 64\,KiB \\
|
Smallest object on disk & 64\,KiB \\
|
||||||
Filler entries in a new vault & 8 to 32 \\
|
Largest object on disk & 4\,MiB \\
|
||||||
|
Folders under \file{records/} and \file{objects/} & 256 each \\
|
||||||
|
Filler entries in a new vault & 8 to 32, or more with \cmd{--filler} \\
|
||||||
Key file size & 319 bytes \\
|
Key file size & 319 bytes \\
|
||||||
Exit codes & 0 ok, 1 error, 2 no command \\
|
Exit codes & 0 ok, 1 error, 2 no command \\
|
||||||
\end{vtable}
|
\end{vtable}
|
||||||
@@ -1177,13 +1257,13 @@ Exit codes & 0 ok, 1 error, 2 no command \\
|
|||||||
\item[Argon2id] A deliberately slow, memory-hard function that turns your passphrase into a key, making guessing expensive.
|
\item[Argon2id] A deliberately slow, memory-hard function that turns your passphrase into a key, making guessing expensive.
|
||||||
\item[Catalogue] The encrypted list of what's in your vault, stored as one record per file.
|
\item[Catalogue] The encrypted list of what's in your vault, stored as one record per file.
|
||||||
\item[Decoy passphrase] A second passphrase that opens a separate, harmless set of files.
|
\item[Decoy passphrase] A second passphrase that opens a separate, harmless set of files.
|
||||||
\item[Entry] One record plus its object. Real, decoy and filler entries all look identical on disk.
|
\item[Entry] One record plus its objects. Real, decoy and filler entries all look identical on disk.
|
||||||
\item[Filler] Entries sealed to a key that was immediately destroyed. Nobody can open them. They make the decoy plausible.
|
\item[Filler] Entries sealed to a key that was immediately destroyed. Nobody can open them. They make the decoy plausible.
|
||||||
\item[FrodoKEM, ML-KEM] Post-quantum key-encapsulation algorithms. vault uses both at once.
|
\item[FrodoKEM, ML-KEM] Post-quantum key-encapsulation algorithms. vault uses both at once.
|
||||||
\item[Key file] The small file holding your identity, encrypted under your passphrase (and decoy).
|
\item[Key file] The small file holding your identity, encrypted under your passphrase (and decoy).
|
||||||
\item[Object] The encrypted contents of one file, padded to a power-of-two size.
|
\item[Object] One encrypted piece of a file, 4\,MiB, or padded to a smaller power of two if it's the last piece.
|
||||||
\item[Post-quantum] Designed to stay secure against attackers with large quantum computers.
|
\item[Post-quantum] Designed to stay secure against attackers with large quantum computers.
|
||||||
\item[Record] The encrypted catalogue entry for one file: its name, size, dates and object ID.
|
\item[Record] The encrypted catalogue entry for one file: its name, size, dates and the seed its objects' names come from.
|
||||||
\item[Vault] The folder where your encrypted files live.
|
\item[Vault] The folder where your encrypted files live.
|
||||||
\end{description}
|
\end{description}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/andydixon/vault/internal/term"
|
"github.com/andydixon/vault/internal/term"
|
||||||
@@ -20,7 +21,8 @@ import (
|
|||||||
|
|
||||||
const usage = `Usage:
|
const usage = `Usage:
|
||||||
vault genkey create a new passphrase-protected key
|
vault genkey create a new passphrase-protected key
|
||||||
vault init create a new, empty vault
|
vault init [--filler SIZE] create a new, empty vault, with at least
|
||||||
|
SIZE of filler (e.g. 500M, 2G)
|
||||||
vault add FILE|DIR [FOLDER] encrypt a file or directory into the vault,
|
vault add FILE|DIR [FOLDER] encrypt a file or directory into the vault,
|
||||||
optionally under a folder inside the vault
|
optionally under a folder inside the vault
|
||||||
vault list [PREFIX] list stored files
|
vault list [PREFIX] list stored files
|
||||||
@@ -62,6 +64,7 @@ func main() {
|
|||||||
keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "")
|
keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "")
|
||||||
vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "")
|
vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "")
|
||||||
output := fset.String("o", "", "")
|
output := fset.String("o", "", "")
|
||||||
|
filler := fset.String("filler", "0", "")
|
||||||
pos, err := parseInterleaved(fset, args)
|
pos, err := parseInterleaved(fset, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("%v\n\n%s", err, usage)
|
fail("%v\n\n%s", err, usage)
|
||||||
@@ -87,7 +90,9 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath)
|
fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath)
|
||||||
case "init":
|
case "init":
|
||||||
want(0, 0)
|
want(0, 0)
|
||||||
check(initStore(*vaultDir))
|
size, err := parseSize(*filler)
|
||||||
|
check(err)
|
||||||
|
check(initStore(*vaultDir, size))
|
||||||
fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir)
|
fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir)
|
||||||
case "add":
|
case "add":
|
||||||
want(1, 2)
|
want(1, 2)
|
||||||
@@ -144,6 +149,22 @@ func parseInterleaved(fset *flag.FlagSet, args []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSize reads a byte count with an optional K, M, G or T suffix, in
|
||||||
|
// powers of 1024.
|
||||||
|
func parseSize(s string) (int64, error) {
|
||||||
|
num, shift := strings.TrimSuffix(strings.ToUpper(s), "B"), 0
|
||||||
|
for i, unit := range "KMGT" {
|
||||||
|
if strings.HasSuffix(num, string(unit)) {
|
||||||
|
num, shift = strings.TrimSuffix(num, string(unit)), 10*(i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(num, 10, 64)
|
||||||
|
if err != nil || n < 0 || n > fileLimit>>shift {
|
||||||
|
return 0, fmt.Errorf("invalid size %q", s)
|
||||||
|
}
|
||||||
|
return n << shift, nil
|
||||||
|
}
|
||||||
|
|
||||||
func defaultPath(env, name string) string {
|
func defaultPath(env, name string) string {
|
||||||
if v := os.Getenv(env); v != "" {
|
if v := os.Getenv(env); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
|
mrand "math/rand/v2"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -26,31 +31,40 @@ import (
|
|||||||
|
|
||||||
// On disk a vault is:
|
// On disk a vault is:
|
||||||
//
|
//
|
||||||
// DIR/.vault marker
|
// DIR/.vault marker
|
||||||
// DIR/records/<id> one encrypted catalogue record per file
|
// DIR/records/<xx>/<id> one encrypted catalogue record per file
|
||||||
// DIR/objects/<id> one encrypted file body per record
|
// DIR/objects/<xx>/<id> the encrypted body of a file, in one or more chunks
|
||||||
//
|
//
|
||||||
// IDs are random. Records are padded to one fixed size and objects to
|
// <xx> is the first two hex digits of <id>, spreading entries over 256
|
||||||
// power-of-two buckets, so the storage reveals neither names, exact sizes nor
|
// shards. IDs look random: record IDs are, and a file's object IDs derive
|
||||||
// timestamps. Every ciphertext's mtime is pinned to the epoch.
|
// from a secret seed in its record, so nothing on disk links a file's
|
||||||
|
// chunks. Records are padded to one fixed size. A body is cut into
|
||||||
|
// chunkSize objects and the last is padded to a power-of-two bucket, so
|
||||||
|
// every object is 64 KiB to 4 MiB whatever the file size. Every ciphertext's
|
||||||
|
// and shard's mtime is pinned to the epoch.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
markerName = ".vault"
|
markerName = ".vault"
|
||||||
markerContent = "VAULT 1\n"
|
markerContent = "VAULT 2\n"
|
||||||
recordsDir = "records"
|
recordsDir = "records"
|
||||||
objectsDir = "objects"
|
objectsDir = "objects"
|
||||||
recordSize = 4096
|
recordSize = 4096
|
||||||
nameLimit = 1024
|
nameLimit = 1024
|
||||||
fileLimit = int64(1) << 40
|
fileLimit = int64(1) << 40
|
||||||
minPadding = int64(64 << 10)
|
minPadding = int64(64 << 10)
|
||||||
idSize = 16
|
chunkSize = minPadding << 6 // 4 MiB, the largest bucket
|
||||||
fillerMin = 8
|
chunkData = chunkSize - idSize
|
||||||
fillerMax = 32
|
idSize = 16
|
||||||
|
objectSeedSize = 32
|
||||||
|
fillerMin = 8
|
||||||
|
fillerMax = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var epoch = time.Unix(0, 0)
|
||||||
|
|
||||||
type record struct {
|
type record struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Object string `json:"object"`
|
Seed string `json:"seed"` // derives the file's object IDs
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Mode uint32 `json:"mode"`
|
Mode uint32 `json:"mode"`
|
||||||
Modified time.Time `json:"modified"`
|
Modified time.Time `json:"modified"`
|
||||||
@@ -65,57 +79,68 @@ type store struct {
|
|||||||
noSync bool // filler needs no per-file durability
|
noSync bool // filler needs no per-file durability
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStore(root string) error {
|
// initStore creates a vault at root holding at least filler bytes of filler.
|
||||||
|
func initStore(root string, filler int64) error {
|
||||||
if _, err := os.Stat(filepath.Join(root, markerName)); err == nil {
|
if _, err := os.Stat(filepath.Join(root, markerName)); err == nil {
|
||||||
return fmt.Errorf("%s is already a vault", root)
|
return fmt.Errorf("%s is already a vault", root)
|
||||||
}
|
}
|
||||||
for _, dir := range []string{root, filepath.Join(root, recordsDir), filepath.Join(root, objectsDir)} {
|
// Every shard exists from the start, so the tree's shape says nothing.
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
for _, dir := range []string{recordsDir, objectsDir} {
|
||||||
return err
|
for i := range 256 {
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, dir, fmt.Sprintf("%02x", i)), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writeFiller(root); err != nil {
|
if err := writeFiller(root, filler); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, dir := range []string{recordsDir, objectsDir} {
|
||||||
|
shards, _ := filepath.Glob(filepath.Join(root, dir, "??"))
|
||||||
|
for _, p := range append(shards, filepath.Join(root, dir)) {
|
||||||
|
if err := os.Chtimes(p, epoch, epoch); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600)
|
return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFiller stores a random number of record/object pairs sealed to a key
|
// writeFiller stores random entries sealed to a key that is thrown away.
|
||||||
// that is thrown away. Every vault therefore holds entries nobody can open,
|
// Every vault therefore holds entries nobody can open, so a file hidden from
|
||||||
// so a file hidden from a decoy passphrase is indistinguishable from filler.
|
// a decoy passphrase is indistinguishable from filler. Filler entries are
|
||||||
func writeFiller(root string) error {
|
// real entries, written by the same code as add, of log-uniformly random
|
||||||
|
// sizes: fillerMin to fillerMax of them, or more until they total target
|
||||||
|
// bytes.
|
||||||
|
func writeFiller(root string, target int64) error {
|
||||||
throwaway, err := pqcrypt.GeneratePQIdentity()
|
throwaway, err := pqcrypt.GeneratePQIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer throwaway.Close()
|
defer throwaway.Close()
|
||||||
s := &store{root: root, id: throwaway, noSync: true}
|
s := &store{root: root, id: throwaway, noSync: true}
|
||||||
var b [2]byte
|
// Bigger targets allow bigger files, so the filler holds multi-chunk
|
||||||
if _, err := rand.Read(b[:1]); err != nil {
|
// files in about the proportion a real collection would.
|
||||||
return err
|
upper := float64(min(max(target/8, chunkSize), 1<<30))
|
||||||
}
|
count := fillerMin + mrand.IntN(fillerMax-fillerMin+1)
|
||||||
for range fillerMin + int(b[0])%(fillerMax-fillerMin+1) {
|
for i, total := 0, int64(0); i < count || total < target; i++ {
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
size := int64(1024 * math.Exp(mrand.Float64()*math.Log(upper/1024)))
|
||||||
|
seed, err := randomHex(objectSeedSize)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Object buckets from 64 KiB to 4 MiB, weighted towards small ones.
|
if err := s.writeObjects(rand.Reader, seed, size); err != nil {
|
||||||
objSize := minPadding << (min(b[0]%8, b[1]%8) % 7)
|
return err
|
||||||
for _, part := range []struct {
|
|
||||||
dir string
|
|
||||||
size int64
|
|
||||||
}{{recordsDir, recordSize}, {objectsDir, objSize}} {
|
|
||||||
id, err := randomID()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = s.writeCiphertext(part.dir, id, func(w io.Writer) error {
|
|
||||||
_, err := io.CopyN(w, rand.Reader, part.size)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
name, err := randomHex(8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := s.writeRecord(&record{Name: name, Seed: seed, Size: size, Mode: 0o600, Modified: now, Added: now}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += size
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -128,17 +153,47 @@ func openStore(root string, id *pqcrypt.PQIdentity) (*store, error) {
|
|||||||
return &store{root: root, id: id}, nil
|
return &store{root: root, id: id}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomID() (string, error) {
|
func randomHex(n int) (string, error) {
|
||||||
b := make([]byte, idSize)
|
b := make([]byte, n)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(b), nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validID(s string) bool {
|
func validHex(s string, n int) bool {
|
||||||
b, err := hex.DecodeString(s)
|
b, err := hex.DecodeString(s)
|
||||||
return err == nil && len(b) == idSize && hex.EncodeToString(b) == s
|
return err == nil && len(b) == n && hex.EncodeToString(b) == s
|
||||||
|
}
|
||||||
|
|
||||||
|
func validID(s string) bool { return validHex(s, idSize) }
|
||||||
|
|
||||||
|
// objectID derives the ID of a file's i-th object from its record's seed.
|
||||||
|
// Without the seed the IDs are unlinkable, so a file's chunks can't be told
|
||||||
|
// apart from any other object.
|
||||||
|
func objectID(seed string, i int64) string {
|
||||||
|
key, _ := hex.DecodeString(seed)
|
||||||
|
m := hmac.New(sha256.New, key)
|
||||||
|
binary.Write(m, binary.BigEndian, i)
|
||||||
|
return hex.EncodeToString(m.Sum(nil)[:idSize])
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectCount is how many objects hold a file of size bytes.
|
||||||
|
func objectCount(size int64) int64 {
|
||||||
|
return max(1, (size+chunkData-1)/chunkData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) path(dir, id string) string {
|
||||||
|
return filepath.Join(s.root, dir, id[:2], id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeFile deletes dir/id and re-pins its shard's mtime.
|
||||||
|
func (s *store) removeFile(dir, id string) error {
|
||||||
|
p := s.path(dir, id)
|
||||||
|
if err := os.Remove(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Chtimes(filepath.Dir(p), epoch, epoch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validName(name string) bool {
|
func validName(name string) bool {
|
||||||
@@ -166,7 +221,8 @@ func paddedSize(size int64) int64 {
|
|||||||
// writeCiphertext encrypts to dir/id via a temporary file, so a partial
|
// writeCiphertext encrypts to dir/id via a temporary file, so a partial
|
||||||
// ciphertext never appears under a real ID.
|
// ciphertext never appears under a real ID.
|
||||||
func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) error {
|
func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) error {
|
||||||
dir = filepath.Join(s.root, dir)
|
dst := s.path(dir, id)
|
||||||
|
dir = filepath.Dir(dst)
|
||||||
tmp, err := os.CreateTemp(dir, ".pending-")
|
tmp, err := os.CreateTemp(dir, ".pending-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -191,11 +247,13 @@ func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) err
|
|||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
epoch := time.Unix(0, 0)
|
|
||||||
if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil {
|
if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp.Name(), filepath.Join(dir, id)); err != nil || s.noSync {
|
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chtimes(dir, epoch, epoch); err != nil || s.noSync {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return syncDir(dir)
|
return syncDir(dir)
|
||||||
@@ -224,7 +282,7 @@ func (s *store) writeRecord(r *record) error {
|
|||||||
if _, err := rand.Read(plain[4+len(data):]); err != nil {
|
if _, err := rand.Read(plain[4+len(data):]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if r.id, err = randomID(); err != nil {
|
if r.id, err = randomHex(idSize); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeCiphertext(recordsDir, r.id, func(w io.Writer) error {
|
return s.writeCiphertext(recordsDir, r.id, func(w io.Writer) error {
|
||||||
@@ -236,25 +294,34 @@ func (s *store) writeRecord(r *record) error {
|
|||||||
// catalogue decrypts every record this identity can open. Records sealed to
|
// catalogue decrypts every record this identity can open. Records sealed to
|
||||||
// a different key are skipped silently: they are simply not ours.
|
// a different key are skipped silently: they are simply not ours.
|
||||||
func (s *store) catalogue() (map[string][]*record, error) {
|
func (s *store) catalogue() (map[string][]*record, error) {
|
||||||
entries, err := os.ReadDir(filepath.Join(s.root, recordsDir))
|
shards, err := os.ReadDir(filepath.Join(s.root, recordsDir))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
byName := map[string][]*record{}
|
byName := map[string][]*record{}
|
||||||
for _, e := range entries {
|
for _, shard := range shards {
|
||||||
if !validID(e.Name()) {
|
if !shard.IsDir() {
|
||||||
continue
|
|
||||||
}
|
|
||||||
r, err := s.readRecord(e.Name())
|
|
||||||
var noMatch *pqcrypt.NoIdentityMatchError
|
|
||||||
if errors.As(err, &noMatch) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Join(s.root, recordsDir, shard.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "vault: skipping unreadable record %s: %v\n", e.Name(), err)
|
return nil, err
|
||||||
continue
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !validID(e.Name()) || e.Name()[:2] != shard.Name() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, err := s.readRecord(e.Name())
|
||||||
|
var noMatch *pqcrypt.NoIdentityMatchError
|
||||||
|
if errors.As(err, &noMatch) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "vault: skipping unreadable record %s: %v\n", e.Name(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byName[r.Name] = append(byName[r.Name], r)
|
||||||
}
|
}
|
||||||
byName[r.Name] = append(byName[r.Name], r)
|
|
||||||
}
|
}
|
||||||
// Newest first, so [0] is current. Duplicates only exist after an
|
// Newest first, so [0] is current. Duplicates only exist after an
|
||||||
// interrupted replace; the next write of that name cleans them up.
|
// interrupted replace; the next write of that name cleans them up.
|
||||||
@@ -265,7 +332,7 @@ func (s *store) catalogue() (map[string][]*record, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) readRecord(id string) (*record, error) {
|
func (s *store) readRecord(id string) (*record, error) {
|
||||||
f, err := os.Open(filepath.Join(s.root, recordsDir, id))
|
f, err := os.Open(s.path(recordsDir, id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -291,7 +358,7 @@ func (s *store) readRecord(id string) (*record, error) {
|
|||||||
if err := dec.Decode(r); err != nil {
|
if err := dec.Decode(r); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !validName(r.Name) || !validID(r.Object) || r.Size < 0 || r.Size > fileLimit {
|
if !validName(r.Name) || !validHex(r.Seed, objectSeedSize) || r.Size < 0 || r.Size > fileLimit {
|
||||||
return nil, errors.New("malformed record")
|
return nil, errors.New("malformed record")
|
||||||
}
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -315,36 +382,23 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
|
|||||||
if !info.Mode().IsRegular() || info.Size() > fileLimit {
|
if !info.Mode().IsRegular() || info.Size() > fileLimit {
|
||||||
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
|
return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src)
|
||||||
}
|
}
|
||||||
objID, err := randomID()
|
seed, err := randomHex(objectSeedSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
err = s.writeCiphertext(objectsDir, objID, func(w io.Writer) error {
|
if err := s.writeObjects(f, seed, info.Size()); err != nil {
|
||||||
// The object ID leads the plaintext so a record can only ever
|
s.dropObjects(seed, info.Size())
|
||||||
// open the object it names, not one swapped in under its ID.
|
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||||
raw, _ := hex.DecodeString(objID)
|
err = fmt.Errorf("%s changed while being added", src)
|
||||||
if _, err := w.Write(raw); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
n, err := io.Copy(w, io.LimitReader(f, info.Size()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n != info.Size() {
|
|
||||||
return fmt.Errorf("%s changed while being added", src)
|
|
||||||
}
|
|
||||||
_, err = io.CopyN(w, rand.Reader, paddedSize(idSize+n)-idSize-n)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
r := &record{
|
r := &record{
|
||||||
Name: name, Object: objID, Size: info.Size(), Mode: uint32(info.Mode().Perm()),
|
Name: name, Seed: seed, Size: info.Size(), Mode: uint32(info.Mode().Perm()),
|
||||||
Modified: info.ModTime().UTC(), Added: time.Now().UTC(),
|
Modified: info.ModTime().UTC(), Added: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
if err := s.writeRecord(r); err != nil {
|
if err := s.writeRecord(r); err != nil {
|
||||||
os.Remove(filepath.Join(s.root, objectsDir, objID))
|
s.dropObjects(seed, info.Size())
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
replaced := len(cat[name]) > 0
|
replaced := len(cat[name]) > 0
|
||||||
@@ -355,6 +409,67 @@ func (s *store) add(cat map[string][]*record, src, name string) (bool, error) {
|
|||||||
return replaced, nil
|
return replaced, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeObjects reads size bytes from f into the objects derived from seed.
|
||||||
|
// Chunks are read in order but encrypted, written and synced by a few
|
||||||
|
// workers at once, so their fsyncs overlap instead of queueing.
|
||||||
|
func (s *store) writeObjects(f io.Reader, seed string, size int64) error {
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
firstErr error
|
||||||
|
)
|
||||||
|
fail := func(err error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failed := func() bool {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return firstErr != nil
|
||||||
|
}
|
||||||
|
slots := make(chan struct{}, 8) // at most 8 chunks, 32 MiB, in memory
|
||||||
|
remaining := size
|
||||||
|
for i := range objectCount(size) {
|
||||||
|
if failed() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// The object ID leads the plaintext so a record can only ever
|
||||||
|
// open the objects it derives, each in its place, not one
|
||||||
|
// swapped in under its ID.
|
||||||
|
id := objectID(seed, i)
|
||||||
|
part := min(remaining, chunkData)
|
||||||
|
remaining -= part
|
||||||
|
slots <- struct{}{}
|
||||||
|
buf := make([]byte, idSize+part)
|
||||||
|
hex.Decode(buf, []byte(id))
|
||||||
|
if _, err := io.ReadFull(f, buf[idSize:]); err != nil {
|
||||||
|
<-slots
|
||||||
|
fail(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-slots }()
|
||||||
|
err := s.writeCiphertext(objectsDir, id, func(w io.Writer) error {
|
||||||
|
if _, err := w.Write(buf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := io.CopyN(w, rand.Reader, paddedSize(int64(len(buf)))-int64(len(buf)))
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
// remove deletes every record for name and, when dropObjects is set, any of
|
// remove deletes every record for name and, when dropObjects is set, any of
|
||||||
// their objects no other record still uses. Records go first, so an
|
// their objects no other record still uses. Records go first, so an
|
||||||
// interruption only ever leaves an orphaned object.
|
// interruption only ever leaves an orphaned object.
|
||||||
@@ -363,16 +478,16 @@ func (s *store) remove(cat map[string][]*record, name string, dropObjects bool)
|
|||||||
for n, rs := range cat {
|
for n, rs := range cat {
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
if n != name {
|
if n != name {
|
||||||
inUse[r.Object] = true
|
inUse[r.Seed] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, r := range cat[name] {
|
for _, r := range cat[name] {
|
||||||
if err := os.Remove(filepath.Join(s.root, recordsDir, r.id)); err != nil {
|
if err := s.removeFile(recordsDir, r.id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if dropObjects && !inUse[r.Object] {
|
if dropObjects && !inUse[r.Seed] {
|
||||||
if err := os.Remove(filepath.Join(s.root, objectsDir, r.Object)); err != nil && !os.IsNotExist(err) {
|
if err := s.dropObjects(r.Seed, r.Size); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,6 +495,17 @@ func (s *store) remove(cat map[string][]*record, name string, dropObjects bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dropObjects deletes the objects holding a file of size bytes, skipping any
|
||||||
|
// already gone.
|
||||||
|
func (s *store) dropObjects(seed string, size int64) error {
|
||||||
|
for i := range objectCount(size) {
|
||||||
|
if err := s.removeFile(objectsDir, objectID(seed, i)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) lookup(name string) (map[string][]*record, *record, error) {
|
func (s *store) lookup(name string) (map[string][]*record, *record, error) {
|
||||||
cat, err := s.catalogue()
|
cat, err := s.catalogue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -419,7 +545,7 @@ func (s *store) rename(oldName, newName string) error {
|
|||||||
return s.remove(cat, oldName, false)
|
return s.remove(cat, oldName, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// get decrypts name to out. Nothing appears at out until the entire object,
|
// get decrypts name to out. Nothing appears at out until every object,
|
||||||
// padding included, has authenticated.
|
// padding included, has authenticated.
|
||||||
func (s *store) get(name, out string) error {
|
func (s *store) get(name, out string) error {
|
||||||
_, r, err := s.lookup(name)
|
_, r, err := s.lookup(name)
|
||||||
@@ -429,34 +555,19 @@ func (s *store) get(name, out string) error {
|
|||||||
if _, err := os.Lstat(out); err == nil {
|
if _, err := os.Lstat(out); err == nil {
|
||||||
return fmt.Errorf("%s already exists", out)
|
return fmt.Errorf("%s already exists", out)
|
||||||
}
|
}
|
||||||
f, err := os.Open(filepath.Join(s.root, objectsDir, r.Object))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
pr, err := pqcrypt.Decrypt(f, s.id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
head := make([]byte, idSize)
|
|
||||||
if _, err := io.ReadFull(pr, head); err != nil || hex.EncodeToString(head) != r.Object {
|
|
||||||
return errors.New("object does not match its catalogue record")
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(out), ".vault-get-")
|
tmp, err := os.CreateTemp(filepath.Dir(out), ".vault-get-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.Remove(tmp.Name())
|
defer os.Remove(tmp.Name())
|
||||||
defer tmp.Close()
|
defer tmp.Close()
|
||||||
if _, err := io.CopyN(tmp, pr, r.Size); err != nil {
|
remaining := r.Size
|
||||||
return err
|
for i := range objectCount(r.Size) {
|
||||||
}
|
part := min(remaining, chunkData)
|
||||||
pad, err := io.Copy(io.Discard, pr)
|
if err := s.readObject(tmp, objectID(r.Seed, i), part); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
remaining -= part
|
||||||
if idSize+r.Size+pad != paddedSize(idSize+r.Size) {
|
|
||||||
return errors.New("object size does not match its catalogue record")
|
|
||||||
}
|
}
|
||||||
if err := tmp.Chmod(os.FileMode(r.Mode) & 0o777); err != nil {
|
if err := tmp.Chmod(os.FileMode(r.Mode) & 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -472,3 +583,32 @@ func (s *store) get(name, out string) error {
|
|||||||
}
|
}
|
||||||
return os.Rename(tmp.Name(), out)
|
return os.Rename(tmp.Name(), out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readObject decrypts object id to w, checking it leads with its own ID and
|
||||||
|
// holds part bytes padded to their bucket.
|
||||||
|
func (s *store) readObject(w io.Writer, id string, part int64) error {
|
||||||
|
f, err := os.Open(s.path(objectsDir, id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
pr, err := pqcrypt.Decrypt(f, s.id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
head := make([]byte, idSize)
|
||||||
|
if _, err := io.ReadFull(pr, head); err != nil || hex.EncodeToString(head) != id {
|
||||||
|
return errors.New("object does not match its catalogue record")
|
||||||
|
}
|
||||||
|
if _, err := io.CopyN(w, pr, part); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pad, err := io.Copy(io.Discard, pr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if idSize+part+pad != paddedSize(idSize+part) {
|
||||||
|
return errors.New("object size does not match its catalogue record")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -46,7 +48,7 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
|
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := initStore(filepath.Join(dir, "store")); err != nil {
|
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := newTestStore(t, keyPath, "pass")
|
s := newTestStore(t, keyPath, "pass")
|
||||||
@@ -84,9 +86,8 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
// Swapping one object for another must be detected.
|
// Swapping one object for another must be detected.
|
||||||
cat, _ = s.catalogue()
|
cat, _ = s.catalogue()
|
||||||
d, c := cat["d"][0], cat["c"][0]
|
d, c := cat["d"][0], cat["c"][0]
|
||||||
objects := filepath.Join(s.root, objectsDir)
|
data, _ := os.ReadFile(s.path(objectsDir, objectID(c.Seed, 0)))
|
||||||
data, _ := os.ReadFile(filepath.Join(objects, c.Object))
|
os.WriteFile(s.path(objectsDir, objectID(d.Seed, 0)), data, 0o600)
|
||||||
os.WriteFile(filepath.Join(objects, d.Object), data, 0o600)
|
|
||||||
if err := s.get("d", filepath.Join(dir, "swapped")); err == nil {
|
if err := s.get("d", filepath.Join(dir, "swapped")); err == nil {
|
||||||
t.Fatal("swapped object was accepted")
|
t.Fatal("swapped object was accepted")
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ func TestDecoy(t *testing.T) {
|
|||||||
if err := writeKeyFile(keyPath, "real", "decoy"); err != nil {
|
if err := writeKeyFile(keyPath, "real", "decoy"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := initStore(filepath.Join(dir, "store")); err != nil {
|
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
src := filepath.Join(dir, "in")
|
src := filepath.Join(dir, "in")
|
||||||
@@ -128,9 +129,96 @@ func TestDecoy(t *testing.T) {
|
|||||||
if _, err := unlockKeyFile(keyPath, "neither"); err == nil {
|
if _, err := unlockKeyFile(keyPath, "neither"); err == nil {
|
||||||
t.Fatal("wrong passphrase unlocked the key")
|
t.Fatal("wrong passphrase unlocked the key")
|
||||||
}
|
}
|
||||||
entries, _ := os.ReadDir(filepath.Join(dir, "store", recordsDir))
|
if n := len(ciphertexts(t, filepath.Join(dir, "store", recordsDir))); n < fillerMin+2 {
|
||||||
if len(entries) < fillerMin+2 {
|
t.Fatalf("only %d records; filler missing", n)
|
||||||
t.Fatalf("only %d records; filler missing", len(entries))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ciphertexts maps every file under dir to its size.
|
||||||
|
func ciphertexts(t *testing.T, dir string) map[string]int64 {
|
||||||
|
t.Helper()
|
||||||
|
sizes := map[string]int64{}
|
||||||
|
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
sizes[p] = info.Size()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChunks checks a file bigger than one chunk becomes unlinkable 4 MiB
|
||||||
|
// objects plus a bucket-sized tail, all in their shards, and that reordering
|
||||||
|
// the chunks is caught.
|
||||||
|
func TestChunks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(dir, "key")
|
||||||
|
if err := writeKeyFile(keyPath, "pass", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := initStore(filepath.Join(dir, "store"), 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := newTestStore(t, keyPath, "pass")
|
||||||
|
objects := filepath.Join(s.root, objectsDir)
|
||||||
|
before := ciphertexts(t, objects)
|
||||||
|
|
||||||
|
src := filepath.Join(dir, "in")
|
||||||
|
want := make([]byte, 2*chunkData+100)
|
||||||
|
rand.Read(want)
|
||||||
|
os.WriteFile(src, want, 0o600)
|
||||||
|
cat, _ := s.catalogue()
|
||||||
|
if _, err := s.add(cat, src, "big"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cat, _ = s.catalogue()
|
||||||
|
r := cat["big"][0]
|
||||||
|
var sizes []int64
|
||||||
|
for i := range objectCount(r.Size) {
|
||||||
|
p := s.path(objectsDir, objectID(r.Seed, i))
|
||||||
|
if filepath.Base(filepath.Dir(p)) != filepath.Base(p)[:2] {
|
||||||
|
t.Fatalf("%s is in the wrong shard", p)
|
||||||
|
}
|
||||||
|
sizes = append(sizes, ciphertexts(t, p)[p])
|
||||||
|
}
|
||||||
|
if len(sizes) != 3 || sizes[0] != sizes[1] || sizes[2] >= sizes[0] {
|
||||||
|
t.Fatalf("object sizes = %v, want two full chunks and a smaller tail", sizes)
|
||||||
|
}
|
||||||
|
if n := len(ciphertexts(t, objects)) - len(before); n != 3 {
|
||||||
|
t.Fatalf("add wrote %d objects, want 3", n)
|
||||||
|
}
|
||||||
|
for p, size := range ciphertexts(t, objects) {
|
||||||
|
if size > sizes[0] {
|
||||||
|
t.Fatalf("%s is %d bytes, larger than a chunk", p, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := filepath.Join(dir, "out")
|
||||||
|
if err := s.get("big", out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(out); !bytes.Equal(got, want) {
|
||||||
|
t.Fatal("round trip mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
a, b := s.path(objectsDir, objectID(r.Seed, 0)), s.path(objectsDir, objectID(r.Seed, 1))
|
||||||
|
os.Rename(a, a+".x")
|
||||||
|
os.Rename(b, a)
|
||||||
|
os.Rename(a+".x", b)
|
||||||
|
if err := s.get("big", filepath.Join(dir, "swapped")); err == nil {
|
||||||
|
t.Fatal("reordered chunks were accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.delete("big"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(ciphertexts(t, objects)); n != len(before) {
|
||||||
|
t.Fatalf("%d objects after rm, want %d", n, len(before))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +263,35 @@ func TestLockExcludesOtherProcess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSize(t *testing.T) {
|
||||||
|
for in, want := range map[string]int64{"0": 0, "512": 512, "2k": 2 << 10, "500M": 500 << 20, "2G": 2 << 30, "1TB": 1 << 40} {
|
||||||
|
if got, err := parseSize(in); err != nil || got != want {
|
||||||
|
t.Errorf("parseSize(%q) = %d, %v; want %d", in, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range []string{"", "G", "-1", "2X", "1.5G", "2T"} {
|
||||||
|
if _, err := parseSize(in); err == nil {
|
||||||
|
t.Errorf("parseSize(%q) accepted", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFiller checks a filler target is met with entries shaped like real
|
||||||
|
// files: fixed-size records and objects no bigger than a chunk.
|
||||||
|
func TestFiller(t *testing.T) {
|
||||||
|
root := filepath.Join(t.TempDir(), "store")
|
||||||
|
if err := initStore(root, 24<<20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n := len(ciphertexts(t, filepath.Join(root, recordsDir))); n < fillerMin {
|
||||||
|
t.Fatalf("%d filler records, want at least %d", n, fillerMin)
|
||||||
|
}
|
||||||
|
var total, largest int64
|
||||||
|
for _, size := range ciphertexts(t, filepath.Join(root, objectsDir)) {
|
||||||
|
total, largest = total+size, max(largest, size)
|
||||||
|
}
|
||||||
|
if total < 24<<20 || largest > chunkSize+chunkSize/64 {
|
||||||
|
t.Fatalf("filler objects total %d, largest %d", total, largest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||