FiloSottile/age · error
mismatched private and public SSH key
Error message
mismatched private and public SSH key
What it means
This error is returned by age's SSH identity Unwrap when decrypting an age-encrypted file with an SSH private key. After successfully decrypting the file key, the library compares the public key derived from the provided private key against the public key recorded in the age header stanza. A mismatch means the private key supplied does not correspond to the SSH public key the file was encrypted to, so the decrypted key cannot be trusted and is discarded.
Source
Thrown at agessh/encrypted_keys.go:132
case *ed25519.PrivateKey:
decrypted, err = NewEd25519Identity(*k)
pubKey = k.Public().(ed25519.PublicKey)
// ParseRawPrivateKey returns inconsistent types. See Issue 429.
case ed25519.PrivateKey:
decrypted, err = NewEd25519Identity(k)
pubKey = k.Public().(ed25519.PublicKey)
case *rsa.PrivateKey:
decrypted, err = NewRSAIdentity(k)
pubKey = &k.PublicKey
default:
return nil, fmt.Errorf("unexpected SSH key type: %T", k)
}
if err != nil {
return nil, fmt.Errorf("invalid SSH key: %v", err)
}
if exp := i.pubKey.(ssh.CryptoPublicKey).CryptoPublicKey(); !pubKey.Equal(exp) {
return nil, fmt.Errorf("mismatched private and public SSH key")
}
i.decrypted = decrypted
return i.decrypted.Unwrap(stanzas)
}
View on GitHub (pinned to b74dce4cdb)
Solutions
- Locate the SSH public key recorded in the age file header stanza and supply the matching private key file to age.Decrypt
- Verify the pairing locally: run ssh-keygen -y -f <private-key-file> and compare output against the public key used for encryption
- List all candidate private keys (ssh.ParseRawPrivateKey on each) and pass them all in the []age.Identity slice so the matching one is found
- If the key was rotated, restore the original private key from backup or have the sender re-encrypt the file to the current public key
Example fix
// before
identity, _ := ssh.ParseRawPrivateKey(pemBytes)
recipient, _ := agessh.NewRSARecipient(identity.(*rsa.PublicKey))
// decrypting a file encrypted to a different key fails:
// "mismatched private and public SSH key"
// after
// pass the identity matching the public key in the file header
identity, err := ssh.ParseRawPrivateKey(correctPemBytes)
if err != nil { log.Fatal(err) }
i, _ := agessh.NewRSAIdentity(identity.(*rsa.PrivateKey))
out, err := age.Decrypt(ciphertext, []age.Identity{i}) Defensive patterns
Strategy: validation
Validate before calling
func validateSSHKeyPair(priv interface{ Public() crypto.PublicKey }) error {
pub, ok := priv.(ssh.CryptoPublicKey)
if !ok {
return errors.New("key has no public part")
}
if pub.CryptoPublicKey() == nil {
return errors.New("empty public key")
}
return nil
} Type guard
func isSSHPrivateKey(k interface{ Public() crypto.PublicKey }) bool {
_, ok := k.(ssh.CryptoPublicKey)
return ok
} Prevention
- Pass every available SSH private key as an []age.Identity to age.Decrypt instead of guessing which one matches
- Verify key pairing with ssh-keygen -y -f <privkey> before encrypting/decrypting workflows
- Keep a stable mapping between public keys used for encryption and the private key files that hold them
- Rotate keys by re-encrypting files, never by overwriting private key files in place
When it happens
Trigger: Calling age.Decrypt with an ssh.ParseRawPrivateKey-parsed private key whose derived public key (via CryptoPublicKey) does not equal the pubKey stored in the encrypted-SSH stanza; typically the wrong identity file was passed to the identity list.
Common situations: A user has multiple SSH keys and passes the wrong one (e.g. id_ed25519 instead of the id_rsa that was used to encrypt); key files were rotated or regenerated on the same filename; a deploy pipeline points at a default ~/.ssh/id_* key that differs from the intended one; the age file was encrypted to a teammate's public key.
Related errors
- SSH public key is not an RSA key
- unexpected public key type
- pk does not implement ssh.CryptoPublicKey
- RSA key size is too small
- SSH public key is not an Ed25519 key
AI-assisted analysis of FiloSottile/age@b74dce4cdb (2026-08-31).
Data as JSON: /api/errors/8eb30c39754ce7d1.
Report an issue: GitHub.