You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 keyecho"age1ql3z..."| hulak env add-recipient --stdin --name Alice
# SSH ed25519 public keyecho"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:
Validate per-line BEFORE handing to age, so error messages are clear
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
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.
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.
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)
ssh-ed25519 AAAAC3Nz...lines anywhere it acceptsage1...linesrecipients.txtalready supports both formats once age'sParseRecipientsis wired up — it auto-detects per linehttps://github.com/<username>.keys(plain text, one key per line, served unauthenticated)https://gitlab.com/<user>.keys, Codeberg, Forgejo, etc.)For identities (private keys)
~/.ssh/id_ed25519can double as a hulak identity — users don't generate a separate keypair~/.ssh/id_ed25519.pub) is already on GitHub, so they're discoverableCaveats
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).~/.ssh/id_ed25519is passphrase-protected, age prompts for the passphrase on every decrypt. age doesn't currently talk tossh-agent, so the passphrase is requested fresh each time — painful forhulak runin a loop. Workarounds:age1...)ssh-agentsupportssh-ed25519andage1...lines can coexist inrecipients.txt. age handles both transparently.HULAK_MASTER_KEYenv var →~/.config/hulak/identity.txt→~/.ssh/id_ed25519. The last one is opt-in.Commands
Identity fallback to SSH key
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_ed25519Or set
HULAK_SSH_IDENTITY=~/.ssh/work_ed25519for persistent use.add-recipient --stdinaccepts both formatsadd-recipient --github <username>shorthandFor the common case, ship a built-in shortcut:
This is sugar over the curl pipe — same path internally. Worth its own flag because GitHub onboarding is THE flagship use case.
If
alice.keysreturns ssh-rsa entries:Format detection
Per-line in
recipients.txt:age1ssh-ed25519ssh-rsaecdsa-sha2-*age.ParseRecipientsalready handles ed25519 and rsa. We just need to:Files to Create/Change
pkg/vault/recipients.gopkg/vault/keys.go~/.ssh/id_ed25519pkg/userFlags/env.goadd-recipient --github <user>shortcut;add-recipient --stdinaccepts SSH formatspkg/userFlags/runner.go--ssh-identity <path>flag (or acceptHULAK_SSH_IDENTITYenv var)Tests
recipients.txtwith mixedage1...+ssh-ed25519lines decrypts/encrypts correctlyadd-recipient --github <user>fetches keys, filters non-ssh, adds the restadd-recipient --stdinwith multi-line input adds all keys with shared--namessh-rsarecipient produces a warning but is addedecdsa-sha2-nistp256recipient is rejected with a clear erroridentity.txt+~/.ssh/id_ed25519exists → uses SSH key--ssh-identity <path>overrides default location-tags=integration)https://github.com/<unknown>.keysreturns 404 → friendly errorSecurity considerations
--githubflag. The user explicitly opts in. We don't want hulak quietly making network calls.github.commirror or DNS hijack shouldn't be able to inject a recipient into the team's vault.--keyserver <url>for self-hosted GitLab / Forgejo without hardcoding github.com.Depends on
age.ParseRecipientswhich already handles SSH)Blocks
Nothing — this is purely additive UX.
Out of scope
ssh-agentintegration for passphrase-cached private keys (upstream age limitation)References
Additional cases to cover (review pass)
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.comresolves tohttps://gitlab.com/alice.keys).<user>; ask them to add one." Do not silently add the RSA keys; require explicit--allow-rsaflag to opt in.# Alice (github:alice, fetched 2026-04-26)); the username link is informational only.--githubre-run: dedupe by key string. Re-running fetches the latest keys, adds new ones, removes none (removal stays explicit viaremove-recipient).<user>" error.--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.-----BEGIN OPENSSH PRIVATE KEY-----): if user points--ssh-identityat a raw blob, fail with a clear "expected an OpenSSH-formatted private key" message rather than a confusing parse error.hulak runin a directory loop doesn't issue 50 prompts in 30 seconds — recommendHULAK_MASTER_KEYfor those flows or cache identity for the duration of one invocation.