sigstore 0.1.1

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/CODEOWNERS +6 -0
  4. data/LICENSE +201 -0
  5. data/README.md +26 -0
  6. data/data/_store/prod/root.json +165 -0
  7. data/data/_store/prod/trusted_root.json +114 -0
  8. data/data/_store/staging/root.json +107 -0
  9. data/data/_store/staging/trusted_root.json +87 -0
  10. data/lib/sigstore/error.rb +43 -0
  11. data/lib/sigstore/internal/json.rb +53 -0
  12. data/lib/sigstore/internal/key.rb +183 -0
  13. data/lib/sigstore/internal/keyring.rb +42 -0
  14. data/lib/sigstore/internal/merkle.rb +117 -0
  15. data/lib/sigstore/internal/set.rb +42 -0
  16. data/lib/sigstore/internal/util.rb +52 -0
  17. data/lib/sigstore/internal/x509.rb +460 -0
  18. data/lib/sigstore/models.rb +272 -0
  19. data/lib/sigstore/oidc.rb +149 -0
  20. data/lib/sigstore/policy.rb +104 -0
  21. data/lib/sigstore/rekor/checkpoint.rb +114 -0
  22. data/lib/sigstore/rekor/client.rb +136 -0
  23. data/lib/sigstore/signer.rb +280 -0
  24. data/lib/sigstore/trusted_root.rb +116 -0
  25. data/lib/sigstore/tuf/config.rb +46 -0
  26. data/lib/sigstore/tuf/error.rb +49 -0
  27. data/lib/sigstore/tuf/file.rb +96 -0
  28. data/lib/sigstore/tuf/keys.rb +42 -0
  29. data/lib/sigstore/tuf/roles.rb +106 -0
  30. data/lib/sigstore/tuf/root.rb +53 -0
  31. data/lib/sigstore/tuf/snapshot.rb +45 -0
  32. data/lib/sigstore/tuf/targets.rb +84 -0
  33. data/lib/sigstore/tuf/timestamp.rb +39 -0
  34. data/lib/sigstore/tuf/trusted_metadata_set.rb +193 -0
  35. data/lib/sigstore/tuf/updater.rb +267 -0
  36. data/lib/sigstore/tuf.rb +158 -0
  37. data/lib/sigstore/verifier.rb +492 -0
  38. data/lib/sigstore/version.rb +19 -0
  39. data/lib/sigstore.rb +44 -0
  40. metadata +128 -0
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2024 The Sigstore Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require_relative "error"
18
+
19
+ require_relative "trusted_root"
20
+
21
+ module Sigstore
22
+ VerificationResult = Struct.new(:success, keyword_init: true) do
23
+ # @implements VerificationResult
24
+
25
+ alias_method :verified?, :success
26
+ end
27
+
28
+ class VerificationSuccess < VerificationResult
29
+ # @implements VerificationSuccess
30
+ def initialize
31
+ super(success: true)
32
+ end
33
+ end
34
+
35
+ class VerificationFailure < VerificationResult
36
+ # @implements VerificationFailure
37
+ attr_reader :reason
38
+
39
+ def initialize(reason)
40
+ @reason = reason
41
+ super(success: false)
42
+ end
43
+ end
44
+
45
+ class BundleType
46
+ include Comparable
47
+
48
+ attr_reader :media_type
49
+
50
+ def initialize(media_type)
51
+ @media_type = media_type
52
+ end
53
+
54
+ BUNDLE_0_1 = new("application/vnd.dev.sigstore.bundle+json;version=0.1")
55
+ BUNDLE_0_2 = new("application/vnd.dev.sigstore.bundle+json;version=0.2")
56
+ BUNDLE_0_3 = new("application/vnd.dev.sigstore.bundle.v0.3+json")
57
+
58
+ VERSIONS = [BUNDLE_0_1, BUNDLE_0_2, BUNDLE_0_3].freeze
59
+
60
+ def self.from_media_type(media_type)
61
+ case media_type
62
+ when BUNDLE_0_1.media_type
63
+ BUNDLE_0_1
64
+ when BUNDLE_0_2.media_type
65
+ BUNDLE_0_2
66
+ when BUNDLE_0_3.media_type, "application/vnd.dev.sigstore.bundle+json;version=0.3"
67
+ BUNDLE_0_3
68
+ else
69
+ raise Error::InvalidBundle, "Unsupported bundle format: #{media_type.inspect}"
70
+ end
71
+ end
72
+
73
+ def <=>(other)
74
+ VERSIONS.index(self) <=> VERSIONS.index(other)
75
+ end
76
+ end
77
+
78
+ class VerificationInput < DelegateClass(Verification::V1::Input)
79
+ attr_reader :trusted_root, :sbundle, :hashed_input
80
+
81
+ def initialize(*)
82
+ super
83
+ @trusted_root = TrustedRoot.new(artifact_trust_root)
84
+ @sbundle = SBundle.new(bundle)
85
+ if sbundle.message_signature? && !artifact
86
+ raise Error::InvalidVerificationInput, "bundle with message_signature requires an artifact"
87
+ end
88
+
89
+ case artifact.data
90
+ when :artifact_uri
91
+ unless artifact.artifact_uri.start_with?("sha256:")
92
+ raise Error::InvalidVerificationInput,
93
+ "artifact_uri must be prefixed with 'sha256:'"
94
+ end
95
+
96
+ @hashed_input = Common::V1::HashOutput.new.tap do |hash_output|
97
+ hash_output.algorithm = Common::V1::HashAlgorithm::SHA2_256
98
+ hexdigest = artifact.artifact_uri.split(":", 2).last
99
+ hash_output.digest = Internal::Util.hex_decode(hexdigest)
100
+ end
101
+ when :artifact
102
+ @hashed_input = Common::V1::HashOutput.new.tap do |hash_output|
103
+ hash_output.algorithm = Common::V1::HashAlgorithm::SHA2_256
104
+ hash_output.digest = OpenSSL::Digest.new("SHA256").update(artifact.artifact).digest
105
+ end
106
+ else
107
+ raise Error::InvalidVerificationInput, "Unsupported artifact data: #{artifact.data}"
108
+ end
109
+
110
+ freeze
111
+ end
112
+ end
113
+
114
+ class SBundle < DelegateClass(Bundle::V1::Bundle)
115
+ attr_reader :bundle_type, :leaf_certificate
116
+
117
+ def initialize(*)
118
+ super
119
+ @bundle_type = BundleType.from_media_type(media_type)
120
+ validate_version!
121
+ freeze
122
+ end
123
+
124
+ def self.for_cert_bytes_and_signature(cert_bytes, signature)
125
+ bundle = Bundle::V1::Bundle.new
126
+ bundle.media_type = BundleType::BUNDLE_0_3.media_type
127
+ bundle.verification_material = Bundle::V1::VerificationMaterial.new
128
+ bundle.verification_material.certificate = Common::V1::X509Certificate.new
129
+ bundle.verification_material.certificate.raw_bytes = cert_bytes
130
+ bundle.message_signature = Common::V1::MessageSignature.new
131
+ bundle.message_signature.signature = signature
132
+ new(bundle)
133
+ end
134
+
135
+ def expected_tlog_entry(hashed_input)
136
+ case content
137
+ when :message_signature
138
+ expected_hashed_rekord_tlog_entry(hashed_input)
139
+ when :dsse_envelope
140
+ rekor_entry = verification_material.tlog_entries.first
141
+ case JSON.parse(rekor_entry.canonicalized_body).values_at("kind", "apiVersion")
142
+ when %w[dsse 0.0.1]
143
+ expected_dsse_0_0_1_tlog_entry
144
+ when %w[intoto 0.0.2]
145
+ expected_intoto_0_0_2_tlog_entry
146
+ else
147
+ raise Error::InvalidRekorEntry, "Unhandled rekor entry kind/version: #{t.inspect}"
148
+ end
149
+ else
150
+ raise Error::InvalidBundle, "expected either message_signature or dsse_envelope"
151
+ end
152
+ end
153
+
154
+ private
155
+
156
+ def validate_version!
157
+ case bundle_type
158
+ when BundleType::BUNDLE_0_1
159
+ unless verification_material.tlog_entries.all?(&:inclusion_promise)
160
+ raise Error::InvalidBundle,
161
+ "bundle v0.1 requires an inclusion promise"
162
+ end
163
+ if verification_material.tlog_entries.any? { |t| t.inclusion_proof&.checkpoint.nil? }
164
+ raise Error::InvalidBundle,
165
+ "0.1 bundle contains an inclusion proof without checkpoint"
166
+ end
167
+ else
168
+ unless verification_material.tlog_entries.all?(&:inclusion_proof)
169
+ raise Error::InvalidBundle,
170
+ "must contain an inclusion proof"
171
+ end
172
+ unless verification_material.tlog_entries.all? { |t| t.inclusion_proof.checkpoint.envelope }
173
+ raise Error::InvalidBundle,
174
+ "inclusion proof must contain a checkpoint"
175
+ end
176
+ end
177
+
178
+ raise Error::InvalidBundle, "Expected one tlog entry" if verification_material.tlog_entries.size > 1
179
+
180
+ case verification_material.content
181
+ when :public_key
182
+ raise Error::Unimplemented, "public_key content of bundle"
183
+ when :x509_certificate_chain
184
+ certs = verification_material.x509_certificate_chain.certificates.map do |cert|
185
+ Internal::X509::Certificate.read(cert.raw_bytes)
186
+ end
187
+
188
+ @leaf_certificate = certs.first
189
+ certs.each do |cert|
190
+ raise Error::InvalidBundle, "Root CA in chain" if cert.ca?
191
+ end
192
+ when :certificate
193
+ @leaf_certificate = Internal::X509::Certificate.read(verification_material.certificate.raw_bytes)
194
+ else
195
+ raise Error::InvalidBundle, "Unsupported bundle content: #{content}"
196
+ end
197
+ raise Error::InvalidBundle, "Expected leaf certificate" unless @leaf_certificate.leaf?
198
+ end
199
+
200
+ def expected_hashed_rekord_tlog_entry(hashed_input)
201
+ {
202
+ "spec" => {
203
+ "signature" => {
204
+ "content" => Internal::Util.base64_encode(message_signature.signature),
205
+ "publicKey" => {
206
+ "content" => Internal::Util.base64_encode(leaf_certificate.to_pem)
207
+ }
208
+ },
209
+ "data" => {
210
+ "hash" => {
211
+ "algorithm" => Internal::Util.hash_algorithm_name(hashed_input.algorithm),
212
+ "value" => Internal::Util.hex_encode(hashed_input.digest)
213
+ }
214
+ }
215
+ },
216
+ "kind" => "hashedrekord",
217
+ "apiVersion" => "0.0.1"
218
+ }
219
+ end
220
+
221
+ def expected_intoto_0_0_2_tlog_entry
222
+ {
223
+ "apiVersion" => "0.0.2",
224
+ "kind" => "intoto",
225
+ "spec" => {
226
+ "content" => {
227
+ "envelope" => {
228
+ "payloadType" => dsse_envelope.payloadType,
229
+ "payload" => Internal::Util.base64_encode(Internal::Util.base64_encode(dsse_envelope.payload)),
230
+ "signatures" => dsse_envelope.signatures.map do |sig|
231
+ {
232
+ "publicKey" =>
233
+ # needed because #to_pem packs the key in base64 with m*
234
+ Internal::Util.base64_encode(
235
+ "-----BEGIN CERTIFICATE-----\n" \
236
+ "#{Internal::Util.base64_encode(leaf_certificate.to_der)}\n" \
237
+ "-----END CERTIFICATE-----\n"
238
+ ),
239
+ "sig" => Internal::Util.base64_encode(Internal::Util.base64_encode(sig.sig))
240
+ }
241
+ end
242
+ },
243
+ "payloadHash" => {
244
+ "algorithm" => "sha256",
245
+ "value" => OpenSSL::Digest::SHA256.hexdigest(dsse_envelope.payload)
246
+ }
247
+ }
248
+ }
249
+ }
250
+ end
251
+
252
+ def expected_dsse_0_0_1_tlog_entry
253
+ {
254
+ "apiVersion" => "0.0.1",
255
+ "kind" => "dsse",
256
+ "spec" => {
257
+ "payloadHash" => {
258
+ "algorithm" => "sha256",
259
+ "value" => OpenSSL::Digest::SHA256.hexdigest(dsse_envelope.payload)
260
+ },
261
+ "signatures" =>
262
+ dsse_envelope.signatures.map do |sig|
263
+ {
264
+ "signature" => Internal::Util.base64_encode(sig.sig),
265
+ "verifier" => Internal::Util.base64_encode(leaf_certificate.to_pem)
266
+ }
267
+ end
268
+ }
269
+ }
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2024 The Sigstore Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Sigstore
18
+ module OIDC
19
+ KNOWN_OIDC_ISSUERS = {
20
+ "https://accounts.google.com" => "email",
21
+ "https://oauth2.sigstore.dev/auth" => "email",
22
+ "https://oauth2.sigstage.dev/auth" => "email",
23
+ "https://token.actions.githubusercontent.com" => "job_workflow_ref"
24
+ }.freeze
25
+ private_constant :KNOWN_OIDC_ISSUERS
26
+
27
+ DEFAULT_AUDIENCE = "sigstore"
28
+ private_constant :DEFAULT_AUDIENCE
29
+
30
+ class IdentityToken
31
+ attr_reader :raw_token, :identity
32
+
33
+ def initialize(raw_token)
34
+ @raw_token = raw_token
35
+
36
+ @unverified_claims = self.class.decode_jwt(raw_token)
37
+ @iss = @unverified_claims["iss"]
38
+ @nbf = @unverified_claims["nbf"]
39
+ @exp = @unverified_claims["exp"]
40
+
41
+ # fail early if this token isn't within its validity period
42
+ raise Error::InvalidIdentityToken, "identity token is not within its validity period" unless in_validity_period?
43
+
44
+ if (identity_claim = KNOWN_OIDC_ISSUERS[issuer])
45
+ unless @unverified_claims[identity_claim]
46
+ raise Error::InvalidIdentityToken, "identity token is missing required claim: #{identity_claim}"
47
+ end
48
+
49
+ @identity = @unverified_claims[identity_claim]
50
+ # https://github.com/sigstore/fulcio/blob/8311f93c01ea5b068a86d37c4bb51573289bfd69/pkg/identity/github/principal.go#L92
51
+ @identity = "https://github.com/#{@identity}" if issuer == "https://token.actions.githubusercontent.com"
52
+ else
53
+ @identity = @unverified_claims["sub"]
54
+ end
55
+ end
56
+
57
+ def issuer
58
+ @iss
59
+ end
60
+
61
+ def self.decode_jwt(raw_token)
62
+ # These claims are required by OpenID Connect, so
63
+ # we can strongly enforce their presence.
64
+ # See: https://openid.net/specs/openid-connect-basic-1_0.html#IDToken
65
+ required = %w[aud sub iat exp iss]
66
+ audience = DEFAULT_AUDIENCE
67
+ leeway = 5
68
+
69
+ _header, payload, _signature =
70
+ raw_token
71
+ .split(".", 3)
72
+ .tap do |parts|
73
+ raise Error::InvalidIdentityToken, "identity token is not a JWT" unless parts.length == 3
74
+ end.map! do |part| # rubocop:disable Style/MultilineBlockChain
75
+ part.unpack1("m*")
76
+ rescue ArgumentError
77
+ raise Error::InvalidIdentityToken, "Invalid base64 in identity token"
78
+ end
79
+
80
+ begin
81
+ payload = JSON.parse(payload)
82
+ rescue JSON::ParserError
83
+ raise Error::InvalidIdentityToken, "Invalid JSON in identity token"
84
+ end
85
+ unless payload.is_a?(Hash)
86
+ raise Error::InvalidIdentityToken,
87
+ "Invalid JSON in identity token: must be a json object"
88
+ end
89
+ time = Time.now.to_i
90
+ validate_required_claims(payload, required)
91
+ validate_iat(payload["iat"], time, leeway)
92
+ validate_nbf(payload["nbf"], time, leeway)
93
+ validate_exp(payload["exp"], time, leeway)
94
+ validate_aud(payload["aud"], audience)
95
+
96
+ payload
97
+ end
98
+
99
+ private
100
+
101
+ # Returns whether or not this `Identity` is currently within its self-stated validity period.
102
+ def in_validity_period?
103
+ now = Time.now.utc.to_i
104
+ return false if @nbf && @nbf > now
105
+
106
+ now < @exp
107
+ end
108
+
109
+ class << self
110
+ private
111
+
112
+ def validate_required_claims(payload, required)
113
+ required.each do |claim|
114
+ next if payload[claim]
115
+
116
+ raise Error::InvalidIdentityToken, "Missing required claim in identity token: #{claim}"
117
+ end
118
+ end
119
+
120
+ def validate_iat(iat, now, leeway)
121
+ raise Error::InvalidIdentityToken, "iat claim must be an integer" unless iat.is_a?(Integer)
122
+ raise Error::InvalidIdentityToken, "iat claim is in the future" if iat > now + leeway
123
+ end
124
+
125
+ def validate_nbf(nbf, now, leeway)
126
+ raise Error::InvalidIdentityToken, "nbf claim must be an integer" unless nbf.is_a?(Integer)
127
+ raise Error::InvalidIdentityToken, "nbf claim is in the future" if nbf > now + leeway
128
+ end
129
+
130
+ def validate_exp(exp, now, leeway)
131
+ raise Error::InvalidIdentityToken, "exp claim must be an integer" unless exp.is_a?(Integer)
132
+ raise Error::InvalidIdentityToken, "exp claim is in the past" if exp <= now - leeway
133
+ end
134
+
135
+ def validate_aud(aud, audience)
136
+ aud = Array(aud)
137
+
138
+ raise Error::InvalidIdentityToken, "aud claim must not be empty" if aud.empty?
139
+ raise Error::InvalidIdentityToken, "aud claim must be strings" unless aud.all?(String)
140
+
141
+ return if aud.include?(audience)
142
+
143
+ raise Error::InvalidIdentityToken,
144
+ "aud claim does not contain the expected audience #{audience.inspect}"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2024 The Sigstore Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Sigstore
18
+ module Policy
19
+ class SingleX509ExtPolicy
20
+ def initialize(value)
21
+ @value = value
22
+ end
23
+
24
+ def verify(cert)
25
+ ext = cert.openssl.find_extension(oid)
26
+ unless ext
27
+ return VerificationFailure.new("Certificate does not contain #{self.class.name&.[](/::([^:]+)$/, 1)} " \
28
+ "(#{oid}) extension")
29
+ end
30
+
31
+ value = ext_value(ext)
32
+ verified = value == @value
33
+ unless verified
34
+ return VerificationFailure.new("Certificate's #{self.class.name&.[](/::([^:]+)$/, 1)} does not match " \
35
+ "(got #{value}, expected #{@value})")
36
+ end
37
+
38
+ VerificationSuccess.new
39
+ end
40
+
41
+ def ext_value(ext)
42
+ ext.value
43
+ end
44
+
45
+ def oid
46
+ self.class::OID # : String
47
+ end
48
+ end
49
+
50
+ class OIDCIssuer < SingleX509ExtPolicy
51
+ OID = "1.3.6.1.4.1.57264.1.1"
52
+ end
53
+
54
+ class OIDCIssuerV2 < SingleX509ExtPolicy
55
+ OID = "1.3.6.1.4.1.57264.1.8"
56
+
57
+ def ext_value(ext)
58
+ OpenSSL::ASN1.decode(ext.value_der).value
59
+ end
60
+ end
61
+
62
+ class AnyOf
63
+ def initialize(*policies)
64
+ @policies = policies
65
+ end
66
+
67
+ def verify(cert)
68
+ failures = []
69
+ @policies.each do |policy|
70
+ result = policy.verify(cert)
71
+ return result if result.verified?
72
+
73
+ failures << result.reason
74
+ end
75
+
76
+ VerificationFailure.new("No policy matched: #{failures.join(", ")}")
77
+ end
78
+ end
79
+
80
+ class Identity
81
+ def initialize(identity:, issuer:)
82
+ @identity = identity
83
+ @issuer = AnyOf.new(OIDCIssuer.new(issuer), OIDCIssuerV2.new(issuer))
84
+ end
85
+
86
+ def verify(cert)
87
+ issuer_verified = @issuer.verify(cert)
88
+ return issuer_verified unless issuer_verified.verified?
89
+
90
+ san_ext = cert.extension(Sigstore::Internal::X509::Extension::SubjectAlternativeName)
91
+ raise Error::InvalidCertificate, "Certificate does not contain subjectAltName extension" unless san_ext
92
+
93
+ verified = san_ext.general_names.any? { |_, id| id == @identity }
94
+ unless verified
95
+ return VerificationFailure.new(
96
+ "Certificate's SANs do not match #{@identity}; actual SANs: #{san_ext.general_names}"
97
+ )
98
+ end
99
+
100
+ VerificationSuccess.new
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2024 The Sigstore Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module Sigstore
18
+ module Rekor
19
+ module Checkpoint
20
+ Signature = Struct.new(:name, :sig_hash, :signature, keyword_init: true)
21
+
22
+ SignedCheckpoint = Struct.new(:signed_note, :checkpoint, keyword_init: true) do
23
+ # @implements SignedCheckpoint
24
+
25
+ def self.from_text(text)
26
+ signed_note = SignedNote.from_text(text)
27
+ checkpoint = LogCheckpoint.from_text(signed_note.note)
28
+
29
+ new(signed_note:, checkpoint:)
30
+ end
31
+ end
32
+
33
+ SignedNote = Struct.new(:note, :signatures, keyword_init: true) do
34
+ # @implements SignedNote
35
+
36
+ def self.from_text(text)
37
+ separator = "\n\n"
38
+
39
+ raise Error::InvalidCheckpoint, "Note must include double newline separator" unless text.include?(separator)
40
+
41
+ note, signatures = text.split(separator, 2)
42
+ raise Error::InvalidCheckpoint, "must contain at least one signature" if signatures.empty?
43
+ raise Error::InvalidCheckpoint, "signatures must end with a newline" unless signatures.end_with?("\n")
44
+
45
+ note << "\n"
46
+
47
+ sig_parser = %r{^\u2014 (?<name>[^[[:space:]]+]+) (?<signature>[0-9A-Za-z+/=-]+)\n}
48
+
49
+ signatures = signatures.lines.map! do |line|
50
+ raise Error::InvalidCertificate, "Invalid signature line: #{line.inspect}" unless sig_parser =~ line
51
+
52
+ name = Regexp.last_match[:name]
53
+ signature = Regexp.last_match[:signature]
54
+
55
+ signature_bytes = signature.unpack1("m0")
56
+ raise Error::InvalidCheckpoint, "too few bytes in signature" if signature_bytes.bytesize < 5
57
+
58
+ sig_hash = signature_bytes.slice!(0, 4).unpack1("a4")
59
+
60
+ Signature.new(name:, sig_hash:, signature: signature_bytes)
61
+ end
62
+
63
+ new(note:, signatures:)
64
+ end
65
+
66
+ def verify(rekor_keyring, key_id)
67
+ data = note.encode("utf-8")
68
+ signatures.each do |signature|
69
+ sig_hash = key_id[0, 4]
70
+ if signature.sig_hash != sig_hash
71
+ raise Error::InvalidCheckpoint,
72
+ "sig_hash hint #{signature.sig_hash.inspect} does not match key_id #{sig_hash.inspect}"
73
+ end
74
+
75
+ rekor_keyring.verify(key_id: key_id.unpack1("H*"), signature: signature.signature, data:)
76
+ end
77
+ end
78
+ end
79
+
80
+ LogCheckpoint = Struct.new(:origin, :log_size, :log_hash, :other_content, keyword_init: true) do
81
+ # @implements LogCheckpoint
82
+
83
+ def self.from_text(text)
84
+ lines = text.strip.split("\n")
85
+
86
+ raise Error::InvalidCheckpoint, "too few items in header" if lines.size < 3
87
+
88
+ origin = lines.shift
89
+ log_size = lines.shift.to_i
90
+ root_hash = lines.shift.unpack1("m0")
91
+
92
+ raise Error::InvalidCheckpoint, "empty origin" if origin.empty?
93
+
94
+ new(origin:, log_size:, log_hash: root_hash, other_content: lines)
95
+ end
96
+ end
97
+
98
+ def self.verify_checkpoint(rekor_keyring, entry)
99
+ raise Error::InvalidRekorEntry, "Rekor entry has no inclusion proof" unless entry.inclusion_proof
100
+
101
+ signed_checkpoint = SignedCheckpoint.from_text(entry.inclusion_proof.checkpoint.envelope)
102
+ signed_checkpoint.signed_note.verify(rekor_keyring, entry.log_id.key_id)
103
+
104
+ checkpoint_hash = signed_checkpoint.checkpoint.log_hash
105
+ root_hash = entry.inclusion_proof.root_hash
106
+
107
+ return if checkpoint_hash == root_hash
108
+
109
+ raise Error::InvalidRekorEntry, "Inclusion proof contains invalid root hash: " \
110
+ "expected #{checkpoint_hash.inspect}, calculated #{root_hash.inspect}"
111
+ end
112
+ end
113
+ end
114
+ end