Skip to content

vault: support SSH ed25519 keys as identities and recipients #170

@xaaha

Description

@xaaha

Summary

Support SSH ed25519 keys as both identities (private keys for decryption) and recipients (public keys for encryption). age natively supports this; hulak just needs to surface it.

The big UX win: team onboarding via GitHub. Everyone has already published their SSH public key at https://github.com/<username>.keys, so adding a teammate becomes a one-liner — no "send me your hulak public key" exchange.

Why

For recipients (public keys)

  • age accepts ssh-ed25519 AAAAC3Nz... lines anywhere it accepts age1... lines
  • recipients.txt already supports both formats once age's ParseRecipients is wired up — it auto-detects per line
  • GitHub publishes SSH public keys at https://github.com/<username>.keys (plain text, one key per line, served unauthenticated)
  • Onboarding via GitHub:
    curl -s https://github.com/alice.keys | hulak env add-recipient --stdin --name Alice
    # ✓ Added Alice (4 ssh-ed25519 keys) to .hulak/recipients.txt
    # ✓ Re-encrypted store.age for 4 recipients
  • Works with any git-hosting that publishes keys (GitLab: https://gitlab.com/<user>.keys, Codeberg, Forgejo, etc.)

For identities (private keys)

  • ~/.ssh/id_ed25519 can double as a hulak identity — users don't generate a separate keypair
  • Resolves the "where's my private key?" question for anyone already using SSH for git
  • The corresponding public key (~/.ssh/id_ed25519.pub) is already on GitHub, so they're discoverable

Caveats

  • ed25519 only as a first-class citizen. age also supports ssh-rsa, but warns about lower security margin. Hulak should accept ssh-rsa with a deprecation notice, not reject it outright (some teams still have RSA-only keys).
  • Passphrase-protected SSH keys. If ~/.ssh/id_ed25519 is passphrase-protected, age prompts for the passphrase on every decrypt. age doesn't currently talk to ssh-agent, so the passphrase is requested fresh each time — painful for hulak run in a loop. Workarounds:
    • Use a separate unprotected age identity for hulak (back to age1...)
    • Cache the unlocked private key in memory for the duration of a hulak invocation
    • Wait for upstream age to add ssh-agent support
  • Mixed recipients in one file = fine. ssh-ed25519 and age1... lines can coexist in recipients.txt. age handles both transparently.
  • Mixed identities = not a thing. A user has one identity at a time. The fallback chain is: HULAK_MASTER_KEY env var → ~/.config/hulak/identity.txt~/.ssh/id_ed25519. The last one is opt-in.

Commands

Identity fallback to SSH key

hulak run requests/x.yaml --env prod
# (no ~/.config/hulak/identity.txt, falls back to ~/.ssh/id_ed25519)
# ✓ Using SSH identity ~/.ssh/id_ed25519

A new flag --ssh-identity <path> lets users point at a non-default key:

hulak run requests/x.yaml --env prod --ssh-identity ~/.ssh/work_ed25519

Or set HULAK_SSH_IDENTITY=~/.ssh/work_ed25519 for persistent use.

add-recipient --stdin accepts both formats

# age public key
echo "age1ql3z..." | hulak env add-recipient --stdin --name Alice

# SSH ed25519 public key
echo "ssh-ed25519 AAAAC3Nz..." | hulak env add-recipient --stdin --name Alice

# Multiple keys at once (e.g. all of someone's GitHub SSH keys)
curl -s https://github.com/alice.keys | hulak env add-recipient --stdin --name Alice

add-recipient --github <username> shorthand

For the common case, ship a built-in shortcut:

hulak env add-recipient --github alice --name Alice
# ✓ Fetched 4 ssh-ed25519 keys from https://github.com/alice.keys
# ✓ Added Alice (4 keys) to .hulak/recipients.txt
# ✓ Re-encrypted store.age for 4 recipients

This is sugar over the curl pipe — same path internally. Worth its own flag because GitHub onboarding is THE flagship use case.

If alice.keys returns ssh-rsa entries:

⚠ Skipped 1 ssh-rsa key (lower security margin). Use ed25519 keys for new keypairs.
✓ Added Alice (3 ssh-ed25519 keys) to .hulak/recipients.txt

Format detection

Per-line in recipients.txt:

Prefix Type Status
age1 age X25519 recipient Canonical
ssh-ed25519 SSH ed25519 recipient Supported
ssh-rsa SSH RSA recipient Accepted with deprecation warning
ecdsa-sha2-* SSH ECDSA recipient Rejected (age doesn't support)
anything else Reject with "unrecognized recipient format"

age.ParseRecipients already handles ed25519 and rsa. We just need to:

  1. Validate per-line BEFORE handing to age, so error messages are clear
  2. Surface the rsa deprecation as a hulak-level warning (age itself logs to stderr but it's noisy)

Files to Create/Change

File Change
pkg/vault/recipients.go Detect format per line; warn on ssh-rsa; reject ecdsa
pkg/vault/keys.go Identity resolution chain falls back to ~/.ssh/id_ed25519
pkg/userFlags/env.go add-recipient --github <user> shortcut; add-recipient --stdin accepts SSH formats
pkg/userFlags/runner.go --ssh-identity <path> flag (or accept HULAK_SSH_IDENTITY env var)

Tests

  • recipients.txt with mixed age1... + ssh-ed25519 lines decrypts/encrypts correctly
  • add-recipient --github <user> fetches keys, filters non-ssh, adds the rest
  • add-recipient --stdin with multi-line input adds all keys with shared --name
  • ssh-rsa recipient produces a warning but is added
  • ecdsa-sha2-nistp256 recipient is rejected with a clear error
  • Identity fallback: no identity.txt + ~/.ssh/id_ed25519 exists → uses SSH key
  • --ssh-identity <path> overrides default location
  • Passphrase-protected SSH key prompts on first decrypt (interactive test gated behind -tags=integration)
  • https://github.com/<unknown>.keys returns 404 → friendly error
  • GitHub returns empty body → "no keys found for user, ask them to upload an ed25519 key"

Security considerations

  • Don't auto-fetch from GitHub without --github flag. The user explicitly opts in. We don't want hulak quietly making network calls.
  • Validate fetched content before writing. A misbehaving github.com mirror or DNS hijack shouldn't be able to inject a recipient into the team's vault.
  • Store the GitHub username in the comment, not just the key:
    # Alice (github:alice, added 2026-04-25)
    ssh-ed25519 AAAAC3...
    
    Future-you can re-fetch and verify.
  • Optional: support --keyserver <url> for self-hosted GitLab / Forgejo without hardcoding github.com.

Depends on

Blocks

Nothing — this is purely additive UX.

Out of scope

  • ssh-agent integration for passphrase-cached private keys (upstream age limitation)
  • ECDSA SSH keys (age doesn't support)
  • GPG/PGP keys (different ecosystem; age explicitly avoids GPG complexity)

References

Additional cases to cover (review pass)

  • TLS verification on the GitHub fetch: HTTPS only, default cert validation, no insecure-skip option exposed.
  • Redirect policy: follow at most 1 redirect, refuse protocol downgrades (HTTPS → HTTP).
  • Timeout: 5s default for the fetch. No retries — failures are user-reproducible and the user can re-run.
  • User-Agent header: send hulak/<version> so GitHub's abuse heuristics don't ban the source IP.
  • --keyserver <url> format: pin the URL template — <keyserver>/{username}.keys, with {username} as the literal placeholder. Document with one example (e.g. --keyserver https://gitlab.com resolves to https://gitlab.com/alice.keys).
  • GitHub user has only RSA/DSA keys, no ed25519: skip-with-warning, "no ed25519 keys found for <user>; ask them to add one." Do not silently add the RSA keys; require explicit --allow-rsa flag to opt in.
  • GitHub username later renamed: stored comment becomes stale. Mitigate by storing the fetch date in the comment header (# Alice (github:alice, fetched 2026-04-26)); the username link is informational only.
  • Idempotency on --github re-run: dedupe by key string. Re-running fetches the latest keys, adds new ones, removes none (removal stays explicit via remove-recipient).
  • Empty response body / 404: friendly "no keys published for <user>" error.
  • HULAK_MASTER_KEY interaction with --ssh-identity: if both are set, master key wins (env-var > flag, by analogy with hulak env import-key/export-key + HULAK_MASTER_KEY fallback #131 precedence). Document.
  • SSH key without OpenSSH armor (-----BEGIN OPENSSH PRIVATE KEY-----): if user points --ssh-identity at a raw blob, fail with a clear "expected an OpenSSH-formatted private key" message rather than a confusing parse error.
  • Passphrase prompt timing: every decrypt re-prompts (no agent). Make sure hulak run in a directory loop doesn't issue 50 prompts in 30 seconds — recommend HULAK_MASTER_KEY for those flows or cache identity for the duration of one invocation.

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