Files
ubicloud/lib/ssh_key.rb
Daniel Farina a37e5f3020 Fix private key format of SshKey generation
Previously, this program generated private SSH key blobs that worked
with net-ssh but not with openssh/libcrypto.

You can reproduce pre-patch like so:

    load 'lib/ssh_key.rb'
    File.write('/tmp/test.key', SshKey.generate.private_key, perm: 0600)
    system('ssh-add /tmp/test.key')

It'll write:

    Error loading key "/tmp/test.key": error in libcrypto

And after the patch, it writes:

    Identity added: /tmp/test.key (/tmp/test.key)

There were three deviations where net-ssh has more relaxed
requirements than openssh/libcrypto, all of which needed to be
addressed to load the generated key text with `ssh`:

1. The comment field in private keys is required.
2. Padding bytes must be set to values one through eight *in binary.*
3. The public key data must be encapsulated in another SSH bitstring,
   including the public key type before the payload.

Regarding the third point, the system maintains three representations
of each public key: one in the `public_key` column in ASCII, and two
within the openssh private key blob. The two copies in the private key
blob are mandated by the openssh format. The separate ASCII column
enables SQL-based auditing of non-sensitive public key copies without
requiring application-level decryption, making it easier to track
which keys are intended to be active at any time, by digesting them
and matching them with the same digests reported by OpenSSH in logs.
2025-07-14 18:14:37 -07:00

147 lines
4.1 KiB
Ruby

# frozen_string_literal: true
require "base64"
require "stringio"
require "ed25519"
require "net/ssh"
class SshKey
def self.generate
new Ed25519::SigningKey.generate
end
def self.from_binary(keypair)
new Ed25519::SigningKey.from_keypair keypair
end
def initialize(signer)
@signer = signer
end
def keypair
@signer.keypair
end
def private_key
return @private_key if @private_key
# N.B. net-ssh only supports one private key in a key_data at one
# time, in ed25519.rb in 7.1.0:
#
# raise ArgumentError.new("Only 1 key is supported in ssh keys #{num_keys} was in private key") unless num_keys == 1
#
# Kudos
# https://dnaeon.github.io/openssh-private-key-binary-format/ with
# excerpts and replication of primary references below.
#
# https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.key?annotate=HEAD
#
# byte[] AUTH_MAGIC
# string ciphername
# string kdfname
# string kdfoptions
# uint32 number of keys N
# string publickey1
# string publickey2
# ...
# string publickeyN
# string encrypted, padded list of private keys
#
# We don't use OpenSSH encryption, preferring column encryption or
# some other application method, so he encipherment will be none:
#
# > For unencrypted keys the cipher "none" and the KDF "none" are
# > used with empty passphrases. The options if the KDF "none" are
# > the empty string.
#
# uint32 checkint
# uint32 checkint
# byte[] privatekey1
# string comment1
# byte[] privatekey2
# string comment2
# ...
# string privatekeyN
# string commentN
# byte 1
# byte 2
# byte 3
# ...
# byte padlen % 255
#
# > The list of privatekey/comment pairs is padded with the bytes
# > 1, 2, 3, ... until the total length is a multiple of the
# > cipher block size.
#
# If here is no cipher, the padding is eight:
# https://github.com/openssh/openssh-portable/blob/eba523f0a130f1cce829e6aecdcefa841f526a1a/cipher.c#L86
#
# The byte array containing he private key has a per-key defined
# level of protocol.
checkint = rand(0..(2**32 - 1))
verify_key_bytes = @signer.verify_key.to_bytes
nested_private_key = Net::SSH::Buffer.from(
:long, checkint,
:long, checkint,
# 'encrypted' private keys
:string, "ssh-ed25519",
:string, verify_key_bytes,
:string, @signer.keypair,
:string, "" # empty "comment" field
)
# Negative modulus is a handy trick to fill out pads like this.
padding = (nested_private_key.length % -8).abs
nested_private_key.write("\x01\x02\x03\x04\x05\x06\x07\x08".slice(0, padding))
# :nocov:
fail "BUG: padding broken" unless nested_private_key.length % 8 == 0
# :nocov:
@private_key = StringIO.open { |s|
s.puts "-----BEGIN OPENSSH PRIVATE KEY-----"
s.write Base64.encode64(Net::SSH::Buffer.from(
:raw, "openssh-key-v1\0", # AUTH_MAGIC
:string, "none", # cipher
:string, "none", # kdf
:string, "", # kdfoptions
:long, 1, # number of keys N
:string, Net::SSH::Buffer.from(
:string, "ssh-ed25519",
:string, verify_key_bytes
).content, # publickey1
:string, nested_private_key.content # privatekey1
).content)
s.puts "-----END OPENSSH PRIVATE KEY-----"
s.string
}
end
def self.public_key(public_key)
type, binary = case public_key
when OpenSSL::PKey::RSA
["ssh-rsa", public_key.to_blob]
else
verify_key = case public_key
when Ed25519::VerifyKey
public_key
when Net::SSH::Authentication::ED25519::PubKey
public_key.verify_key
else
fail "BUG: unrecognized key type"
end
["ssh-ed25519", Net::SSH::Buffer.from(
:string, "ssh-ed25519",
:string, verify_key.to_bytes
).content]
end
type + " " + Base64.strict_encode64(binary)
end
def public_key
@public_key ||= self.class.public_key(@signer.verify_key)
end
end