age encryption and ssh signing, the pair that makes a vault readable without a person present.
GPG fuses the two: --sign -e produces one blob and the signature is read back off the status stream while decrypting. age does not sign at all, so moving to it is a change of trust model rather than a substitution.
The model that replaces it is the one the manifest already implies. Every entry carries a SHA-256 of its blob, and unseal and get check it before decrypting anything. Authenticity therefore only has to be established once, over the manifest; the hashes inside it cover the blobs. That makes a detached signature over the manifest sufficient, and per-blob signatures redundant.
Signing uses ssh-keygen -Y, which verifies against an allowed-signers file and needs no agent when the key has no passphrase. That is what a timer at 04:00 requires: GPG's alternative is an agent whose cache expires, which fails silently on some later morning.
--- detached signing over the manifest ---Procs
proc ageBinary(): string {....raises: [OSError, NimvaultError], tags: [ReadDirEffect, ReadEnvEffect, ReadIOEffect], forbids: [].}
- Resolve an age implementation once, so a missing binary is reported as itself rather than as a decryption failure further down.
proc ageDecrypt(cfg: GpgConfig; inPath, outPath: string) {. ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadEnvEffect, ReadIOEffect, ReadDirEffect, ExecIOEffect, RootEffect, TimeEffect, WriteIOEffect], forbids: [].}
proc ageDecryptToString(cfg: GpgConfig; inPath: string): string {. ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadEnvEffect, ReadIOEffect, ReadDirEffect, ExecIOEffect, RootEffect, TimeEffect, WriteIOEffect], forbids: [].}
proc ageEncrypt(cfg: GpgConfig; inPath, outPath: string) {. ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ExecIOEffect, ReadEnvEffect, RootEffect, ReadDirEffect, ReadIOEffect, TimeEffect, WriteIOEffect], forbids: [].}
- Encrypt to the configured recipient. No signature is produced here; the manifest signature covers this blob through its recorded hash.
proc ageIdentityPath(cfg: GpgConfig): string {....raises: [NimvaultError], tags: [ReadEnvEffect, ReadIOEffect, ReadDirEffect], forbids: [].}
- The identity is what makes decryption unattended, so its absence is worth a specific error: without it every read fails with an opaque age message.
proc sshSign(cfg: GpgConfig; path: string) {. ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadEnvEffect, ReadIOEffect, ReadDirEffect, WriteDirEffect, ExecIOEffect, RootEffect, WriteIOEffect, TimeEffect], forbids: [].}
- Write <path>.sig. The namespace is fixed so a signature made for this tool cannot be replayed as one made for git or anything else using the same key.
proc sshVerify(cfg: GpgConfig; path: string) {. ...raises: [NimvaultError, OSError, IOError, ValueError], tags: [ReadDirEffect, ReadEnvEffect, ReadIOEffect, ExecIOEffect, RootEffect, WriteIOEffect, TimeEffect], forbids: [].}
- Verify <path>.sig against the allowed-signers file. A missing signature is a failure, not a skip: an unsigned manifest is exactly what an attacker who replaced it would produce.