Creating a detailed workflow of how SSH works with OpenPubkey is an excellent idea! I hope transform my answer here into documentation.
If you really want to deep dive into how this works we have a fairly easy to read code example of SSH using OpenPubkey and the PR that I wrote, which adds SSH provides additional diagrams and documentation. If you come across any bugs or want to add any improvements, we welcome all contributors. Feel free to open a PR.
Creating PK Token
In your "OPK client calls OPK SSH client executable" steps, you are missing the final step the OPK Client performs which is:
- After the OPK Client receives the ID Token, the OPK Client uses Alice key to sign the ID Token and adds this additional signature to the ID Token. We call this ID Token with Alice's signature: the PK Token.
ID Tokens are JWS (JSON Web Signatures). JWS can have more than one signature/protected header. An ID Token in JWS format looks like:
ID Token
{"payload":
{"sub":"1234...", "iss":"https://accounts.google.com", "email": "[email protected]",
"nonce": Hash(upk=alice-pubkey, alg=ES256, rz=crypto.Rand(), typ="CIC")},
"signatures": [
{
"protected": {"typ": "JWT", "alg": "RS256", "kid": "abcd...", "typ": "JWT"},
"signature": SIGN(google-signkey, (payload, signatures[0].protected))
}
]}
We simply add Alice's signature on the payload and Alice's protected header to the ID Token, creating a PK Token as shown:
{"payload":
{"sub":"1234...", "iss":"https://accounts.google.com", "email": "[email protected]",
"nonce": Hash(upk=alice-pubkey, alg=ES256, rz=crypto.Rand(), typ="CIC")},
"signatures": [
{
"protected": {"typ": "JWT", "alg": "RS256", "kid": "abcd...", "typ": "JWT"},
"signature": SIGN(google-signkey, (payload, signatures[0].protected))
},
{"protected": {"upk": alice-pubkey, "alg": "EC256", "rz": crypto.Rand(), "typ": "CIC"},
"signature": <SIGN(alice-signkey, (payload, signatures[1].protected))>
},
]}
This is important for two reasons:
- The value in the nonce is the hash of Alice's protected header. That means anyone with the PK Token can determine Alice's public key without any additional information.
- Alice's signature functions as a Proof of Possession, preventing rogue key attacks that have plagued similar systems.
Creating OpenPubkey SSH Public Key/SSH Certificate
Your steps for "OPK Client creates SSH certificate" are mostly correct, but there are some missing pieces. Let me rewrite those steps:
- OPK Client creates SSH certificate:
- Puts
alice-pubkey
in pubkey
field
- Puts user's email address (from ID Token/PK Token payload) in
keyId
field
- Puts PK Token in certificate extension field named
openpubkey-pkt
- OPK Client signs SSH certificate with Alice's signing key (a.k.a. private key)
- OPK Client saves the SSH certificate as a default SSH key in the default SSH directory as either ~/.ssh/id_ecdsa or ~/.ssh/id_dsa. This works because SSH sees SSH certificates as a type of SSH key.
The code for this is simple:
SshCert: &ssh.Certificate{
Key: pubkeySsh,
CertType: ssh.UserCert,
KeyId: claims.Email,
ValidPrincipals: principals,
ValidBefore: ssh.CertTimeInfinity,
Permissions: ssh.Permissions{
Extensions: map[string]string{
"permit-X11-forwarding": "",
"permit-agent-forwarding": "",
"permit-port-forwarding": "",
"permit-pty": "",
"permit-user-rc": "",
"openpubkey-pkt": pktB64,
},
},
},
Additional notes
Includes signature (signed SSH certificate fields, signed by alice-privkey
) => is this correct?
Yes, this is needed to prove the user actually created the ssh certificate and didn't just plug someone else's PK Token into the SSH certificate. The security impact of not signing the SSH certificate would be small; the SSH connection would still fail when the user attempts to perform an SSH handshake without the correct public key. Better to fail as early as possible and head off any shenanigans at the pass.
For the purposes of OpenPubkey, we just want AuthorizedKeyCommand to be able to say if the Alice's SSH public key (which happens to be an SSH certificate) is valid or invalid. The semantics of AuthorizedKeyCommand require that we can't just return true, but instead we are required to return a public key. The way our AuthorizedKeyCommand verifier signals to the ssh-server that Alice's key is valid, is that it returns the public key which Alice's key was signed under. In our currently design this happens to be the user's public key, but we could have build it so that it is just a public value or a CA.
For instance if you already have a CA that issues SSH Certificates, you can just use that instead of Alice's key and then OpenPubkey certificates would be backwards compatible with your existing ssh configurations. It allows you to do partial deployments. See our code which is called by AuthorizedKeyCommand
// This function is called by the SSH server as the authorizedKeysCommand:
//
// The following lines are added to /etc/ssh/sshd_config:
//
// AuthorizedKeysCommand /etc/opk/opkssh ver %u %k %t
// AuthorizedKeysCommandUser root
//
// The parameters specified in the config map the parameters sent to the function below.
// We prepend "Arg" to specify which ones are arguments sent by sshd. They are:
//
// %u The username (requested principal) - userArg
// %t The public key type - typArg - in this case a certificate being used as a public key
// %k The base64-encoded public key for authentication - certB64Arg - the public key is also a certificate
func authorizedKeysCommand(userArg string, typArg string, certB64Arg string, policyEnforcer policyCheck, op client.OpenIdProvider) (string, error) {
cert, err := sshcert.NewFromAuthorizedKey(typArg, certB64Arg)
if err != nil {
return "", err
}
if pkt, err := cert.VerifySshPktCert(op); err != nil {
return "", err
} else if err := policyEnforcer(userArg, pkt); err != nil {
return "", err
} else {
// sshd expects the public key in the cert, not the cert itself.
// This public key is key of the CA the signs the cert, in our
// setting there is no CA.
pubkeyBytes := ssh.MarshalAuthorizedKey(cert.SshCert.SignatureKey)
return "cert-authority " + string(pubkeyBytes), nil
}
}
OpenPubkey just uses plain SSH (no code changes, two lines of config in sshd_config)
Regarding the OPK SSH server, what you wrote is technically accurate but I worry calling it a "server" might confuse a reader less familiar with OpenPubkey.
The SSH server is just a standard OpenSSH server configured to work with OpenPubkey PK Tokens. The SSH client is just the SSH client you have installed. opk-ssh
just creates an SSH key pair with a PK Token. What you are calling the OPK SSH server is just a very simple command called by that actual SSH server. It isn't a server.
[8]: func (v *DefaultProviderVerifier) verifyCommitment(pkt *pktoken.PKToken) error {