Skip to content

Commit e7c2b70

Browse files
authored
Merge pull request #31 from github/full-sig-support
Support SSHSIG and SK verification
2 parents 1890c4b + c77dba4 commit e7c2b70

File tree

100 files changed

+1324
-15
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

100 files changed

+1324
-15
lines changed

.editorconfig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
root = true
2+
3+
[*]
4+
insert_final_newline = true
5+
6+
[*.rb]
7+
indent_size = 2
8+
indent_style = space

.github/workflows/ruby.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ jobs:
2020
run: |
2121
gem install bundler
2222
bundle install --jobs 4 --retry 3
23-
chmod 600 ./spec/fixtures/*
23+
find ./spec/fixtures -type f -exec chmod 600 -- {} +
2424
bundle exec rspec

lib/ssh_data.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@ def key_parts(key)
3434
require "ssh_data/public_key"
3535
require "ssh_data/private_key"
3636
require "ssh_data/encoding"
37+
require "ssh_data/signature"

lib/ssh_data/encoding.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@ module Encoding
33
# Fields in an OpenSSL private key
44
# https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key
55
OPENSSH_PRIVATE_KEY_MAGIC = "openssh-key-v1\x00"
6+
7+
OPENSSH_SIGNATURE_MAGIC = "SSHSIG"
8+
OPENSSH_SIGNATURE_VERSION = 0x01
9+
10+
OPENSSH_SIGNATURE_FIELDS = [
11+
[:sigversion, :uint32],
12+
[:publickey, :string],
13+
[:namespace, :string],
14+
[:reserved, :string],
15+
[:hash_algorithm, :string],
16+
[:signature, :string],
17+
]
18+
619
OPENSSH_PRIVATE_KEY_FIELDS = [
720
[:ciphername, :string],
821
[:kdfname, :string],
@@ -313,6 +326,21 @@ def decode_string_public_key(raw, offset=0, algo=nil)
313326
[key, str_read]
314327
end
315328

329+
def decode_openssh_signature(raw, offset=0)
330+
total_read = 0
331+
332+
magic = raw.byteslice(offset, OPENSSH_SIGNATURE_MAGIC.bytesize)
333+
unless magic == OPENSSH_SIGNATURE_MAGIC
334+
raise DecodeError, "bad OpenSSH signature"
335+
end
336+
337+
total_read += OPENSSH_SIGNATURE_MAGIC.bytesize
338+
offset += total_read
339+
data, read = decode_fields(raw, OPENSSH_SIGNATURE_FIELDS, offset)
340+
total_read += read
341+
[data, total_read]
342+
end
343+
316344
# Decode the fields in a certificate.
317345
#
318346
# raw - Binary String certificate as described by RFC4253 section 6.6.
@@ -680,6 +708,32 @@ def encode_uint32(value)
680708
[value].pack("L>")
681709
end
682710

711+
# Read a uint8 from the provided raw data.
712+
#
713+
# raw - A binary String.
714+
# offset - The offset into raw at which to read (default 0).
715+
#
716+
# Returns an Array including the decoded uint8 as an Integer and the
717+
# Integer number of bytes read.
718+
def decode_uint8(raw, offset=0)
719+
if raw.bytesize < offset + 1
720+
raise DecodeError, "data too short"
721+
end
722+
723+
uint8 = raw.byteslice(offset, 1).unpack("C").first
724+
725+
[uint8, 1]
726+
end
727+
728+
# Encoding an integer as a uint8.
729+
#
730+
# value - The Integer value to encode.
731+
#
732+
# Returns an encoded representation of the value.
733+
def encode_uint8(value)
734+
[value].pack("C")
735+
end
736+
683737
extend self
684738
end
685739
end

lib/ssh_data/public_key.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def self.from_data(data)
7878
end
7979

8080
require "ssh_data/public_key/base"
81+
require "ssh_data/public_key/security_key"
8182
require "ssh_data/public_key/rsa"
8283
require "ssh_data/public_key/dsa"
8384
require "ssh_data/public_key/ecdsa"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
module SSHData
2+
module PublicKey
3+
module SecurityKey
4+
5+
# Defaults to match OpenSSH, user presence is required by verification is not.
6+
DEFAULT_SK_VERIFY_OPTS = {
7+
user_presence_required: true,
8+
user_verification_required: false
9+
}
10+
11+
SK_FLAG_USER_PRESENCE = 0b001
12+
SK_FLAG_USER_VERIFICATION = 0b100
13+
14+
def build_signing_blob(application, signed_data, signature)
15+
read = 0
16+
sig_algo, raw_sig, signature_read = Encoding.decode_signature(signature)
17+
read += signature_read
18+
sk_flags, sk_flags_read = Encoding.decode_uint8(signature, read)
19+
read += sk_flags_read
20+
counter, counter_read = Encoding.decode_uint32(signature, read)
21+
read += counter_read
22+
23+
if read != signature.bytesize
24+
raise DecodeError, "unexpected trailing data"
25+
end
26+
27+
application_hash = OpenSSL::Digest::SHA256.digest(application)
28+
message_hash = OpenSSL::Digest::SHA256.digest(signed_data)
29+
30+
blob =
31+
application_hash +
32+
Encoding.encode_uint8(sk_flags) +
33+
Encoding.encode_uint32(counter) +
34+
message_hash
35+
36+
[sig_algo, raw_sig, sk_flags, blob]
37+
end
38+
end
39+
end
40+
end

lib/ssh_data/public_key/skecdsa.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module SSHData
22
module PublicKey
33
class SKECDSA < ECDSA
4+
include SecurityKey
45
attr_reader :application
56

67
OPENSSL_CURVE_NAME_FOR_CURVE = {
@@ -34,8 +35,25 @@ def rfc4253
3435
)
3536
end
3637

37-
def verify(signed_data, signature)
38-
raise UnsupportedError, "SK-ECDSA verification is not supported."
38+
def verify(signed_data, signature, **opts)
39+
opts = DEFAULT_SK_VERIFY_OPTS.merge(opts)
40+
unknown_opts = opts.keys - DEFAULT_SK_VERIFY_OPTS.keys
41+
raise UnsupportedError, "Verification options #{unknown_opts.inspect} are not supported." unless unknown_opts.empty?
42+
43+
sig_algo, raw_sig, sk_flags, blob = build_signing_blob(application, signed_data, signature)
44+
self.class.check_algorithm!(sig_algo, curve)
45+
46+
openssl_sig = self.class.openssl_signature(raw_sig)
47+
digest = DIGEST_FOR_CURVE[curve]
48+
49+
result = openssl.verify(digest.new, openssl_sig, blob)
50+
51+
# We don't know that the flags are correct until after we've validated the signature
52+
# which embeds the flags, so always verify the signature first.
53+
return false if opts[:user_presence_required] && (sk_flags & SK_FLAG_USER_PRESENCE != SK_FLAG_USER_PRESENCE)
54+
return false if opts[:user_verification_required] && (sk_flags & SK_FLAG_USER_VERIFICATION != SK_FLAG_USER_VERIFICATION)
55+
56+
result
3957
end
4058

4159
def ==(other)

lib/ssh_data/public_key/sked25519.rb

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
module SSHData
22
module PublicKey
33
class SKED25519 < ED25519
4+
include SecurityKey
45
attr_reader :application
56

67
def initialize(algo:, pk:, application:)
78
@application = application
89
super(algo: algo, pk: pk)
910
end
10-
11+
1112
def self.algorithm_identifier
1213
ALGO_SKED25519
1314
end
@@ -23,8 +24,30 @@ def rfc4253
2324
)
2425
end
2526

26-
def verify(signed_data, signature)
27-
raise UnsupportedError, "SK-Ed25519 verification is not supported."
27+
def verify(signed_data, signature, **opts)
28+
self.class.ed25519_gem_required!
29+
opts = DEFAULT_SK_VERIFY_OPTS.merge(opts)
30+
unknown_opts = opts.keys - DEFAULT_SK_VERIFY_OPTS.keys
31+
raise UnsupportedError, "Verification options #{unknown_opts.inspect} are not supported." unless unknown_opts.empty?
32+
33+
sig_algo, raw_sig, sk_flags, blob = build_signing_blob(application, signed_data, signature)
34+
35+
if sig_algo != self.class.algorithm_identifier
36+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
37+
end
38+
39+
result = begin
40+
ed25519_key.verify(raw_sig, blob)
41+
rescue Ed25519::VerifyError
42+
false
43+
end
44+
45+
# We don't know that the flags are correct until after we've validated the signature
46+
# which embeds the flags, so always verify the signature first.
47+
return false if opts[:user_presence_required] && (sk_flags & SK_FLAG_USER_PRESENCE != SK_FLAG_USER_PRESENCE)
48+
return false if opts[:user_verification_required] && (sk_flags & SK_FLAG_USER_VERIFICATION != SK_FLAG_USER_VERIFICATION)
49+
50+
result
2851
end
2952

3053
def ==(other)

lib/ssh_data/signature.rb

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# frozen_string_literal: true
2+
3+
module SSHData
4+
class Signature
5+
PEM_TYPE = "SSH SIGNATURE"
6+
SIGNATURE_PREAMBLE = "SSHSIG"
7+
MIN_SUPPORTED_VERSION = 1
8+
MAX_SUPPORTED_VERSION = 1
9+
10+
# Spec: no SHA1 or SHA384. In practice, OpenSSH is always going to use SHA512.
11+
# Note the actual signing / verify primitive may use a different hash algorithm.
12+
# https://github.com/openssh/openssh-portable/blob/b7ffbb17e37f59249c31f1ff59d6c5d80888f689/PROTOCOL.sshsig#L67
13+
SUPPORTED_HASH_ALGORITHMS = {
14+
"sha256" => OpenSSL::Digest::SHA256,
15+
"sha512" => OpenSSL::Digest::SHA512,
16+
}
17+
18+
PERMITTED_RSA_SIGNATURE_ALGORITHMS = [
19+
PublicKey::ALGO_RSA_SHA2_256,
20+
PublicKey::ALGO_RSA_SHA2_512,
21+
]
22+
23+
attr_reader :sigversion, :namespace, :signature, :reserved, :hash_algorithm
24+
25+
# Parses a PEM armored SSH signature.
26+
# pem - A PEM encoded SSH signature.
27+
#
28+
# Returns a Signature instance.
29+
def self.parse_pem(pem)
30+
pem_type = Encoding.pem_type(pem)
31+
32+
if pem_type != PEM_TYPE
33+
raise DecodeError, "Mismatched PEM type. Expecting '#{PEM_TYPE}', actually '#{pem_type}'."
34+
end
35+
36+
blob = Encoding.decode_pem(pem, pem_type)
37+
self.parse_blob(blob)
38+
end
39+
40+
def self.parse_blob(blob)
41+
data, read = Encoding.decode_openssh_signature(blob)
42+
43+
if read != blob.bytesize
44+
raise DecodeError, "unexpected trailing data"
45+
end
46+
47+
new(**data)
48+
end
49+
50+
def initialize(sigversion:, publickey:, namespace:, reserved:, hash_algorithm:, signature:)
51+
if sigversion > MAX_SUPPORTED_VERSION || sigversion < MIN_SUPPORTED_VERSION
52+
raise UnsupportedError, "Signature version is not supported"
53+
end
54+
55+
unless SUPPORTED_HASH_ALGORITHMS.has_key?(hash_algorithm)
56+
raise UnsupportedError, "Hash algorithm #{hash_algorithm} is not supported."
57+
end
58+
59+
# Spec: empty namespaces are not permitted.
60+
# https://github.com/openssh/openssh-portable/blob/b7ffbb17e37f59249c31f1ff59d6c5d80888f689/PROTOCOL.sshsig#L57
61+
raise UnsupportedError, "A namespace is required." if namespace.empty?
62+
63+
# Spec: ignore 'reserved', don't need to validate that it is empty.
64+
65+
@sigversion = sigversion
66+
@publickey = publickey
67+
@namespace = namespace
68+
@reserved = reserved
69+
@hash_algorithm = hash_algorithm
70+
@signature = signature
71+
end
72+
73+
def verify(signed_data, **opts)
74+
signing_key = public_key
75+
76+
# Unwrap the signing key if this signature was created from a certificate.
77+
key = signing_key.is_a?(Certificate) ? signing_key.public_key : signing_key
78+
79+
digest_algorithm = SUPPORTED_HASH_ALGORITHMS[@hash_algorithm]
80+
81+
if key.is_a?(PublicKey::RSA)
82+
sig_algo, * = Encoding.decode_signature(@signature)
83+
84+
# Spec: If the signature is an RSA signature, the legacy 'ssh-rsa'
85+
# identifer is not permitted.
86+
# https://github.com/openssh/openssh-portable/blob/b7ffbb17e37f59249c31f1ff59d6c5d80888f689/PROTOCOL.sshsig#L72
87+
unless PERMITTED_RSA_SIGNATURE_ALGORITHMS.include?(sig_algo)
88+
raise UnsupportedError, "RSA signature #{sig_algo} is not supported."
89+
end
90+
end
91+
92+
message_digest = digest_algorithm.digest(signed_data)
93+
blob =
94+
SIGNATURE_PREAMBLE +
95+
Encoding.encode_string(@namespace) +
96+
Encoding.encode_string(@reserved || "") +
97+
Encoding.encode_string(@hash_algorithm) +
98+
Encoding.encode_string(message_digest)
99+
100+
if key.class.include?(::SSHData::PublicKey::SecurityKey)
101+
key.verify(blob, @signature, **opts)
102+
else
103+
key.verify(blob, @signature)
104+
end
105+
end
106+
107+
# Gets the public key from the signature.
108+
# If the signature was created from a certificate, this will be an
109+
# SSHData::Certificate. Otherwise, this will be a PublicKey algorithm.
110+
def public_key
111+
public_key_algorithm, _ = Encoding.decode_string(@publickey)
112+
113+
if PublicKey::ALGOS.include?(public_key_algorithm)
114+
PublicKey.parse_rfc4253(@publickey)
115+
elsif Certificate::ALGOS.include?(public_key_algorithm)
116+
Certificate.parse_rfc4253(@publickey)
117+
else
118+
raise UnsupportedError, "Public key algorithm #{public_key_algorithm} is not supported."
119+
end
120+
end
121+
end
122+
end

spec/certificate_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,22 @@
281281
SSHData::PublicKey::RSA # ca key type
282282
]
283283

284+
test_cases << [
285+
:rsa_leaf_for_skecdsa_ca, # name
286+
"rsa_leaf_for_skecdsa_ca-cert.pub", # fixture
287+
SSHData::Certificate::ALGO_RSA, # algo
288+
SSHData::PublicKey::RSA, # public key type
289+
SSHData::PublicKey::SKECDSA # ca key type
290+
]
291+
292+
test_cases << [
293+
:rsa_leaf_for_sked25519_ca, # name
294+
"rsa_leaf_for_sked25519_ca-cert.pub", # fixture
295+
SSHData::Certificate::ALGO_RSA, # algo
296+
SSHData::PublicKey::RSA, # public key type
297+
SSHData::PublicKey::SKED25519 # ca key type
298+
]
299+
284300
test_cases.each do |name, fixture_name, algo, public_key_class, ca_key_class|
285301
describe(name) do
286302
let(:openssh) { fixture(fixture_name).strip }

0 commit comments

Comments
 (0)