Skip to content

hulak env import-key/export-key + HULAK_MASTER_KEY fallback #131

@xaaha

Description

@xaaha

Summary

Identity portability across machines and CI: hulak env export-key, hulak env import-key, plus a friendly error when HULAK_MASTER_KEY doesn't match.

Goal

Users need a way to back up their age private key and restore it on another machine. They also need a clear error when the wrong key is provided in CI.

The key lives at ~/.config/hulak/identity.txt (file mode 0600). These commands read and write that file safely.

Commands

  • hulak env export-key — prints the private key to stdout with a one-line warning that this is the master key
  • hulak env export-key --out <path> — writes the key to a file with mode 0600 instead of stdout
  • hulak env import-key <path> — reads a key from a file and writes it to the identity location
  • hulak env import-key --stdin — reads a key from standard input (for piping out of a password manager)
  • hulak env import-key ... --force — overwrites an existing identity file (default: refuse)

Cases to cover

  • Export with no identity file → friendly error pointing to hulak init
  • Export to stdout always prints the "treat like a password" warning, even in non-TTY mode (don't suppress it)
  • Export to --out writes mode 0600 regardless of the umask
  • Import validates the key with age.ParseX25519Identity before writing — malformed input never lands on disk
  • Import refuses to overwrite an existing identity unless --force is passed
  • Import normalizes whitespace (strips trailing newlines, ignores leading whitespace)
  • Import sets file mode 0600 on the destination regardless of source file permissions
  • --stdin can read multiple lines but only uses the first non-empty, non-comment line as the key

HULAK_MASTER_KEY error UX (no command, just a wrapper)

When HULAK_MASTER_KEY is set and decrypt fails with "no identity matched any of the recipients", wrap it in a friendlier message:

  • Wraps the raw age error so users don't have to know age internals
  • Suggests common causes: the key is for a different project, you were removed as a recipient, or stray whitespace from a copy/paste
  • Wraps age.ParseX25519Identity parse errors with a hint to use the AGE-SECRET-KEY-1... format

This applies wherever the identity is loaded — hulak run, hulak env *, etc.

Cases to cover (HULAK_MASTER_KEY UX)

  • Key set but doesn't match any recipient → friendly error, not raw age string
  • Key has stray whitespace/quotes → friendly error suggesting verbatim paste
  • Key in a clearly wrong format (e.g., age1... public key by mistake) → "this looks like a public key, not a private key"
  • Key empty → "HULAK_MASTER_KEY is set but empty"
  • No HULAK_MASTER_KEY and no identity file → original "no identity found" error stays as is

Depends on

Blocks

Nothing.

Additional cases to cover (review pass)

  • Atomicity on import: write to identity.txt.tmp then rename, so an interrupted write can never corrupt an existing identity file. Validation runs first; only well-formed input ever reaches the rename.
  • --out <path> collision policy: refuse if the destination file already exists; require --force to overwrite. Default is non-destructive. Mirror this with import-key.
  • --force scope: applies only to the "destination already exists" guard. Never bypasses key parsing/validation — malformed input must still be rejected even with --force.
  • Identity precedence on import: if HULAK_MASTER_KEY is set in the environment, import-key should refuse with a clear error. Importing while a master-key env var shadows the on-disk identity makes the import dead-on-arrival.
  • Export works outside a project: identity is global, not project-scoped. hulak env export-key from any directory still prints the key with the standard warning. Don't gate on project root detection.
  • HULAK_MASTER_KEY is a valid age key for a different project (parses fine, just doesn't decrypt this store): same friendly "no identity matched any of the recipients" wrapping, but mention "this key may be for a different project, or you were removed as a recipient."

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepic: encryptionIssues that belong to the encryption epic

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions