ssh_data 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/lib/ssh_data.rb +36 -0
- data/lib/ssh_data/certificate.rb +240 -0
- data/lib/ssh_data/encoding.rb +666 -0
- data/lib/ssh_data/error.rb +7 -0
- data/lib/ssh_data/private_key.rb +73 -0
- data/lib/ssh_data/private_key/base.rb +39 -0
- data/lib/ssh_data/private_key/dsa.rb +75 -0
- data/lib/ssh_data/private_key/ecdsa.rb +95 -0
- data/lib/ssh_data/private_key/ed25519.rb +68 -0
- data/lib/ssh_data/private_key/rsa.rb +106 -0
- data/lib/ssh_data/public_key.rb +78 -0
- data/lib/ssh_data/public_key/base.rb +71 -0
- data/lib/ssh_data/public_key/dsa.rb +122 -0
- data/lib/ssh_data/public_key/ecdsa.rb +151 -0
- data/lib/ssh_data/public_key/ed25519.rb +74 -0
- data/lib/ssh_data/public_key/rsa.rb +79 -0
- data/lib/ssh_data/version.rb +3 -0
- metadata +116 -0
@@ -0,0 +1,78 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
# Public key algorithm identifiers
|
4
|
+
ALGO_RSA = "ssh-rsa"
|
5
|
+
ALGO_DSA = "ssh-dss"
|
6
|
+
ALGO_ECDSA256 = "ecdsa-sha2-nistp256"
|
7
|
+
ALGO_ECDSA384 = "ecdsa-sha2-nistp384"
|
8
|
+
ALGO_ECDSA521 = "ecdsa-sha2-nistp521"
|
9
|
+
ALGO_ED25519 = "ssh-ed25519"
|
10
|
+
|
11
|
+
# RSA SHA2 *signature* algorithms used with ALGO_RSA keys.
|
12
|
+
# https://tools.ietf.org/html/draft-rsa-dsa-sha2-256-02
|
13
|
+
ALGO_RSA_SHA2_256 = "rsa-sha2-256"
|
14
|
+
ALGO_RSA_SHA2_512 = "rsa-sha2-512"
|
15
|
+
|
16
|
+
ALGOS = [
|
17
|
+
ALGO_RSA, ALGO_DSA, ALGO_ECDSA256, ALGO_ECDSA384, ALGO_ECDSA521,
|
18
|
+
ALGO_ED25519
|
19
|
+
]
|
20
|
+
|
21
|
+
# Parse an OpenSSH public key in authorized_keys format (see sshd(8) manual
|
22
|
+
# page).
|
23
|
+
#
|
24
|
+
# key - An OpenSSH formatted public key, including algo, base64 encoded key
|
25
|
+
# and optional comment.
|
26
|
+
#
|
27
|
+
# Returns a PublicKey::Base subclass instance.
|
28
|
+
def self.parse_openssh(key)
|
29
|
+
algo, raw, _ = SSHData.key_parts(key)
|
30
|
+
parsed = parse_rfc4253(raw)
|
31
|
+
|
32
|
+
if parsed.algo != algo
|
33
|
+
raise DecodeError, "algo mismatch: #{parsed.algo.inspect}!=#{algo.inspect}"
|
34
|
+
end
|
35
|
+
|
36
|
+
parsed
|
37
|
+
end
|
38
|
+
|
39
|
+
# Deprecated
|
40
|
+
singleton_class.send(:alias_method, :parse, :parse_openssh)
|
41
|
+
|
42
|
+
# Parse an RFC 4253 binary SSH public key.
|
43
|
+
#
|
44
|
+
# key - A RFC 4253 binary public key String.
|
45
|
+
#
|
46
|
+
# Returns a PublicKey::Base subclass instance.
|
47
|
+
def self.parse_rfc4253(raw)
|
48
|
+
data, read = Encoding.decode_public_key(raw)
|
49
|
+
|
50
|
+
if read != raw.bytesize
|
51
|
+
raise DecodeError, "unexpected trailing data"
|
52
|
+
end
|
53
|
+
|
54
|
+
from_data(data)
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.from_data(data)
|
58
|
+
case data[:algo]
|
59
|
+
when ALGO_RSA
|
60
|
+
RSA.new(**data)
|
61
|
+
when ALGO_DSA
|
62
|
+
DSA.new(**data)
|
63
|
+
when ALGO_ECDSA256, ALGO_ECDSA384, ALGO_ECDSA521
|
64
|
+
ECDSA.new(**data)
|
65
|
+
when ALGO_ED25519
|
66
|
+
ED25519.new(**data)
|
67
|
+
else
|
68
|
+
raise DecodeError, "unkown algo: #{data[:algo].inspect}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
require "ssh_data/public_key/base"
|
75
|
+
require "ssh_data/public_key/rsa"
|
76
|
+
require "ssh_data/public_key/dsa"
|
77
|
+
require "ssh_data/public_key/ecdsa"
|
78
|
+
require "ssh_data/public_key/ed25519"
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class Base
|
4
|
+
attr_reader :algo
|
5
|
+
|
6
|
+
def initialize(**kwargs)
|
7
|
+
@algo = kwargs[:algo]
|
8
|
+
end
|
9
|
+
|
10
|
+
# Calculate the fingerprint of this public key.
|
11
|
+
#
|
12
|
+
# md5: - Bool of whether to generate an MD5 fingerprint instead of the
|
13
|
+
# default SHA256.
|
14
|
+
#
|
15
|
+
# Returns a String fingerprint.
|
16
|
+
def fingerprint(md5: false)
|
17
|
+
if md5
|
18
|
+
# colon separated, hex encoded md5 digest
|
19
|
+
OpenSSL::Digest::MD5.digest(rfc4253).unpack("H2" * 16).join(":")
|
20
|
+
else
|
21
|
+
# base64 encoded sha256 digest with b64 padding stripped
|
22
|
+
Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(rfc4253))[0...-1]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Make an SSH signature.
|
27
|
+
#
|
28
|
+
# signed_data - The String message over which to calculated the signature.
|
29
|
+
#
|
30
|
+
# Returns a binary String signature.
|
31
|
+
def sign(signed_data)
|
32
|
+
raise "implement me"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Verify an SSH signature.
|
36
|
+
#
|
37
|
+
# signed_data - The String message that the signature was calculated over.
|
38
|
+
# signature - The binary String signature with SSH encoding.
|
39
|
+
#
|
40
|
+
# Returns boolean.
|
41
|
+
def verify(signed_data, signature)
|
42
|
+
raise "implement me"
|
43
|
+
end
|
44
|
+
|
45
|
+
# RFC4253 binary encoding of the public key.
|
46
|
+
#
|
47
|
+
# Returns a binary String.
|
48
|
+
def rfc4253
|
49
|
+
raise "implement me"
|
50
|
+
end
|
51
|
+
|
52
|
+
# OpenSSH public key in authorized_keys format (see sshd(8) manual page).
|
53
|
+
#
|
54
|
+
# comment - Optional String comment to append.
|
55
|
+
#
|
56
|
+
# Returns a String key.
|
57
|
+
def openssh(comment: nil)
|
58
|
+
[algo, Base64.strict_encode64(rfc4253), comment].compact.join(" ")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Is this public key equal to another public key?
|
62
|
+
#
|
63
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
64
|
+
#
|
65
|
+
# Returns boolean.
|
66
|
+
def ==(other)
|
67
|
+
other.class == self.class
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class DSA < Base
|
4
|
+
attr_reader :p, :q, :g, :y, :openssl
|
5
|
+
|
6
|
+
# Convert an SSH encoded DSA signature to DER encoding for verification with
|
7
|
+
# OpenSSL.
|
8
|
+
#
|
9
|
+
# sig - A binary String signature from an SSH packet.
|
10
|
+
#
|
11
|
+
# Returns a binary String signature, as expected by OpenSSL.
|
12
|
+
def self.openssl_signature(sig)
|
13
|
+
if sig.bytesize != 40
|
14
|
+
raise DecodeError, "bad DSA signature size"
|
15
|
+
end
|
16
|
+
|
17
|
+
r = OpenSSL::BN.new(sig.byteslice(0, 20), 2)
|
18
|
+
s = OpenSSL::BN.new(sig.byteslice(20, 20), 2)
|
19
|
+
|
20
|
+
OpenSSL::ASN1::Sequence.new([
|
21
|
+
OpenSSL::ASN1::Integer.new(r),
|
22
|
+
OpenSSL::ASN1::Integer.new(s)
|
23
|
+
]).to_der
|
24
|
+
end
|
25
|
+
|
26
|
+
# Convert an DER encoded DSA signature, as generated by OpenSSL to SSH
|
27
|
+
# encoding.
|
28
|
+
#
|
29
|
+
# sig - A binary String signature, as generated by OpenSSL.
|
30
|
+
#
|
31
|
+
# Returns a binary String signature, as found in an SSH packet.
|
32
|
+
def self.ssh_signature(sig)
|
33
|
+
a1 = OpenSSL::ASN1.decode(sig)
|
34
|
+
if a1.tag_class != :UNIVERSAL || a1.tag != OpenSSL::ASN1::SEQUENCE || a1.value.count != 2
|
35
|
+
raise DecodeError, "bad asn1 signature"
|
36
|
+
end
|
37
|
+
|
38
|
+
r, s = a1.value
|
39
|
+
if r.tag_class != :UNIVERSAL || r.tag != OpenSSL::ASN1::INTEGER || s.tag_class != :UNIVERSAL || s.tag != OpenSSL::ASN1::INTEGER
|
40
|
+
raise DecodeError, "bad asn1 signature"
|
41
|
+
end
|
42
|
+
|
43
|
+
# left pad big endian representations to 20 bytes and concatenate
|
44
|
+
[
|
45
|
+
"\x00" * (20 - r.value.num_bytes),
|
46
|
+
r.value.to_s(2),
|
47
|
+
"\x00" * (20 - s.value.num_bytes),
|
48
|
+
s.value.to_s(2)
|
49
|
+
].join
|
50
|
+
end
|
51
|
+
|
52
|
+
def initialize(algo:, p:, q:, g:, y:)
|
53
|
+
unless algo == ALGO_DSA
|
54
|
+
raise DecodeError, "bad algorithm: #{algo.inspect}"
|
55
|
+
end
|
56
|
+
|
57
|
+
@p = p
|
58
|
+
@q = q
|
59
|
+
@g = g
|
60
|
+
@y = y
|
61
|
+
|
62
|
+
@openssl = OpenSSL::PKey::DSA.new(asn1.to_der)
|
63
|
+
|
64
|
+
super(algo: algo)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Verify an SSH signature.
|
68
|
+
#
|
69
|
+
# signed_data - The String message that the signature was calculated over.
|
70
|
+
# signature - The binarty String signature with SSH encoding.
|
71
|
+
#
|
72
|
+
# Returns boolean.
|
73
|
+
def verify(signed_data, signature)
|
74
|
+
sig_algo, ssh_sig, _ = Encoding.decode_signature(signature)
|
75
|
+
if sig_algo != ALGO_DSA
|
76
|
+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
|
77
|
+
end
|
78
|
+
|
79
|
+
openssl_sig = self.class.openssl_signature(ssh_sig)
|
80
|
+
openssl.verify(OpenSSL::Digest::SHA1.new, openssl_sig, signed_data)
|
81
|
+
end
|
82
|
+
|
83
|
+
# RFC4253 binary encoding of the public key.
|
84
|
+
#
|
85
|
+
# Returns a binary String.
|
86
|
+
def rfc4253
|
87
|
+
Encoding.encode_fields(
|
88
|
+
[:string, algo],
|
89
|
+
[:mpint, p],
|
90
|
+
[:mpint, q],
|
91
|
+
[:mpint, g],
|
92
|
+
[:mpint, y],
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Is this public key equal to another public key?
|
97
|
+
#
|
98
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
99
|
+
#
|
100
|
+
# Returns boolean.
|
101
|
+
def ==(other)
|
102
|
+
super && other.p == p && other.q == q && other.g == g && other.y == y
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def asn1
|
108
|
+
OpenSSL::ASN1::Sequence.new([
|
109
|
+
OpenSSL::ASN1::Sequence.new([
|
110
|
+
OpenSSL::ASN1::ObjectId.new("DSA"),
|
111
|
+
OpenSSL::ASN1::Sequence.new([
|
112
|
+
OpenSSL::ASN1::Integer.new(p),
|
113
|
+
OpenSSL::ASN1::Integer.new(q),
|
114
|
+
OpenSSL::ASN1::Integer.new(g),
|
115
|
+
]),
|
116
|
+
]),
|
117
|
+
OpenSSL::ASN1::BitString.new(OpenSSL::ASN1::Integer.new(y).to_der),
|
118
|
+
])
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class ECDSA < Base
|
4
|
+
attr_reader :curve, :public_key_bytes, :openssl
|
5
|
+
|
6
|
+
NISTP256 = "nistp256"
|
7
|
+
NISTP384 = "nistp384"
|
8
|
+
NISTP521 = "nistp521"
|
9
|
+
|
10
|
+
OPENSSL_CURVE_NAME_FOR_CURVE = {
|
11
|
+
NISTP256 => "prime256v1",
|
12
|
+
NISTP384 => "secp384r1",
|
13
|
+
NISTP521 => "secp521r1",
|
14
|
+
}
|
15
|
+
|
16
|
+
CURVE_FOR_OPENSSL_CURVE_NAME = {
|
17
|
+
"prime256v1" => NISTP256,
|
18
|
+
"secp384r1" => NISTP384,
|
19
|
+
"secp521r1" => NISTP521,
|
20
|
+
}
|
21
|
+
|
22
|
+
DIGEST_FOR_CURVE = {
|
23
|
+
NISTP256 => OpenSSL::Digest::SHA256,
|
24
|
+
NISTP384 => OpenSSL::Digest::SHA384,
|
25
|
+
NISTP521 => OpenSSL::Digest::SHA512,
|
26
|
+
}
|
27
|
+
|
28
|
+
# Convert an SSH encoded ECDSA signature to DER encoding for verification with
|
29
|
+
# OpenSSL.
|
30
|
+
#
|
31
|
+
# sig - A binary String signature from an SSH packet.
|
32
|
+
#
|
33
|
+
# Returns a binary String signature, as expected by OpenSSL.
|
34
|
+
def self.openssl_signature(sig)
|
35
|
+
r, rlen = Encoding.decode_mpint(sig, 0)
|
36
|
+
s, slen = Encoding.decode_mpint(sig, rlen)
|
37
|
+
|
38
|
+
if rlen + slen != sig.bytesize
|
39
|
+
raise DecodeError, "unexpected trailing data"
|
40
|
+
end
|
41
|
+
|
42
|
+
OpenSSL::ASN1::Sequence.new([
|
43
|
+
OpenSSL::ASN1::Integer.new(r),
|
44
|
+
OpenSSL::ASN1::Integer.new(s)
|
45
|
+
]).to_der
|
46
|
+
end
|
47
|
+
|
48
|
+
# Convert an DER encoded ECDSA signature, as generated by OpenSSL to SSH
|
49
|
+
# encoding.
|
50
|
+
#
|
51
|
+
# sig - A binary String signature, as generated by OpenSSL.
|
52
|
+
#
|
53
|
+
# Returns a binary String signature, as found in an SSH packet.
|
54
|
+
def self.ssh_signature(sig)
|
55
|
+
a1 = OpenSSL::ASN1.decode(sig)
|
56
|
+
if a1.tag_class != :UNIVERSAL || a1.tag != OpenSSL::ASN1::SEQUENCE || a1.value.count != 2
|
57
|
+
raise DecodeError, "bad asn1 signature"
|
58
|
+
end
|
59
|
+
|
60
|
+
r, s = a1.value
|
61
|
+
if r.tag_class != :UNIVERSAL || r.tag != OpenSSL::ASN1::INTEGER || s.tag_class != :UNIVERSAL || s.tag != OpenSSL::ASN1::INTEGER
|
62
|
+
raise DecodeError, "bad asn1 signature"
|
63
|
+
end
|
64
|
+
|
65
|
+
[Encoding.encode_mpint(r.value), Encoding.encode_mpint(s.value)].join
|
66
|
+
end
|
67
|
+
|
68
|
+
def initialize(algo:, curve:, public_key:)
|
69
|
+
unless [ALGO_ECDSA256, ALGO_ECDSA384, ALGO_ECDSA521].include?(algo)
|
70
|
+
raise DecodeError, "bad algorithm: #{algo.inspect}"
|
71
|
+
end
|
72
|
+
|
73
|
+
unless algo == "ecdsa-sha2-#{curve}"
|
74
|
+
raise DecodeError, "bad curve: #{curve.inspect}"
|
75
|
+
end
|
76
|
+
|
77
|
+
@curve = curve
|
78
|
+
@public_key_bytes = public_key
|
79
|
+
|
80
|
+
@openssl = begin
|
81
|
+
OpenSSL::PKey::EC.new(asn1.to_der)
|
82
|
+
rescue ArgumentError
|
83
|
+
raise DecodeError, "bad key data"
|
84
|
+
end
|
85
|
+
|
86
|
+
super(algo: algo)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Verify an SSH signature.
|
90
|
+
#
|
91
|
+
# signed_data - The String message that the signature was calculated over.
|
92
|
+
# signature - The binarty String signature with SSH encoding.
|
93
|
+
#
|
94
|
+
# Returns boolean.
|
95
|
+
def verify(signed_data, signature)
|
96
|
+
sig_algo, ssh_sig, _ = Encoding.decode_signature(signature)
|
97
|
+
if sig_algo != "ecdsa-sha2-#{curve}"
|
98
|
+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
|
99
|
+
end
|
100
|
+
|
101
|
+
openssl_sig = self.class.openssl_signature(ssh_sig)
|
102
|
+
digest = DIGEST_FOR_CURVE[curve]
|
103
|
+
|
104
|
+
openssl.verify(digest.new, openssl_sig, signed_data)
|
105
|
+
end
|
106
|
+
|
107
|
+
# RFC4253 binary encoding of the public key.
|
108
|
+
#
|
109
|
+
# Returns a binary String.
|
110
|
+
def rfc4253
|
111
|
+
Encoding.encode_fields(
|
112
|
+
[:string, algo],
|
113
|
+
[:string, curve],
|
114
|
+
[:string, public_key_bytes],
|
115
|
+
)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Is this public key equal to another public key?
|
119
|
+
#
|
120
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
121
|
+
#
|
122
|
+
# Returns boolean.
|
123
|
+
def ==(other)
|
124
|
+
super && other.curve == curve && other.public_key_bytes == public_key_bytes
|
125
|
+
end
|
126
|
+
|
127
|
+
# The digest algorithm to use with this key's curve.
|
128
|
+
#
|
129
|
+
# Returns an OpenSSL::Digest.
|
130
|
+
def digest
|
131
|
+
DIGEST_FOR_CURVE[curve]
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def asn1
|
137
|
+
unless name = OPENSSL_CURVE_NAME_FOR_CURVE[curve]
|
138
|
+
raise DecodeError, "unknown curve: #{curve.inspect}"
|
139
|
+
end
|
140
|
+
|
141
|
+
OpenSSL::ASN1::Sequence.new([
|
142
|
+
OpenSSL::ASN1::Sequence.new([
|
143
|
+
OpenSSL::ASN1::ObjectId.new("id-ecPublicKey"),
|
144
|
+
OpenSSL::ASN1::ObjectId.new(name),
|
145
|
+
]),
|
146
|
+
OpenSSL::ASN1::BitString.new(public_key_bytes),
|
147
|
+
])
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module SSHData
|
2
|
+
module PublicKey
|
3
|
+
class ED25519 < Base
|
4
|
+
attr_reader :pk, :ed25519_key
|
5
|
+
|
6
|
+
# ed25519 isn't a hard requirement for using this Gem. We only do actual
|
7
|
+
# validation with the key if the ed25519 Gem has been loaded.
|
8
|
+
def self.enabled?
|
9
|
+
Object.const_defined?(:Ed25519)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Assert that the ed25519 gem has been loaded.
|
13
|
+
#
|
14
|
+
# Returns nothing, raises AlgorithmError.
|
15
|
+
def self.ed25519_gem_required!
|
16
|
+
raise AlgorithmError, "the ed25519 gem is not loaded" unless enabled?
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(algo:, pk:)
|
20
|
+
unless algo == ALGO_ED25519
|
21
|
+
raise DecodeError, "bad algorithm: #{algo.inspect}"
|
22
|
+
end
|
23
|
+
|
24
|
+
@pk = pk
|
25
|
+
|
26
|
+
if self.class.enabled?
|
27
|
+
@ed25519_key = Ed25519::VerifyKey.new(pk)
|
28
|
+
end
|
29
|
+
|
30
|
+
super(algo: algo)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Verify an SSH signature.
|
34
|
+
#
|
35
|
+
# signed_data - The String message that the signature was calculated over.
|
36
|
+
# signature - The binarty String signature with SSH encoding.
|
37
|
+
#
|
38
|
+
# Returns boolean.
|
39
|
+
def verify(signed_data, signature)
|
40
|
+
self.class.ed25519_gem_required!
|
41
|
+
|
42
|
+
sig_algo, raw_sig, _ = Encoding.decode_signature(signature)
|
43
|
+
if sig_algo != ALGO_ED25519
|
44
|
+
raise DecodeError, "bad signature algorithm: #{sig_algo.inspect}"
|
45
|
+
end
|
46
|
+
|
47
|
+
begin
|
48
|
+
ed25519_key.verify(raw_sig, signed_data)
|
49
|
+
rescue Ed25519::VerifyError
|
50
|
+
false
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# RFC4253 binary encoding of the public key.
|
55
|
+
#
|
56
|
+
# Returns a binary String.
|
57
|
+
def rfc4253
|
58
|
+
Encoding.encode_fields(
|
59
|
+
[:string, algo],
|
60
|
+
[:string, pk],
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
# Is this public key equal to another public key?
|
65
|
+
#
|
66
|
+
# other - Another SSHData::PublicKey::Base instance to compare with.
|
67
|
+
#
|
68
|
+
# Returns boolean.
|
69
|
+
def ==(other)
|
70
|
+
super && other.pk == pk
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|