Skip to content

Commit 9c8a6b3

Browse files
committed
Add sct verification
1 parent cc0d44c commit 9c8a6b3

File tree

9 files changed

+328
-57
lines changed

9 files changed

+328
-57
lines changed

lib/sigstore/internal/keyring.rb

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,17 @@ def initialize(keys:)
1414
def verify(key_id:, signature:, data:)
1515
key = @keyring.fetch(key_id) { raise KeyError, "key not found: #{key_id.inspect}, known: #{@keyring.keys}" }
1616

17-
return if case key
18-
when OpenSSL::PKey::EC
19-
key.verify("SHA256", signature, data)
20-
when OpenSSL::PKey::RSA
21-
key.verify("SHA256", signature, data, { rsa_padding_mode: "pkcs1" })
22-
else
23-
raise "unsupported key type #{key}"
24-
end
17+
return true \
18+
if case key
19+
when OpenSSL::PKey::EC
20+
key.verify("SHA256", signature, data)
21+
when OpenSSL::PKey::RSA
22+
key.verify("SHA256", signature, data, { rsa_padding_mode: "pkcs1" })
23+
else
24+
raise "unsupported key type #{key}"
25+
end
2526

26-
raise("invalid signature: #{signature.inspect} over #{data.inspect} with key #{key_id}")
27+
raise("invalid signature: #{signature.inspect} over #{data.inspect} with key #{key_id.inspect}")
2728
end
2829
end
2930
end

lib/sigstore/models.rb

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,15 @@ def self.from_bundle(input:, bundle:, offline:)
143143
raise "Unsupported bundle format: #{media_type}"
144144
end
145145

146-
raise "DSSE envelope verification not yet supported" if bundle.dsse_envelope
147-
raise "bundle missing message signature" unless bundle.message_signature
148-
149-
signature = bundle.message_signature.signature
146+
case bundle.content
147+
when :message_signature
148+
signature = bundle.message_signature.signature
149+
when :dsse_envelope
150+
# TODO: handle DSSE envelope
151+
raise "DSSE envelope verification not yet supported: #{bundle.dsse_envelope.inspect}"
152+
else
153+
raise "Unsupported bundle content: #{bundle.content}"
154+
end
150155

151156
tlog_entries = bundle.verification_material.tlog_entries
152157
raise "Expected one tlog entry" if tlog_entries.size != 1
@@ -216,12 +221,23 @@ def self.cert_is_ca?(cert)
216221

217222
raise "invalid X.509 certificate: non-critical BasicConstraints in CA" unless basic_constraints.critical?
218223

219-
ca = basic_constraints.value.ca
224+
seq = OpenSSL::ASN1.decode(basic_constraints.value_der)
225+
raise "invalid X.509 certificate: BasicConstraints is not a sequence" unless seq.is_a?(OpenSSL::ASN1::Sequence)
226+
227+
ca, _path_len = seq.value
228+
raise "invalid X.509 certificate: ca is not a boolean" unless ca.is_a?(OpenSSL::ASN1::Boolean)
229+
230+
ca = ca.value
220231

221232
key_usage = cert.find_extension("keyUsage")
222233
raise "invalid X.509 certificate: missing keyUsage" unless key_usage
223234

224-
key_sign_cert = key_usage.value.key_sign_cert
235+
key_usage_bs = OpenSSL::ASN1.decode(key_usage.value_der)
236+
unless key_usage_bs.is_a?(OpenSSL::ASN1::BitString)
237+
raise "invalid X.509 certificate: keyUsage is not a bit string"
238+
end
239+
240+
key_sign_cert = key_usage_bs.value.getbyte(0).allbits?(0b00000100) # KeyUsage.keyCertSign, bit 5
225241

226242
return true if ca && key_sign_cert
227243

lib/sigstore/rekor/client.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ module Rekor
99
class Client
1010
DEFAULT_REKOR_URL = "https://rekor.sigstore.dev"
1111

12-
attr_reader :rekor_keyring
12+
attr_reader :rekor_keyring, :ct_keyring
1313

1414
def initialize(url:, rekor_keyring:, ct_keyring:)
1515
@url = URI.join(url, "api/v1/")

lib/sigstore/trusted_root.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
require "delegate"
44
require "json"
5-
require "protobug_sigstore_protos"
65
require "openssl"
76

7+
require "protobug_sigstore_protos"
8+
89
require_relative "tuf"
910

1011
module Sigstore
@@ -54,11 +55,13 @@ def fulcio_cert_chain
5455

5556
private
5657

58+
# TODO: why not return the whole Sigstore::TrustRoot::V1::TransparencyLogInstance ?
59+
# it has the log id, hash algorithm, public key, and validity range
5760
def tlog_keys(tlogs)
5861
return enum_for(__method__, tlogs) unless block_given?
5962

60-
tlogs.each do |key|
61-
key_bytes = key.public_key.raw_bytes
63+
tlogs.each do |transparency_log_instance|
64+
key_bytes = transparency_log_instance.public_key.raw_bytes
6265
yield key_bytes if key_bytes
6366
end
6467
end

lib/sigstore/verifier.rb

Lines changed: 198 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,21 @@ def verify(materials:, policy:)
3939
)
4040
end
4141

42-
chain = store_ctx.chain || raise
43-
chain.drop(1)
42+
chain = store_ctx.chain || raise("no chain found")
43+
chain.shift # remove the cert itself
4444

45-
_sct = precertificate_signed_certificate_timestamps(materials.certificate)[0]
46-
# verify_sct(
47-
# sct,
48-
# materials.certificate,
49-
# chain,
50-
# @rekor_client._ct_keyring
51-
# )
45+
sct_list = precertificate_signed_certificate_timestamps(materials.certificate)
46+
raise "no SCTs found" if sct_list.empty?
47+
48+
sct_list.each do |sct|
49+
verified = verify_sct(
50+
sct,
51+
materials.certificate,
52+
chain,
53+
@rekor_client.ct_keyring
54+
)
55+
return VerificationFailure.new("SCT verification failed") unless verified
56+
end
5257

5358
usage_ext = materials.certificate.find_extension("keyUsage")
5459
unless usage_ext.value == "Digital Signature"
@@ -66,7 +71,7 @@ def verify(materials:, policy:)
6671
signing_key = materials.certificate.public_key
6772

6873
raise "missing hashed input" unless materials.hashed_input
69-
raise "missing signature" unless materials.signature
74+
raise "missing signature" unless materials.signature # TODO: handle DSSE envelope
7075
raise "missing input bytes" unless materials.input_bytes
7176

7277
verified = signing_key.verify(materials.hashed_input.name, materials.signature,
@@ -95,12 +100,110 @@ def verify(materials:, policy:)
95100

96101
private
97102

103+
def verify_sct(sct, certificate, chain, ct_keyring)
104+
# TODO: validate hash & signature algorithm match the key in the keyring
105+
hash = sct.fetch(:hash)
106+
signature_algorithm = sct.fetch(:signature_algorithm)
107+
unless hash == "sha256" && signature_algorithm == "ecdsa"
108+
# TODO: support more algorithms
109+
raise "only sha256 edcsa supported, got #{hash} #{signature_algorithm}"
110+
end
111+
112+
issuer_key_id = nil
113+
if sct[:entry_type] == 1
114+
issuer_cert = find_issuer_cert(chain)
115+
issuer_pubkey = issuer_cert.public_key
116+
unless VerificationMaterials.cert_is_ca?(issuer_cert)
117+
raise "Invalid issuer pubkey basicConstraint (not a CA): #{issuer_cert.to_text}"
118+
end
119+
raise "unsupported issuer pubkey" unless case issuer_pubkey
120+
when OpenSSL::PKey::RSA, OpenSSL::PKey::EC
121+
true
122+
else
123+
false
124+
end
125+
126+
issuer_key_id = OpenSSL::Digest::SHA256.digest(issuer_pubkey.public_to_der)
127+
end
128+
129+
digitally_signed = pack_digitally_signed(sct, certificate, issuer_key_id).b
130+
131+
ct_keyring.verify(key_id: sct[:log_id], signature: sct[:signature], data: digitally_signed)
132+
end
133+
134+
def pack_digitally_signed(sct, certificate, issuer_key_id = nil)
135+
# https://datatracker.ietf.org/doc/html/rfc6962#section-3.4
136+
# https://datatracker.ietf.org/doc/html/rfc6962#section-3.5
137+
#
138+
# digitally-signed struct {
139+
# Version sct_version;
140+
# SignatureType signature_type = certificate_timestamp;
141+
# uint64 timestamp;
142+
# LogEntryType entry_type;
143+
# select(entry_type) {
144+
# case x509_entry: ASN.1Cert;
145+
# case precert_entry: PreCert;
146+
# } signed_entry;
147+
# CtExtensions extensions;
148+
# };
149+
150+
signed_entry =
151+
case sct[:entry_type]
152+
when 0 # x509_entry
153+
cert_der = certificate.to_public_der
154+
cert_len = cert_der.bytesize
155+
unused, len1, len2, len3 = [cert_len].pack("N").unpack("C4")
156+
raise "invalid cert_len #{cert_len} #{cert_der.inspect}" if unused != 0
157+
158+
[len1, len2, len3, cert_der].pack("CCC a#{cert_len}")
159+
when 1 # precert_entry
160+
unless issuer_key_id&.bytesize == 32
161+
raise "issuer_key_id must be 32 bytes for precert, given #{issuer_key_id.inspect}"
162+
end
163+
164+
tbs_cert = tbs_certificate_der(certificate)
165+
tbs_cert_len = tbs_cert.bytesize
166+
unused, len1, len2, len3 = [tbs_cert_len].pack("N").unpack("C4")
167+
raise "invalid tbs_cert_len #{tbs_cert_len} #{tbs_cert.inspect}" if unused != 0
168+
169+
[issuer_key_id, len1, len2, len3, tbs_cert].pack("a32 CCC a#{tbs_cert_len}")
170+
else
171+
raise "only x509_entry and precert_entry supported, given #{sct[:entry_type].inspect}"
172+
end
173+
174+
[sct[:version], 0, sct[:timestamp], sct[:entry_type], signed_entry, 0].pack(<<~PACK)
175+
C # version
176+
C # signature_type
177+
Q> # timestamp
178+
n # entry_type
179+
a#{signed_entry.bytesize} # signed_entry
180+
n # extensions length
181+
PACK
182+
end
183+
184+
def tbs_certificate_der(certificate)
185+
tbs_cert = certificate.dup
186+
oid = OpenSSL::X509::Extension.new("1.3.6.1.4.1.11129.2.4.2", "").oid
187+
tbs_cert.extensions = tbs_cert.extensions.reject do |ext|
188+
ext.oid == oid
189+
end
190+
# ensure the underlying certificate is marked as modified
191+
tbs_cert.serial = tbs_cert.serial + 1
192+
tbs_cert.serial = tbs_cert.serial - 1
193+
194+
raise "no #{oid} extension found" unless certificate.extensions.size == tbs_cert.extensions.size + 1
195+
196+
OpenSSL::ASN1.decode(tbs_cert.to_der).value[0].to_der.b
197+
end
198+
199+
# https://letsencrypt.org/2018/04/04/sct-encoding.html
98200
def precertificate_signed_certificate_timestamps(certificate)
99201
# this is cursed. can't always find_extension(oid) because #oid can return a string or an OID
100202
oid = OpenSSL::X509::Extension.new("1.3.6.1.4.1.11129.2.4.2", "").oid
101203
precert_scts_extension = certificate.find_extension(oid)
204+
102205
unless precert_scts_extension
103-
raise "No PrecertificateSignedCertificateTimestamps (#{oid.inspect}) found for the certificate #{certificate.extensions.join("\n")}"
206+
raise "No PrecertificateSignedCertificateTimestamps (#{oid.inspect}) found for the certificate #{certificate.to_text}"
104207
end
105208

106209
# TODO: parse the extension properly
@@ -109,22 +212,94 @@ def precertificate_signed_certificate_timestamps(certificate)
109212
os1 = OpenSSL::ASN1.decode(precert_scts_extension.value_der)
110213

111214
len = os1.value.unpack1("n")
112-
string = os1.value[2..]
113-
raise "os1: len=#{len} #{os1.value.inspect}" unless string && string.size == len
215+
string = os1.value.byteslice(2..)
216+
raise "os1: len=#{len} #{os1.value.inspect}" unless string && string.bytesize == len
114217

115218
len = string.unpack1("n")
116-
string = string[2..]
117-
raise "os1: len=#{len} #{string.inspect}" unless string && string.size == len
118-
119-
sct_version, sct_log_id, sct_timestamp, sct_extensions_len, sct_signature_alg_hash,
120-
sct_signature_alg_sign, sct_signature_len, sct_signature_bytes = string.unpack("Ca32QnCCna*")
121-
raise "sct extensions not supported" unless sct_extensions_len.zero?
122-
unless sct_signature_bytes.bytesize == sct_signature_len
123-
raise "sct_signature_bytes: #{sct_signature_bytes.inspect} sct_signature_len: #{sct_signature_len}"
219+
string = string.byteslice(2..)
220+
raise "os2: len=#{len} #{string.inspect}" unless string && string.bytesize == len
221+
222+
list = unpack_sct_list(string)
223+
224+
list.map! do |sct|
225+
hash = {
226+
0 => "none",
227+
1 => "md5",
228+
2 => "sha1",
229+
3 => "sha224",
230+
4 => "sha256",
231+
5 => "sha384",
232+
6 => "sha512",
233+
255 => "unknown"
234+
}.fetch(sct[:sct_signature_alg_hash], "unknown")
235+
236+
signature_algorithm = {
237+
0 => "anonymous",
238+
1 => "rsa",
239+
2 => "dsa",
240+
3 => "ecdsa",
241+
255 => "unknown"
242+
}.fetch(sct[:sct_signature_alg_sign], "unknown")
243+
244+
{
245+
version: sct[:sct_version],
246+
log_id: sct[:sct_log_id].unpack1("H*"),
247+
timestamp: sct[:sct_timestamp],
248+
signature: sct[:sct_signature_bytes],
249+
hash: hash,
250+
signature_algorithm: signature_algorithm,
251+
entry_type: 1 # precert_entry
252+
}
253+
end
254+
end
255+
256+
def unpack_sct_list(string)
257+
offset = 0
258+
len = string.bytesize
259+
list = []
260+
while offset < len
261+
sct_version, sct_log_id, sct_timestamp, sct_extensions_len = string.unpack("Ca32Q>n", offset: offset)
262+
offset += 1 + 32 + 8 + 2 + sct_extensions_len
263+
raise "expect sct version to be 0, got #{sct_version}" unless sct_version.zero?
264+
raise "sct_extensions_len=#{sct_extensions_len} not supported" unless sct_extensions_len.zero?
265+
266+
sct_signature_alg_hash, sct_signature_alg_sign, sct_signature_len = string.unpack("CCn", offset: offset)
267+
offset += 1 + 1 + 2
268+
sct_signature_bytes = string.unpack1("a#{sct_signature_len}", offset: offset).b
269+
offset += sct_signature_len
270+
list << {
271+
sct_version: sct_version,
272+
sct_log_id: sct_log_id,
273+
sct_timestamp: sct_timestamp,
274+
sct_extensions_len: sct_extensions_len,
275+
sct_signature_alg_hash: sct_signature_alg_hash,
276+
sct_signature_alg_sign: sct_signature_alg_sign,
277+
sct_signature_len: sct_signature_len,
278+
sct_signature_bytes: sct_signature_bytes
279+
}
124280
end
281+
raise "offset=#{offset} len=#{len}" unless offset == len
282+
283+
list
284+
end
285+
286+
def find_issuer_cert(chain)
287+
issuer = chain[0]
288+
issuer = chain[1] if preissuer?(issuer)
289+
raise "issuer not found" unless issuer
125290

126-
# TODO: parse the SCT properly
127-
[nil]
291+
issuer
292+
end
293+
294+
def preissuer?(cert)
295+
return false unless (eku = cert.find_extension("extendedKeyUsage"))
296+
297+
values = OpenSSL::ASN1.decode(eku.value_der).value
298+
raise values.inspect unless values.is_a?(Array)
299+
300+
values.any? do
301+
_1.oid == "1.3.6.1.4.1.11129.2.4.4"
302+
end
128303
end
129304
end
130305
end

0 commit comments

Comments
 (0)