nimvault/crypto

Search:
Group by:

Backend dispatch: route encryption and decryption to gpg or age.

Kept in its own module so neither backend has to know the other exists, and so the choice is made in exactly one place rather than at each call site.

Types

Decrypted = tuple[plain, err: string]
One file's plaintext, or the message decryptToString would have raised.

Procs

proc blobExt(cfg: GpgConfig): string {....raises: [], tags: [], forbids: [].}
Blobs and the manifest carry the extension of the primitive that made them. Giving both backends the same name would make a vault that cannot be read look like a vault that is empty, which is the worst of the available failure modes: silent and reassuring.
proc blobPath(repo: string; cfg: GpgConfig; id: string): string {....raises: [],
    tags: [], forbids: [].}
Where a blob lives for the configured backend.
proc decryptFile(cfg: GpgConfig; inPath, outPath: string; verifySig = false) {.
    ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadEnvEffect,
    ReadIOEffect, ReadDirEffect, ExecIOEffect, RootEffect, TimeEffect,
    WriteIOEffect], forbids: [].}
age carries no signature, so verifySig has nothing to check per blob. That is not a weakening: the manifest records each blob's SHA-256 and the callers verify it before decrypting, with the manifest itself signed.
proc decryptManyToString(cfg: GpgConfig; paths: seq[string]; verifySig = false): seq[
    Decrypted] {....raises: [OSError, IOError, ValueError], tags: [ReadEnvEffect,
    ReadIOEffect, ReadDirEffect, ExecIOEffect, RootEffect, TimeEffect,
    WriteIOEffect], forbids: [].}
decryptToString over many small files, in the same order, with no raise: each failure comes back as its message. gpg runs gpgParallelism() processes at a time; a vault of a few hundred split records otherwise pays one process start and one agent round trip per record, in series, on every command that loads the manifest. age stays sequential, since its decrypt is in-process fast.
proc decryptProcess(cfg: GpgConfig; inPath, outPath: string): Process {.
    ...raises: [OSError, IOError, NimvaultError], tags: [ExecIOEffect,
    ReadEnvEffect, RootEffect, ReadDirEffect, ReadIOEffect], forbids: [].}
Spawn a decryption of inPath to outPath. Both backends decrypt as a subprocess with the same shape, which is what lets the batching in unseal and status stay backend-agnostic.
proc decryptToString(cfg: GpgConfig; inPath: string; verifySig = false): string {.
    ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadEnvEffect,
    ReadIOEffect, ReadDirEffect, ExecIOEffect, RootEffect, TimeEffect,
    WriteIOEffect], forbids: [].}
proc effectiveSigner(cfg: GpgConfig): string {....raises: [], tags: [], forbids: [].}
An empty signer means the config was built without going through initGpgConfig, which library callers and tests legitimately do. Treat it as the long-standing behaviour rather than as an error: a vault that worked before this dispatch existed must keep working.
proc encryptFile(cfg: GpgConfig; inPath, outPath: string) {.
    ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ExecIOEffect,
    ReadEnvEffect, RootEffect, ReadDirEffect, ReadIOEffect, TimeEffect,
    WriteIOEffect], forbids: [].}
proc encryptFileTo(cfg: GpgConfig; recipients: seq[string];
                   inPath, outPath: string; sign = true) {.
    ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ExecIOEffect,
    ReadEnvEffect, RootEffect, ReadDirEffect, ReadIOEffect, TimeEffect,
    WriteIOEffect], forbids: [].}

Encrypt to several recipients at once. Both backends take repeated recipient flags and wrap the payload key to each, which is what lets one key file be readable by exactly the machines a group names.

sign = false for material every recipient must be able to read on its own. gpg exits non-zero when it cannot verify a signature, even though it decrypted the content perfectly well, so a signed file is unreadable to any machine that does not also hold the signer's public key. Demanding that every machine import every other machine's key in order to read is the wrong coupling, and it buys nothing here: the manifest is the trust root, it is signed, and it records the digest of every blob. A tampered key file yields a decryption failure, not a forged plaintext.

proc encryptProcess(cfg: GpgConfig; inPath, outPath: string): Process {.
    ...raises: [OSError, IOError, NimvaultError], tags: [ExecIOEffect,
    ReadEnvEffect, RootEffect, ReadDirEffect, ReadIOEffect], forbids: [].}
proc ensureVaultDir(repo: string) {....raises: [OSError, IOError],
                                    tags: [ReadDirEffect, WriteDirEffect],
                                    forbids: [].}
.vault/ holds ciphertext, but a crash can leave a plaintext sidecar in it. Mode 0700 means another uid on the box cannot scoop that file.
proc findBlob(repo: string; cfg: GpgConfig; id: string): string {....raises: [],
    tags: [ReadDirEffect], forbids: [].}
Locate a blob whichever backend wrote it. Reading falls back to the other extension so a vault mid-migration still opens; writing always uses the configured one.
proc findManifest(repo: string; cfg: GpgConfig): string {.
    ...raises: [NimvaultError], tags: [ReadDirEffect], forbids: [].}
Locate an existing manifest whichever backend wrote it, and refuse to treat a manifest this configuration cannot read as an absent one.
proc manifestPath(repo: string; cfg: GpgConfig): string {....raises: [], tags: [],
    forbids: [].}
Path the configured backend would write. Use findManifest to read.
proc privateWorkDir(): string {....raises: [OSError, IOError], tags: [
    ReadEnvEffect, ReadDirEffect, ReadIOEffect, WriteDirEffect], forbids: [].}
Owner-only directory for plaintext and data keys that must not land in .vault/ or under a shared /tmp name. Prefer $XDG_RUNTIME_DIR so the bytes die with the login; fall back to a 0700 directory named by uid under the process temp area.
proc signaturesInBand(cfg: GpgConfig): bool {....raises: [], tags: [], forbids: [].}

Whether decrypting a blob also proves who wrote it.

gpg reports GOODSIG/BADSIG on its status stream while decrypting, so the per-blob check is free. age produces no such thing, and callers that insist on GOODSIG would reject every age blob. For those vaults the equivalent guarantee is assembled differently: the manifest is signed, and it records a digest of each blob that unseal and get verify before decrypting. Skipping the in-band check is therefore not a relaxation, it is the same property established one level up.

proc signManifest(cfg: GpgConfig; path: string) {.
    ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadEnvEffect,
    ReadIOEffect, ReadDirEffect, WriteDirEffect, ExecIOEffect, RootEffect,
    WriteIOEffect, TimeEffect], forbids: [].}
gpg signs while encrypting, so it needs nothing here. age does not sign at all, which is why an age vault defaults to an ssh signer.
proc syncParentDir(path: string) {....raises: [], tags: [], forbids: [].}
Persist the directory entry a rename just created.
proc syncPath(path: string) {....raises: [], tags: [], forbids: [].}

Flush path to stable storage. Best effort: a file system that refuses the sync is not a reason to fail a command that otherwise succeeded.

Rename is atomic against a concurrent reader, which is a different property from surviving a crash: the rename can reach disk before the bytes it points at, leaving a manifest that is present and empty. The orderings real file systems guarantee here are narrower than the idiom suggests (Bornholt et al., ASPLOS 2016, doi:10.1145/2872362.2872406; Chidambaram et al., SOSP 2013, doi:10.1145/2517349.2522726), so the durability is made explicit rather than assumed.

proc usesAge(cfg: GpgConfig): bool {....raises: [], tags: [], forbids: [].}
proc verifyManifest(cfg: GpgConfig; path: string; requireSig: bool) {.
    ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadDirEffect,
    ReadEnvEffect, ReadIOEffect, ExecIOEffect, RootEffect, WriteIOEffect,
    TimeEffect], forbids: [].}

Templates

template withPrivateUmask(body: untyped)
Decrypt/encrypt sidecars inherit the process umask. Force 077 so a typical 022 does not leave a world-readable plaintext next to the destination for the length of the batch.