Not long ago, when I was building melt, I learned something interesting: if you restore a private key from its seed, and marshal it back to the OpenSSH Private Key format, you’ll always get a different block in the middle.
Why?
That lead to an investigation of how the private key format works. I didn’t find many good references out there, except OpenSSH’s source code.
Let’s start from there, shall we?
We can see in the function sshkey_private_to_blob2
in sshkey.c
,
there’s this interesting piece of code:
/* Random check bytes */
check = arc4random();
if ((r = sshbuf_put_u32(encrypted, check)) != 0 ||
(r = sshbuf_put_u32(encrypted, check)) != 0)
goto out;
We see there that it creates what seems to be a random uint32
, and then calls
sshbuf_put_u32
two times, adding it to encrypted
and expecting it all to
succeed.
Interesting… why?
The best clue after that lies in the
PROTOCOL.key
file:
uint32 checkint
uint32 checkint
byte[] privatekey1
string comment1
checkint
… same name… the answer must be here, right?
Going further:
Before the key is encrypted, a random integer is assigned to both
checkint
fields so successful decryption can be quickly checked by verifying that bothcheckint
fields hold the same value.
Aha! So that’s why it’s always different! The checkints
are used to check
that decryption succeeded. When decrypting the key, we don’t know their value,
just that they should be equal.
Cool!
What about Go?
When we started this story, I mentioned I was working on melt, which is written in Go. So far, we’ve looked in to C code, but what about Go?
Turns out there’s an
unresolved merge request
adding SSH key marshaling into Go’s crypto/ssh
package.
We can find the same checks
there (called Check1
and Check2
) but the
code might be a bit easier to read:
// Random check bytes.
var check uint32
if err := binary.Read(rand.Reader, binary.BigEndian, &check); err != nil {
return nil, err
}
pk1.Check1 = check
pk1.Check2 = check
P.S. If you want to use this in your Go program, I’m keeping a repository with these changes.
Playground
We learned that the Private Key file, in the OpenSSH format, will always be a bit different, even if it’s generated with the same parameters. But… is it still the same key? What happens now?
We can verify that using melt!
Let’s create a new key to play with. You can do so with:
ssh-keygen -t ed25519 -f post
And then run melt
to get a mnemonic:
melt ./post >seed
For what its worth, here is the mnemonic for the key I created:
obey axis lecture satoshi deal comic first unfold bomb control attitude lawsuit
this brown often fault myself rabbit assume miss modify riot around punch
Now, let’s restore it a couple of times:
melt restore ./post1 <seed
melt restore ./post2 <seed
Now, let’s check a couple of things, starting with the check sum of the private keys:
sha256sum post post1 post2
a9a08d6ae71412e0397e1c76d9300002d0cb69e484823dd684d217ee07f32081 post
ec7f45126a4bf96a913b66079c1e1773ca809c6b4a653885a1c49e08d2b4d978 post1
cab1849c9560b6705a335192bcb3991ae2ba8ac479d51659e2325aeeb3ab2476 post2
All different…which is expected, due to the checkint
we just learned about.
Let’s now check the public keys:
sha256sum post*.pub
562de9510ca7278f3284f9f0114e8dc757b557c92f0b1744514c42eb9c1b0d81 post.pub
8dd282d6ad5a0fa6da2a2054b70ac96c257a58ef4c23715f02b9885329094a27 post1.pub
8dd282d6ad5a0fa6da2a2054b70ac96c257a58ef4c23715f02b9885329094a27 post2.pub
Except for the first one, they are all equal. So what’s the difference between them?
diff -u post.pub post1.pub
--- post.pub 2022-12-07 13:28:36.009649296 -0300
+++ post1.pub 2022-12-07 13:31:34.426816707 -0300
@@ -1 +1 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPA8QtyAE1DLpUIY3otmLILZv9XdRlXv37hHEWTGib7p carlos@darkstar
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPA8QtyAE1DLpUIY3otmLILZv9XdRlXv37hHEWTGib7p
Ah, our original key had a memo, and the restored ones don’t. Not a big deal!
What about the private keys’ fingerprints?
ssh-keygen -l -f post > post.finger
ssh-keygen -l -f post1 > post1.finger
ssh-keygen -l -f post2 > post2.finger
cat post*.finger
256 SHA256:KfCFwx1/vfcV/XQqdneOMOdgpbVu4Nxz32buks4MLpI carlos@darkstar (ED25519)
256 SHA256:KfCFwx1/vfcV/XQqdneOMOdgpbVu4Nxz32buks4MLpI post1.pub (ED25519)
256 SHA256:KfCFwx1/vfcV/XQqdneOMOdgpbVu4Nxz32buks4MLpI post2.pub (ED25519)
Again, the same key. The only difference is the memo.
Now let’s check how different the private keys really are:
diff -u post1 post2
--- post1 2022-12-07 13:31:34.426816707 -0300
+++ post2 2022-12-07 13:31:37.870839247 -0300
@@ -1,7 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACDwPELcgBNQy6VCGN6LZiyC2b/V3UZV79+4RxFkxom+6QAA
-AIjRy3cc0ct3HAAAAAtzc2gtZWQyNTUxOQAAACDwPELcgBNQy6VCGN6LZiyC2b/V
+AIhYJw2fWCcNnwAAAAtzc2gtZWQyNTUxOQAAACDwPELcgBNQy6VCGN6LZiyC2b/V
3UZV79+4RxFkxom+6QAAAECX4h38X7OCXJXfaRkl7Dq/Hgw6JmqfklYEN8bo63RD
DfA8QtyAE1DLpUIY3otmLILZv9XdRlXv37hHEWTGib7pAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----
Just one line–about 12 chars–are different: jRy3cc0ct3HA
vs hYJw2fWCcNnw
.
All that said, for all intents and purposes it’s the same key, which shouldn’t be a surprise, but it’s good to know anyways!
Hope you enjoyed this trip into how OpenSSH private keys are marshaled, and I’ll see you in the next one!