nimvault/dek

Per-file data keys, and the payload encryption that uses them (format v6).

Until v6 each blob was encrypted straight to the recipient. Both backends do
envelope encryption internally -- GPG draws a session key per file, age a
file key -- but that wrapping lives *inside* the blob, so the recipient is
baked into every payload and changing or adding one rewrites the whole vault.
For a vault of a few hundred entries that is a full re-encrypt to let a
second machine read, and a diff nobody can review.

v6 hoists the key out of the payload. Each blob is encrypted under its own
random data key; the data keys live together in one small file encrypted to
the recipients. Adding a machine or rotating a recipient key then rewrites
that one file and leaves every payload alone.

What that gives, precisely: rotation of the *wrapping*. A data key that has
already leaked stays useful to whoever holds it, because the old ciphertext
is unchanged -- recovering from that needs the payload re-encrypted, which is
what `rotate --rekey` is for. The distinction between cheap rewrapping and
real re-encryption is the subject of Everspaugh et al., CRYPTO 2017
(doi:10.1007/978-3-319-63697-9_4); updatable encryption exists to narrow the
gap (Lehmann and Tackmann, EUROCRYPT 2018,
doi:10.1007/978-3-319-78372-7_22). Conflating the two is how a tool claims a
compromise was handled when it was not.

Per backend the "data key" is whatever opens a payload non-interactively:

| Backend | Data key | Payload |
| gpg | 256-bit passphrase, hex | `gpg --symmetric` |
| age | x25519 identity | `age -r <recipient>` |

Both are ordinary documented modes of the backend already in use, so v6 adds
no new dependency and no hand-rolled primitive.

Types

DekTable = Table[string, string]
entry id -> data key

Consts

KeysFileStem = "keys"

Procs

proc decryptWithDek(cfg: GpgConfig; dek, inPath, outPath: string) {.
    ...raises: [OSError, IOError, NimvaultError, ValueError], tags: [ReadEnvEffect,
    ReadDirEffect, ReadIOEffect, WriteDirEffect, WriteIOEffect, ExecIOEffect,
    RootEffect, TimeEffect], forbids: [].}
Decrypt a v6 payload with its data key.
proc encryptWithDek(cfg: GpgConfig; dek, inPath, outPath: string) {.
    ...raises: [OSError, IOError, NimvaultError, ValueError, Exception], tags: [
    WriteDirEffect, ReadDirEffect, ExecIOEffect, ReadEnvEffect, RootEffect,
    ReadIOEffect, WriteIOEffect, TimeEffect], forbids: [].}
Encrypt a payload under its data key. No recipient is involved, which is the whole point. Write beside the live blob and rename so a crash does not truncate the last sealed copy.
proc findKeysFile(repo: string; cfg: GpgConfig): string {....raises: [],
    tags: [ReadDirEffect], forbids: [].}
Read whichever backend wrote it, so a vault mid-migration still opens.
proc groupId(recips: seq[string]): string {....raises: [], tags: [], forbids: [].}
proc groupKeysPath(repo: string; cfg: GpgConfig; gid: string): string {.
    ...raises: [], tags: [], forbids: [].}
proc keyFiles(repo: string; cfg: GpgConfig): seq[string] {....raises: [OSError],
    tags: [ReadDirEffect], forbids: [].}
Every data-key file present, grouped or legacy flat, either backend.
proc keysPath(repo: string; cfg: GpgConfig): string {....raises: [], tags: [],
    forbids: [].}
proc loadDeks(repo: string; cfg: GpgConfig): DekTable {....raises: [OSError], tags: [
    ReadDirEffect, ReadEnvEffect, ReadIOEffect, ExecIOEffect, RootEffect,
    TimeEffect, WriteIOEffect], forbids: [].}

Merge every data-key this machine can actually open.

Per-entry records under .vault/e/ are the mergeable source. Grouped keys.* files still fill ids that have no record yet (a v6 vault mid-migration). A file that will not decrypt is not an error: it belongs to a group this machine is not in.

proc newDek(cfg: GpgConfig): string {....raises: [OSError, IOError, NimvaultError,
    ValueError], tags: [ExecIOEffect, ReadEnvEffect, RootEffect, ReadDirEffect,
                        ReadIOEffect, TimeEffect, WriteIOEffect], forbids: [].}
Fresh data key for one entry.
proc normalizedRecipients(recips: seq[string]): seq[string] {....raises: [],
    tags: [], forbids: [].}
Sorted and deduplicated, so the name of a group depends on the set and not on how it was written.
proc recipientsFor(cfg: GpgConfig; path: string): seq[string] {.
    ...raises: [RegexError], tags: [], forbids: [].}
Recipients for one entry: the first matching wrap rule wins, and the configured recipient is the fallback. Order in the file is the precedence, so the specific rules go above the general one.
proc saveDeksGrouped(repo: string; cfg: GpgConfig; deks: DekTable;
                     groupOf: Table[string, string];
                     recipsOf: Table[string, seq[string]]) {....raises: [OSError,
    IOError, NimvaultError, OSError, IOError, ValueError, Exception], tags: [
    ReadEnvEffect, ReadDirEffect, ReadIOEffect, WriteDirEffect, WriteIOEffect,
    ExecIOEffect, RootEffect, TimeEffect], forbids: [].}

One key file per recipient group, each encrypted to that group only.

Written atomically and flushed, because a key file is as load bearing as the manifest: losing it loses every payload it names. Groups that no longer hold any entry have their file removed rather than left behind still able to open blobs.