Skip to content

login: expire certificate with token exp and load it into ssh-agent - #606

Draft
sini wants to merge 3 commits into
openpubkey:mainfrom
sini:feat/cert-expiry-and-agent
Draft

login: expire certificate with token exp and load it into ssh-agent#606
sini wants to merge 3 commits into
openpubkey:mainfrom
sini:feat/cert-expiry-and-agent

Conversation

@sini

@sini sini commented Aug 14, 2026

Copy link
Copy Markdown

Summary

Two related changes so an opkssh identity expires coherently, and so the freshly minted certificate is usable without cluttering ~/.ssh.

1. Bind the certificate's validity to the ID Token (sshcert)

sshcert.New currently sets ValidBefore: ssh.CertTimeInfinity, so every opkssh certificate is Valid: forever. The certificate carries a PK token whose authorization is time-bounded (the OP's exp), yet the certificate itself never expires. This change sets ValidBefore to the ID Token's exp claim, so the certificate cannot outlive the token it embeds (defense in depth: a leaked private key + certificate is useless once the token expires, independent of the verifier). Falls back to CertTimeInfinity if the OP omits exp; ValidAfter is left unset to avoid clock-skew rejections.

This also fixes real-world interop: stricter SSH certificate parsers reject valid_before = u64::MAX. For example RustCrypto ssh-key < 0.7 errors with invalid time (RustCrypto/SSH#504), which makes ssh-agent-mux (and anything built on that library) unable to list an agent holding an opkssh cert (overhacked/ssh-agent-mux#56).

2. Load the certificate into ssh-agent on login (login)

When SSH_AUTH_SOCK is set, opkssh login now also adds the certificate and its private key to the running agent, with LifetimeSecs bounded by the token exp, so the agent drops the key exactly when the certificate expires. Best-effort and non-fatal — the keys are still written to disk as today. This revives the intent of #96 on current main, driven off exp rather than a hardcoded lifetime.

Fixes #6. Relates to #96, #56.

Notes

Draft: opening for discussion of the approach (in particular whether the agent-add should be gated behind an explicit flag rather than SSH_AUTH_SOCK presence, and whether to keep writing key files when the agent is used). Unit tests pass (go test ./sshcert/...); the commands integration tests that shell out to sshd were not run in my environment.

@sini
sini force-pushed the feat/cert-expiry-and-agent branch from c401cdd to de3ac7b Compare August 14, 2026 02:44
Two related changes so an opkssh identity expires coherently:

sshcert: set the certificate's ValidBefore to the ID Token's `exp` claim
instead of ssh.CertTimeInfinity. A certificate that carries a token's
authorization should not outlive that token, and a `Valid: forever` cert is
rejected by stricter SSH certificate parsers (e.g. RustCrypto ssh-key < 0.7
errors with "invalid time" on valid_before = u64::MAX). Falls back to
CertTimeInfinity when the OP omits exp.

login: when SSH_AUTH_SOCK is set, additionally load the certificate and its
private key into the ssh-agent with a lifetime bounded by the token's exp, so
the agent drops the key when the certificate expires. This revives the intent
of openpubkey#96 on current main. It is best-effort and non-fatal; the keys are still
written to disk as before.
@EthanHeilman

EthanHeilman commented Aug 17, 2026

Copy link
Copy Markdown
Member

@sini Let me know when this is ready for review.

The certificate carries a PK token whose authorization is time-bounded (the OP's exp), yet the certificate itself never expires. This change sets ValidBefore to the ID Token's exp claim, so the certificate cannot outlive the token it embeds (defense in depth: a leaked private key + certificate is useless once the token expires, independent of the verifier).

OPKSSH does not use the ID Token's expiration claim as the expiration claim for the PK Token. Thus the party that creates the SSH cert does not know what the expiration time will be and can't set it for the SSH cert.

OPKSSH expiry and Token expiry are not the same. They originally were the same, but the number one complaint we got from nearly everyone that use OPKSSH was that SSH keys expired too quickly. This is because OPs set very very low exp times (between 30 seconds to 20 minutes). This means that someone would need to run OPKSSH login every time they SSHed. I ran into this issue myself and was running OPKSSH login like 20 times a day. As a result OPKSSH expiry is set from the iat (issued_at) claim.

The default value is iat + 24 hours, but it is configurable on the OPKSSH server. See https://github.com/openpubkey/opkssh/blob/main/docs/config.md#allowed-openid-providers-etcopkproviders-linux-or-programdataopkproviders-windows

See discussion in this issue here.

This change sets ValidBefore to the ID Token's exp claim, so the certificate cannot outlive the token it embeds (defense in depth: a leaked private key + certificate is useless once the token expires, independent of the verifier).

The SSH certificate here is simply a way to package the PK Token and are self-signed. Thus an attacker with a leaked private key + certificate could simply resign the SSH certificate with expiry they choose. OPKSSH is secure because we check the PK Token inside the certificate using AuthorizedKeysCommand. Without an AuthorizedKeysCommand, OpenSSHd would immediately reject the SSH certificate since it is self-signed. So this wouldn't get us any security.

This also fixes real-world interop: stricter SSH certificate parsers reject valid_before = u64::MAX. For example RustCrypto ssh-key < 0.7 errors with invalid time (RustCrypto/SSH#504), which makes ssh-agent-mux (and anything built on that library) unable to list an agent holding an opkssh cert (overhacked/ssh-agent-mux#56).

Well that sucks. Rust libraries shouldn't place additional restrictions on certificates. Maybe just set the opkssh to (u64::MAX-1) then or something like iat+1 week, which is the longest exp period we support.

When SSH_AUTH_SOCK is set, opkssh login now also adds the certificate and its private key to the running agent, with LifetimeSecs bounded by the token exp, so the agent drops the key exactly when the certificate expires.

One way to get this to work is to continuously refresh the ID Token with opkssh login --auto-refresh. Right now the way this works isn't great as it just runs as a process. You probably want something like ssh-agent that calls ssh-agent, maybe building off of https://github.com/indigo-dc/oidc-agent

What do you think the right approach here is, given the issues with exp and refreshing?

@sini

sini commented Aug 17, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed context. The RustCrypto tolerance fix is on a RC branch, so perhaps the correct move is to just wait for that to land (for my use-case) and do nothing. I'm not sure that (u64::MAX-1) is a proper solution, it feels 1000% like a hack -- which is exactly the kind of solution I was looking to avoid. :)

I'll give the issue thread you linked a deeper read and update the PR after giving it some thought.

@sini

sini commented Aug 17, 2026

Copy link
Copy Markdown
Author

@EthanHeilman Do you have a recommendation for how you would like to see this PR evolve?

Let's say I take this PR and focus solely on the ssh-agent lifecycle aspect of it -- having the CLI auto-register to the local agent with a timeout so keys don't just accumulate indefinitely. In that scenario the expiration time is ideally equal to the certificates validity time. Does this become a discovery problem between client <=> server configuration in your mind?

@EthanHeilman

Copy link
Copy Markdown
Member

In that scenario the expiration time is ideally equal to the certificates validity time. Does this become a discovery problem between client <=> server configuration in your mind?

Yeah, that is the main issue. We need two expiry times, agent_expiry (key lifetime) and server_expiry, and what happens when they don't line up.

| Default | Min | Max | |
| :--- | :--- | :--- |
| 24 hours | 8 hours | 1 week |

If we set agent_expiry = 1 week AND IF server_expiry 24 hours will sometimes send an expired SSH certificate from the agent. By default OpenSSHd has MaxAuthTries=6 so if you have six expired certs and 1 good cert, and it may fail if SSH-agent chooses to send those 6 expired certs first. Given that ssh-agent provides keys as FIFO, it will almost certainly provide all the expired certs first.

If we set agent_expiry = 24 hours AND IF server_expiry <1 week it will never send an expired SSH certificate, however SSH certificates will expire faster than the server intends.

  • Can we set key lifetime to 24 hours in SSH-agent without setting SSH certificate expiry? This would mean only the SSH-agent OPKSSH certificates would have expiry shrunk to 24 hours.
  • If we can't do this, then probably best to create an SSH-agent certificate with an expiry set to 24 hours and an non-SSH-agent certificate with no expiry set.

We should stage this as two PRs. The first PR adds SSH-agent support via this 24 hour lifetime mechanism. The second PR adds full refresh-agent support and then sets SSH-certificate expiry = ID Token.exp if refreshing is enabled.

What do you think about this plan?

@sini

sini commented Aug 18, 2026

Copy link
Copy Markdown
Author

Yeah, I can split this and stack the delivery for ease of review.

Can we set key lifetime to 24 hours in SSH-agent without setting SSH certificate expiry? This would mean only the SSH-agent OPKSSH certificates would have expiry shrunk to 24 hours.

Yes, that's entirely possible -- but if the cert is good for 1 week, why would we expire it at 24 hours? Likewise, if the cert is only good for 4-8 hours, why would we persist it for 24 hours? So I think this should be configurable with a matching default.

So, for the split:

PR 1: using LifetimeSecs in ssh-agent set to a safe default (24h, matching the default server policy) and introducing a client config/flag (--lifetime / agent_lifetime in ~/.opk/config.yml) seems like the cleanest path forward without causing unnecessary key accumulation.

PR 2: once background token refreshing is in place, we can dynamically manage key replacement and sync ValidBefore / agent_lifetime with the refreshed token exp.

@EthanHeilman

Copy link
Copy Markdown
Member

Yes, that's entirely possible -- but if the cert is good for 1 week, why would we expire it at 24 hours? Likewise, if the cert is only good for 4-8 hours, why would we persist it for 24 hours? So I think this should be configurable with a matching default.

The OPKSSH server computes expiration using a configuration setting (8hour, 24hour, 1week) from on the ID Token's iat (Issued At) claim. So the client, and thus SSH-Agent, doesn't actually know what the real expiration is and has to guess.

SSH-agent, AFAICT, doesn't have a delete-key/replace-key function. If it did, we could just use that. Since it doesn't we absolutely have to set a lifetime on keys we add to prevent it from filling up with expired keys.

The split sounds good.

PR 2: once background token refreshing is in place,

Only thing to add is that we currently have background token refreshing is in place, it just needs to be improved. Would you be up for building the improved token refreshing in PR2?

@sini

sini commented Aug 18, 2026

Copy link
Copy Markdown
Author

Would you be up for building the improved token refreshing in PR2?

Yep, just give me your expectations/guidelines for it and I'll happily volunteer my time. :)

@sini

sini commented Aug 18, 2026

Copy link
Copy Markdown
Author

SSH-agent, AFAICT, doesn't have a delete-key/replace-key function.

Apparently, golang.org/x/crypto/ssh/agent actually does have a Remove(key ssh.PublicKey) method. We could call agent.List() to find existing OPKSSH certs and agent.Remove(cert) to delete them, which would give us full key removal capabilities in ssh-agent.


I use ssh-agent-mux to proxy bitwarden backed keys (rbw), gpg-agent (my yubikey), and standalone ssh-agent (opkssh key + a separate LLM agent git signing key). If we're building a persistent daemon it might be worth giving it full ssh-agent capabilities (optionally).

@EthanHeilman

Copy link
Copy Markdown
Member

Yep, just give me your expectations/guidelines for it and I'll happily volunteer my time. :)

Current it is just a process that run refreshes the token and writes a new SSH key. This is bad for four reasons:

  1. It isn't a background process, so that terminal is just stuck running that autorefresh. If you do opkssh login --auto-refresh it just blocks. You can background it, but it is writing to stdout and stderr.
  2. If you close the process or the terminal in which it is running, it breaks. Then 40 minutes later when you go to open a new ssh connection, it doesn't work. This makes opkssh login feel unreliable.
  3. If you have two of these autorefresh processes running, they may overwrite each other.

Instead the refresh-agent should run like ssh-agent or oidc-agent. A single instance spins up as a daemon listening on a socket, talking to ssh-agent. Since it is a single instance it can deconflict refreshes. opkssh can query its current status.

Stretch goal: OpenID Key Binding refresh support: OpenID Key Binding refresh flow signs a random challenge to prove possession of the key. If this signature can be requested from ssh-agent, that is fantastic because then the refresh-agent doesn't need to protect the private key. If the refresh-agent does need to do the signing then we probably want similar isolation as exists in ssh-agent. This doesn't need to be done in PR2, but it is worth thinking about when planning PR2 so that PR2 can work with future a key-binding refresh-agent PR.

@sini

sini commented Aug 18, 2026

Copy link
Copy Markdown
Author

@EthanHeilman How does this draft spec look to you? https://gist.github.com/sini/b645242d94b5174e306e4ab54674bf63

@EthanHeilman

Copy link
Copy Markdown
Member

@sini That looks great! Using the signer in that way can help us plug into HSMs.

@sini

sini commented Aug 18, 2026

Copy link
Copy Markdown
Author

Alright. I've got another project currently in flight but once I hit a stopping point on it I'll do a more concrete implementation plan and build it. Full disclosure ahead of time, I'm going to use Claude Opus 5 for the largely mechanical parts + the Windows integration components since that's not really my wheelhouse so please be extra critical of that. The actual important security related parts/architecture will be all me.

@sini

sini commented Aug 20, 2026

Copy link
Copy Markdown
Author

@EthanHeilman Alright, I found a flaw with my original draft design where I incorrectly assumed the refresh token would be recoverable. I've restructured the responsibilities and flow to ensure the responsibility now lives within the daemon. Here's my new refined design spec:
https://gist.github.com/sini/83f4ea5255d37b096a2b6c4031e5146a

And here's a rough implementation ordering plan:
https://gist.github.com/sini/327c41331c5af65ba66a6a21f082c25a

As a predicate, I'd like to propose refactoring the storage layout for existing config + keys. For keys that means id_ecdsa => id_ecdsa_<provider> and for config that means $XDG_CONFIG_HOME/opk, $XDG_STATE_HOME/opk, ~/.opk fallback. For backwards compat I'd propose doing the same thing firefox/mozilla does; if ~/.mozilla exists, continue to use it -- for new installs use the XDG path. Only the key-provider change is required for the daemon design; but if I'm using XDG_RUNTIME_DIR for the socket I think it'd be nice to fix the other paths to also follow conventions.

Update:

Here's a spec for the proposed file changes: https://gist.github.com/sini/c32d1b73adbb56f1bdbbe620b10dc80f

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Key management improvements on the client and SSH agent support

2 participants