|
1
|
+ |
defmodule OpenAgents.Reputation do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Portable, revocable reputation attestations for accepted outcomes.
|
|
4
|
+ |
|
|
5
|
+ |
An attestation is a signed claim that one subject completed, verified,
|
|
6
|
+ |
reviewed, was paid for, or lost credit for one accepted outcome, under one
|
|
7
|
+ |
admitted verifier policy, in one repository, at one revision. It is scoped
|
|
8
|
+ |
evidence a stranger can check, never a global social score: nothing here
|
|
9
|
+ |
derives credit from presence, token volume, online time, or narration, and
|
|
10
|
+ |
no function returns a universal ranking.
|
|
11
|
+ |
|
|
12
|
+ |
The context owns four operations:
|
|
13
|
+ |
|
|
14
|
+ |
* `admit_policy/1` and `admit_key/1` record the verifier policy and the
|
|
15
|
+ |
issuer public key an attestation binds to.
|
|
16
|
+ |
* `issue/3` signs a claim, and only after the accepted-outcome contract it
|
|
17
|
+ |
names reached an admitted terminal state.
|
|
18
|
+ |
* `verify/2` recomputes the digest, checks the signature against the
|
|
19
|
+ |
admitted key, and reports policy, binding, evidence, and revocation
|
|
20
|
+ |
state. It trusts no column and no caller.
|
|
21
|
+ |
* `revoke/4` and `correct/4` publish a linked invalidating event.
|
|
22
|
+ |
|
|
23
|
+ |
Reads project the stored claim verbatim, so a client can verify an
|
|
24
|
+ |
attestation the forge serves without trusting the surface that displayed it.
|
|
25
|
+ |
"""
|
|
26
|
+ |
|
|
27
|
+ |
import Ecto.Query
|
|
28
|
+ |
|
|
29
|
+ |
alias OpenAgents.Compensation.OutcomeDecision
|
|
30
|
+ |
alias OpenAgents.Forge.Visibility
|
|
31
|
+ |
alias OpenAgents.Issues.Issue
|
|
32
|
+ |
alias OpenAgents.Provenance.Canonical
|
|
33
|
+ |
alias OpenAgents.Repo
|
|
34
|
+ |
alias OpenAgents.Repositories.Repository
|
|
35
|
+ |
alias OpenAgents.Reputation.{Attestation, Claim, PolicyReceipt, SigningKey}
|
|
36
|
+ |
|
|
37
|
+ |
@policy_id "openagents.reputation.verifier.v1"
|
|
38
|
+ |
@policy_version 1
|
|
39
|
+ |
@policy_rules %{
|
|
40
|
+ |
"unit" => "scoped_evidence",
|
|
41
|
+ |
"signature_algorithm" => "ed25519",
|
|
42
|
+ |
"event_types" => Attestation.event_types(),
|
|
43
|
+ |
"accepted_outcome_kinds" => ["compensation_outcome_decision"],
|
|
44
|
+ |
"accepted_terminal_state" => "accepted",
|
|
45
|
+ |
"minimum_confidence_ppm" => 500_000,
|
|
46
|
+ |
"evidence_max_age_seconds" => 7_776_000,
|
|
47
|
+ |
"evidence_kinds" => ["outcome", "issue", "repository", "attestation"],
|
|
48
|
+ |
"global_score" => false
|
|
49
|
+ |
}
|
|
50
|
+ |
|
|
51
|
+ |
@doc "The verifier policy identifier every attestation binds to."
|
|
52
|
+ |
def policy_id, do: @policy_id
|
|
53
|
+ |
|
|
54
|
+ |
@doc "The rules of the current verifier policy version."
|
|
55
|
+ |
def policy_rules, do: @policy_rules
|
|
56
|
+ |
|
|
57
|
+ |
@doc "The digest of one policy version's rules."
|
|
58
|
+ |
@spec policy_digest(String.t(), pos_integer(), map()) :: String.t()
|
|
59
|
+ |
def policy_digest(policy_id, version, rules) do
|
|
60
|
+ |
Canonical.digest!(%{"policy_id" => policy_id, "version" => version, "rules" => rules})
|
|
61
|
+ |
end
|
|
62
|
+ |
|
|
63
|
+ |
@doc """
|
|
64
|
+ |
Admits the current verifier policy version under operator authority.
|
|
65
|
+ |
|
|
66
|
+ |
The receipt is append-only, and its digest is what a client compares a
|
|
67
|
+ |
claim's `verifier.policy_digest` against.
|
|
68
|
+ |
"""
|
|
69
|
+ |
@spec admit_policy(map()) :: {:ok, PolicyReceipt.t()} | {:error, term()}
|
|
70
|
+ |
def admit_policy(operator) do
|
|
71
|
+ |
with :ok <- validate_operator(operator) do
|
|
72
|
+ |
%PolicyReceipt{}
|
|
73
|
+ |
|> PolicyReceipt.changeset(%{
|
|
74
|
+ |
policy_id: @policy_id,
|
|
75
|
+ |
version: @policy_version,
|
|
76
|
+ |
policy_digest: policy_digest(@policy_id, @policy_version, @policy_rules),
|
|
77
|
+ |
rules: @policy_rules,
|
|
78
|
+ |
actor_id: operator.actor_id,
|
|
79
|
+ |
auth_method: operator.auth_method,
|
|
80
|
+ |
approval_receipt_ref: operator.approval_receipt_ref
|
|
81
|
+ |
})
|
|
82
|
+ |
|> Repo.insert()
|
|
83
|
+ |
end
|
|
84
|
+ |
end
|
|
85
|
+ |
|
|
86
|
+ |
@doc "The admitted policy receipt for one version, if any."
|
|
87
|
+ |
@spec policy(String.t(), pos_integer()) :: PolicyReceipt.t() | nil
|
|
88
|
+ |
def policy(policy_id \\ @policy_id, version \\ @policy_version),
|
|
89
|
+ |
do: Repo.get_by(PolicyReceipt, policy_id: policy_id, version: version)
|
|
90
|
+ |
|
|
91
|
+ |
@doc "Every admitted policy version, oldest first."
|
|
92
|
+ |
@spec policies() :: [PolicyReceipt.t()]
|
|
93
|
+ |
def policies,
|
|
94
|
+ |
do:
|
|
95
|
+ |
Repo.all(
|
|
96
|
+ |
from receipt in PolicyReceipt, order_by: [asc: receipt.policy_id, asc: receipt.version]
|
|
97
|
+ |
)
|
|
98
|
+ |
|
|
99
|
+ |
@doc """
|
|
100
|
+ |
The published form of one policy version: the rules a client hashes to
|
|
101
|
+ |
reproduce `policy_digest` itself.
|
|
102
|
+ |
"""
|
|
103
|
+ |
@spec policy_projection(PolicyReceipt.t()) :: map()
|
|
104
|
+ |
def policy_projection(%PolicyReceipt{} = receipt) do
|
|
105
|
+ |
%{
|
|
106
|
+ |
"policy_id" => receipt.policy_id,
|
|
107
|
+ |
"version" => receipt.version,
|
|
108
|
+ |
"policy_digest" => receipt.policy_digest,
|
|
109
|
+ |
"rules" => receipt.rules,
|
|
110
|
+ |
"admitted_at" => receipt.inserted_at
|
|
111
|
+ |
}
|
|
112
|
+ |
end
|
|
113
|
+ |
|
|
114
|
+ |
@doc """
|
|
115
|
+ |
Admits an issuer public key.
|
|
116
|
+ |
|
|
117
|
+ |
Only the public half is stored. The private key stays in runtime
|
|
118
|
+ |
configuration, so the table a verifier reads can never mint a claim.
|
|
119
|
+ |
"""
|
|
120
|
+ |
@spec admit_key(map()) :: {:ok, SigningKey.t()} | {:error, term()}
|
|
121
|
+ |
def admit_key(attributes) do
|
|
122
|
+ |
public_key = Map.fetch!(attributes, :public_key)
|
|
123
|
+ |
|
|
124
|
+ |
%SigningKey{}
|
|
125
|
+ |
|> SigningKey.changeset(%{
|
|
126
|
+ |
key_id: Map.get(attributes, :key_id) || Claim.key_id(public_key),
|
|
127
|
+ |
algorithm: Map.get(attributes, :algorithm, Claim.algorithm()),
|
|
128
|
+ |
public_key: public_key,
|
|
129
|
+ |
issuer: Map.fetch!(attributes, :issuer),
|
|
130
|
+ |
activated_at: Map.get(attributes, :activated_at) || DateTime.utc_now(),
|
|
131
|
+ |
retired_at: Map.get(attributes, :retired_at)
|
|
132
|
+ |
})
|
|
133
|
+ |
|> Repo.insert()
|
|
134
|
+ |
end
|
|
135
|
+ |
|
|
136
|
+ |
@doc "Retires an issuer key. Attestations it already signed keep verifying."
|
|
137
|
+ |
@spec retire_key(SigningKey.t(), DateTime.t()) :: {:ok, SigningKey.t()} | {:error, term()}
|
|
138
|
+ |
def retire_key(%SigningKey{} = key, retired_at \\ DateTime.utc_now()) do
|
|
139
|
+ |
key |> SigningKey.retire_changeset(retired_at) |> Repo.update()
|
|
140
|
+ |
end
|
|
141
|
+ |
|
|
142
|
+ |
@doc "Every admitted issuer key, for independent verification."
|
|
143
|
+ |
@spec keys() :: [SigningKey.t()]
|
|
144
|
+ |
def keys, do: Repo.all(from key in SigningKey, order_by: [asc: key.activated_at])
|
|
145
|
+ |
|
|
146
|
+ |
@doc """
|
|
147
|
+ |
Issues one attestation for an accepted outcome.
|
|
148
|
+ |
|
|
149
|
+ |
`signer` carries the admitted `key_id` and the runtime-only `private_key`.
|
|
150
|
+ |
Issuance fails when the outcome is missing or not accepted, when the key is
|
|
151
|
+ |
unknown, retired, or does not match the admitted public key, when the
|
|
152
|
+ |
confidence falls below the policy, when the requested transparency tier
|
|
153
|
+ |
exceeds the repository's authority, or when the same issuer already
|
|
154
|
+ |
attested this event for this subject and outcome.
|
|
155
|
+ |
"""
|
|
156
|
+ |
@spec issue(PolicyReceipt.t(), map(), map()) :: {:ok, Attestation.t()} | {:error, term()}
|
|
157
|
+ |
def issue(%PolicyReceipt{} = policy, signer, attributes) do
|
|
158
|
+ |
with :ok <- validate_policy(policy),
|
|
159
|
+ |
:ok <- validate_event_type(attributes[:event_type], attributes[:revokes_id]),
|
|
160
|
+ |
:ok <- validate_confidence(policy, attributes[:confidence_ppm]),
|
|
161
|
+ |
{:ok, repository} <- fetch_repository(attributes[:repository]),
|
|
162
|
+ |
:ok <- validate_issue_number(repository, attributes[:issue_number]),
|
|
163
|
+ |
:ok <- validate_tier(repository, attributes[:transparency_tier]),
|
|
164
|
+ |
{:ok, evidence} <- validate_evidence(policy, repository, attributes[:evidence]),
|
|
165
|
+ |
{:ok, outcome} <- resolve_outcome(policy, attributes[:outcome]),
|
|
166
|
+ |
{:ok, key} <- fetch_signing_key(signer, attributes[:attested_at]) do
|
|
167
|
+ |
persist(policy, key, signer, repository, outcome, evidence, attributes)
|
|
168
|
+ |
end
|
|
169
|
+ |
end
|
|
170
|
+ |
|
|
171
|
+ |
@doc """
|
|
172
|
+ |
Publishes a linked invalidating event for `attestation`.
|
|
173
|
+ |
|
|
174
|
+ |
`event_type` is `reversal` for an outcome that was undone and `revocation`
|
|
175
|
+ |
for a claim that should no longer count. The original row keeps its claim
|
|
176
|
+ |
and signature; only its revocation fields are set, and only once.
|
|
177
|
+ |
"""
|
|
178
|
+ |
@spec revoke(Attestation.t(), PolicyReceipt.t(), map(), map()) ::
|
|
179
|
+ |
{:ok, %{revocation: Attestation.t(), attestation: Attestation.t()}} | {:error, term()}
|
|
180
|
+ |
def revoke(%Attestation{} = attestation, %PolicyReceipt{} = policy, signer, attributes) do
|
|
181
|
+ |
event_type = Map.get(attributes, :event_type, "revocation")
|
|
182
|
+ |
reason_code = Map.get(attributes, :reason_code)
|
|
183
|
+ |
|
|
184
|
+ |
with :ok <- validate_invalidating_event(event_type),
|
|
185
|
+ |
:ok <- validate_reason_code(reason_code),
|
|
186
|
+ |
:ok <- require_live(attestation) do
|
|
187
|
+ |
Repo.transaction(fn ->
|
|
188
|
+ |
case issue_invalidation(attestation, policy, signer, attributes, event_type) do
|
|
189
|
+ |
{:ok, revocation} ->
|
|
190
|
+ |
%{
|
|
191
|
+ |
revocation: revocation,
|
|
192
|
+ |
attestation: mark_revoked!(attestation, revocation, reason_code)
|
|
193
|
+ |
}
|
|
194
|
+ |
|
|
195
|
+ |
{:error, reason} ->
|
|
196
|
+ |
Repo.rollback(reason)
|
|
197
|
+ |
end
|
|
198
|
+ |
end)
|
|
199
|
+ |
end
|
|
200
|
+ |
end
|
|
201
|
+ |
|
|
202
|
+ |
@doc """
|
|
203
|
+ |
Corrects `attestation`: revokes it and issues a replacement that names the
|
|
204
|
+ |
revoked claim digest in `supersedes`.
|
|
205
|
+ |
"""
|
|
206
|
+ |
@spec correct(Attestation.t(), PolicyReceipt.t(), map(), map()) ::
|
|
207
|
+ |
{:ok, %{revocation: Attestation.t(), correction: Attestation.t()}} | {:error, term()}
|
|
208
|
+ |
def correct(%Attestation{} = attestation, %PolicyReceipt{} = policy, signer, attributes) do
|
|
209
|
+ |
reason_code = Map.get(attributes, :reason_code, "corrected")
|
|
210
|
+ |
|
|
211
|
+ |
Repo.transaction(fn ->
|
|
212
|
+ |
with {:ok, revoked} <-
|
|
213
|
+ |
revoke(attestation, policy, signer, %{
|
|
214
|
+ |
event_type: "revocation",
|
|
215
|
+ |
reason_code: reason_code,
|
|
216
|
+ |
subject_id: attestation.subject_id
|
|
217
|
+ |
}),
|
|
218
|
+ |
{:ok, correction} <-
|
|
219
|
+ |
issue(
|
|
220
|
+ |
policy,
|
|
221
|
+ |
signer,
|
|
222
|
+ |
attributes
|
|
223
|
+ |
|> Map.put(:supersedes_digest, attestation.claim_digest)
|
|
224
|
+ |
|> Map.put_new(:evidence, evidence_for_link(attestation))
|
|
225
|
+ |
) do
|
|
226
|
+ |
%{revocation: revoked.revocation, correction: correction}
|
|
227
|
+ |
else
|
|
228
|
+ |
{:error, reason} -> Repo.rollback(reason)
|
|
229
|
+ |
end
|
|
230
|
+ |
end)
|
|
231
|
+ |
end
|
|
232
|
+ |
|
|
233
|
+ |
@doc """
|
|
234
|
+ |
Verifies one attestation the way a skeptical client does: recompute the
|
|
235
|
+ |
claim digest, check the Ed25519 signature against the admitted public key,
|
|
236
|
+ |
compare the policy and the binding, resolve the evidence, and read the
|
|
237
|
+ |
revocation state.
|
|
238
|
+ |
|
|
239
|
+ |
`expectation` is what the caller believes it is looking at — any of
|
|
240
|
+ |
`:repository`, `:issue_number`, `:subject_id`, `:revision`, `:event_type`,
|
|
241
|
+ |
`:outcome_ref`, or `:policy_id`. A mismatch is reported, which is what stops
|
|
242
|
+ |
a valid attestation from being replayed for another issue, revision,
|
|
243
|
+ |
verifier, or actor.
|
|
244
|
+ |
"""
|
|
245
|
+ |
@spec verify(Attestation.t() | String.t(), map()) :: map()
|
|
246
|
+ |
def verify(attestation, expectation \\ %{})
|
|
247
|
+ |
|
|
248
|
+ |
def verify(claim_digest, expectation) when is_binary(claim_digest) do
|
|
249
|
+ |
case Repo.get_by(Attestation, claim_digest: claim_digest) do
|
|
250
|
+ |
nil ->
|
|
251
|
+ |
%{"claim_digest" => claim_digest, "verified" => false, "reasons" => ["unknown_claim"]}
|
|
252
|
+ |
|
|
253
|
+ |
attestation ->
|
|
254
|
+ |
verify(attestation, expectation)
|
|
255
|
+ |
end
|
|
256
|
+ |
end
|
|
257
|
+ |
|
|
258
|
+ |
def verify(%Attestation{} = attestation, expectation) do
|
|
259
|
+ |
attestation = Repo.preload(attestation, :repository)
|
|
260
|
+ |
key = Repo.get_by(SigningKey, key_id: attestation.issuer_key_id)
|
|
261
|
+ |
digest_match? = Canonical.digest!(attestation.claim) == attestation.claim_digest
|
|
262
|
+ |
signature = signature_report(attestation, key, digest_match?)
|
|
263
|
+ |
policy = policy_report(attestation)
|
|
264
|
+ |
binding = binding_report(attestation, expectation)
|
|
265
|
+ |
evidence = evidence_report(attestation)
|
|
266
|
+ |
revocation = revocation_report(attestation)
|
|
267
|
+ |
|
|
268
|
+ |
report = %{
|
|
269
|
+ |
"attestation_id" => attestation.id,
|
|
270
|
+ |
"claim_digest" => attestation.claim_digest,
|
|
271
|
+ |
"digest_match" => digest_match?,
|
|
272
|
+ |
"signature" => signature,
|
|
273
|
+ |
"policy" => policy,
|
|
274
|
+ |
"binding" => binding,
|
|
275
|
+ |
"evidence" => evidence,
|
|
276
|
+ |
"revocation" => revocation
|
|
277
|
+ |
}
|
|
278
|
+ |
|
|
279
|
+ |
Map.put(report, "verified", verified?(report))
|
|
280
|
+ |
end
|
|
281
|
+ |
|
|
282
|
+ |
@doc """
|
|
283
|
+ |
The published form of one attestation: the exact signed claim, its
|
|
284
|
+ |
signature, and the state a verifier needs. The claim is the stored object,
|
|
285
|
+ |
never a rendering of it.
|
|
286
|
+ |
"""
|
|
287
|
+ |
@spec projection(Attestation.t()) :: map()
|
|
288
|
+ |
def projection(%Attestation{} = attestation) do
|
|
289
|
+ |
%{
|
|
290
|
+ |
"id" => attestation.id,
|
|
291
|
+ |
"claim" => attestation.claim,
|
|
292
|
+ |
"claim_digest" => attestation.claim_digest,
|
|
293
|
+ |
"signature" => attestation.signature,
|
|
294
|
+ |
"signature_algorithm" => attestation.signature_algorithm,
|
|
295
|
+ |
"event_type" => attestation.event_type,
|
|
296
|
+ |
"subject_id" => attestation.subject_id,
|
|
297
|
+ |
"issuer_key_id" => attestation.issuer_key_id,
|
|
298
|
+ |
"transparency_tier" => attestation.transparency_tier,
|
|
299
|
+ |
"attested_at" => attestation.attested_at,
|
|
300
|
+ |
"supersedes" => attestation.supersedes_digest,
|
|
301
|
+ |
"revokes" => attestation.revokes_id,
|
|
302
|
+ |
"revocation" => %{
|
|
303
|
+ |
"revoked" => not is_nil(attestation.revoked_at),
|
|
304
|
+ |
"revoked_at" => attestation.revoked_at,
|
|
305
|
+ |
"reason_code" => attestation.revocation_reason_code
|
|
306
|
+ |
}
|
|
307
|
+ |
}
|
|
308
|
+ |
end
|
|
309
|
+ |
|
|
310
|
+ |
@doc "The published form of one admitted key."
|
|
311
|
+ |
@spec key_projection(SigningKey.t()) :: map()
|
|
312
|
+ |
def key_projection(%SigningKey{} = key) do
|
|
313
|
+ |
%{
|
|
314
|
+ |
"key_id" => key.key_id,
|
|
315
|
+ |
"algorithm" => key.algorithm,
|
|
316
|
+ |
"public_key" => key.public_key,
|
|
317
|
+ |
"issuer" => key.issuer,
|
|
318
|
+ |
"activated_at" => key.activated_at,
|
|
319
|
+ |
"retired_at" => key.retired_at,
|
|
320
|
+ |
"status" => if(is_nil(key.retired_at), do: "active", else: "retired")
|
|
321
|
+ |
}
|
|
322
|
+ |
end
|
|
323
|
+ |
|
|
324
|
+ |
@doc """
|
|
325
|
+ |
The attestations on one issue that `tiers` may disclose.
|
|
326
|
+ |
|
|
327
|
+ |
A `repository` tier attestation discloses evidence references to repository
|
|
328
|
+ |
members, so callers pass the tiers the reader holds authority for.
|
|
329
|
+ |
"""
|
|
330
|
+ |
@spec list_for_issue(Repository.t(), pos_integer(), [String.t()]) :: [Attestation.t()]
|
|
331
|
+ |
def list_for_issue(%Repository{id: repository_id}, issue_number, tiers) do
|
|
332
|
+ |
Repo.all(
|
|
333
|
+ |
from attestation in Attestation,
|
|
334
|
+ |
where:
|
|
335
|
+ |
attestation.repository_id == ^repository_id and
|
|
336
|
+ |
attestation.issue_number == ^issue_number and
|
|
337
|
+ |
attestation.transparency_tier in ^tiers,
|
|
338
|
+ |
order_by: [asc: attestation.attested_at, asc: attestation.id]
|
|
339
|
+ |
)
|
|
340
|
+ |
end
|
|
341
|
+ |
|
|
342
|
+ |
@doc "One attestation in one repository, or `nil`."
|
|
343
|
+ |
@spec get(Repository.t(), String.t(), [String.t()]) :: Attestation.t() | nil
|
|
344
|
+ |
def get(%Repository{id: repository_id}, id, tiers) do
|
|
345
|
+ |
Repo.one(
|
|
346
|
+ |
from attestation in Attestation,
|
|
347
|
+ |
where:
|
|
348
|
+ |
attestation.repository_id == ^repository_id and attestation.id == ^id and
|
|
349
|
+ |
attestation.transparency_tier in ^tiers
|
|
350
|
+ |
)
|
|
351
|
+ |
rescue
|
|
352
|
+ |
Ecto.Query.CastError -> nil
|
|
353
|
+ |
end
|
|
354
|
+ |
|
|
355
|
+ |
@doc """
|
|
356
|
+ |
Scoped evidence about one subject in one repository.
|
|
357
|
+ |
|
|
358
|
+ |
The projection counts live and revoked events per policy inside one
|
|
359
|
+ |
repository. `score` is always `nil`: a ranking system may weigh these
|
|
360
|
+ |
counts, but nothing here publishes a universal number, and evidence from
|
|
361
|
+ |
one repository never leaks into another's summary.
|
|
362
|
+ |
"""
|
|
363
|
+ |
@spec subject_evidence(String.t(), Repository.t()) :: map()
|
|
364
|
+ |
def subject_evidence(subject_id, %Repository{} = repository) do
|
|
365
|
+ |
attestations =
|
|
366
|
+ |
Repo.all(
|
|
367
|
+ |
from attestation in Attestation,
|
|
368
|
+ |
where:
|
|
369
|
+ |
attestation.repository_id == ^repository.id and
|
|
370
|
+ |
attestation.subject_id == ^subject_id
|
|
371
|
+ |
)
|
|
372
|
+ |
|
|
373
|
+ |
{live, revoked} = Enum.split_with(attestations, &is_nil(&1.revoked_at))
|
|
374
|
+ |
|
|
375
|
+ |
%{
|
|
376
|
+ |
"subject_id" => subject_id,
|
|
377
|
+ |
"scope" => "repository",
|
|
378
|
+ |
"repository" => path(repository),
|
|
379
|
+ |
"policy_id" => @policy_id,
|
|
380
|
+ |
"counts" => Enum.frequencies_by(live, & &1.event_type),
|
|
381
|
+ |
"revoked" => length(revoked),
|
|
382
|
+ |
"score" => nil
|
|
383
|
+ |
}
|
|
384
|
+ |
end
|
|
385
|
+ |
|
|
386
|
+ |
defp persist(policy, key, signer, repository, outcome, evidence, attributes) do
|
|
387
|
+ |
attested_at = attributes[:attested_at] || DateTime.utc_now()
|
|
388
|
+ |
|
|
389
|
+ |
claim =
|
|
390
|
+ |
Claim.build(%{
|
|
391
|
+ |
event_type: attributes[:event_type],
|
|
392
|
+ |
issuer_key_id: key.key_id,
|
|
393
|
+ |
issuer_public_key: key.public_key,
|
|
394
|
+ |
subject_id: attributes[:subject_id],
|
|
395
|
+ |
outcome_kind: outcome.kind,
|
|
396
|
+ |
outcome_ref: outcome.ref,
|
|
397
|
+ |
outcome_digest: outcome.digest,
|
|
398
|
+ |
outcome_state: outcome.state,
|
|
399
|
+ |
repository: path(repository),
|
|
400
|
+ |
repository_id: repository.id,
|
|
401
|
+ |
issue_number: attributes[:issue_number],
|
|
402
|
+ |
revision: attributes[:revision],
|
|
403
|
+ |
artifact_digest: attributes[:artifact_digest],
|
|
404
|
+ |
policy_id: policy.policy_id,
|
|
405
|
+ |
policy_version: policy.version,
|
|
406
|
+ |
policy_digest: policy.policy_digest,
|
|
407
|
+ |
confidence_ppm: attributes[:confidence_ppm],
|
|
408
|
+ |
transparency_tier: attributes[:transparency_tier],
|
|
409
|
+ |
evidence: evidence,
|
|
410
|
+ |
attested_at: attested_at,
|
|
411
|
+ |
nonce: attributes[:nonce] || Claim.nonce(),
|
|
412
|
+ |
supersedes_digest: attributes[:supersedes_digest]
|
|
413
|
+ |
})
|
|
414
|
+ |
|
|
415
|
+ |
with {:ok, digest} <- Claim.digest(claim),
|
|
416
|
+ |
{:ok, signature} <- Claim.sign(claim, Map.fetch!(signer, :private_key)) do
|
|
417
|
+ |
%Attestation{}
|
|
418
|
+ |
|> Attestation.changeset(%{
|
|
419
|
+ |
repository_id: repository.id,
|
|
420
|
+ |
issue_number: attributes[:issue_number],
|
|
421
|
+ |
event_type: attributes[:event_type],
|
|
422
|
+ |
subject_id: attributes[:subject_id],
|
|
423
|
+ |
issuer_key_id: key.key_id,
|
|
424
|
+ |
outcome_kind: outcome.kind,
|
|
425
|
+ |
outcome_ref: outcome.ref,
|
|
426
|
+ |
outcome_digest: outcome.digest,
|
|
427
|
+ |
revision: attributes[:revision],
|
|
428
|
+ |
artifact_digest: attributes[:artifact_digest],
|
|
429
|
+ |
policy_id: policy.policy_id,
|
|
430
|
+ |
policy_version: policy.version,
|
|
431
|
+ |
policy_digest: policy.policy_digest,
|
|
432
|
+ |
confidence_ppm: attributes[:confidence_ppm],
|
|
433
|
+ |
transparency_tier: attributes[:transparency_tier],
|
|
434
|
+ |
attested_at: attested_at,
|
|
435
|
+ |
nonce: claim["nonce"],
|
|
436
|
+ |
claim: claim,
|
|
437
|
+ |
claim_digest: digest,
|
|
438
|
+ |
signature: signature,
|
|
439
|
+ |
signature_algorithm: Claim.algorithm(),
|
|
440
|
+ |
supersedes_digest: attributes[:supersedes_digest],
|
|
441
|
+ |
revokes_id: attributes[:revokes_id]
|
|
442
|
+ |
})
|
|
443
|
+ |
|> Repo.insert()
|
|
444
|
+ |
end
|
|
445
|
+ |
end
|
|
446
|
+ |
|
|
447
|
+ |
defp issue_invalidation(attestation, policy, signer, attributes, event_type) do
|
|
448
|
+ |
attestation = Repo.preload(attestation, :repository)
|
|
449
|
+ |
|
|
450
|
+ |
issue(
|
|
451
|
+ |
policy,
|
|
452
|
+ |
signer,
|
|
453
|
+ |
%{
|
|
454
|
+ |
event_type: event_type,
|
|
455
|
+ |
subject_id: Map.get(attributes, :subject_id, attestation.subject_id),
|
|
456
|
+ |
outcome: %{kind: attestation.outcome_kind, ref: attestation.outcome_ref},
|
|
457
|
+ |
repository: attestation.repository,
|
|
458
|
+ |
issue_number: attestation.issue_number,
|
|
459
|
+ |
revision: attestation.revision,
|
|
460
|
+ |
artifact_digest: attestation.artifact_digest,
|
|
461
|
+ |
confidence_ppm: Map.get(attributes, :confidence_ppm, 1_000_000),
|
|
462
|
+ |
transparency_tier: attestation.transparency_tier,
|
|
463
|
+ |
evidence: Map.get(attributes, :evidence) || evidence_for_link(attestation),
|
|
464
|
+ |
supersedes_digest: attestation.claim_digest,
|
|
465
|
+ |
revokes_id: attestation.id
|
|
466
|
+ |
}
|
|
467
|
+ |
)
|
|
468
|
+ |
end
|
|
469
|
+ |
|
|
470
|
+ |
defp mark_revoked!(attestation, revocation, reason_code) do
|
|
471
|
+ |
attestation
|
|
472
|
+ |
|> Attestation.revocation_changeset(%{
|
|
473
|
+ |
revoked_at: revocation.attested_at,
|
|
474
|
+ |
revocation_reason_code: reason_code,
|
|
475
|
+ |
revoked_by_id: revocation.id
|
|
476
|
+ |
})
|
|
477
|
+ |
|> Repo.update!()
|
|
478
|
+ |
end
|
|
479
|
+ |
|
|
480
|
+ |
defp evidence_for_link(%Attestation{} = attestation) do
|
|
481
|
+ |
[
|
|
482
|
+ |
%{
|
|
483
|
+ |
"kind" => "attestation",
|
|
484
|
+ |
"ref" => attestation.claim_digest,
|
|
485
|
+ |
"digest" => attestation.claim_digest,
|
|
486
|
+ |
"observed_at" => DateTime.to_iso8601(attestation.attested_at)
|
|
487
|
+ |
}
|
|
488
|
+ |
]
|
|
489
|
+ |
end
|
|
490
|
+ |
|
|
491
|
+ |
defp signature_report(attestation, nil, _digest_match?) do
|
|
492
|
+ |
%{"valid" => false, "key_id" => attestation.issuer_key_id, "key_status" => "unknown"}
|
|
493
|
+ |
end
|
|
494
|
+ |
|
|
495
|
+ |
defp signature_report(attestation, %SigningKey{} = key, digest_match?) do
|
|
496
|
+ |
valid? =
|
|
497
|
+ |
digest_match? and
|
|
498
|
+ |
attestation.signature_algorithm == key.algorithm and
|
|
499
|
+ |
Claim.valid_signature?(attestation.claim, attestation.signature, key.public_key)
|
|
500
|
+ |
|
|
501
|
+ |
%{
|
|
502
|
+ |
"valid" => valid?,
|
|
503
|
+ |
"key_id" => key.key_id,
|
|
504
|
+ |
"key_status" => if(is_nil(key.retired_at), do: "active", else: "retired"),
|
|
505
|
+ |
"key_active_at_attestation" => SigningKey.active_at?(key, attestation.attested_at)
|
|
506
|
+ |
}
|
|
507
|
+ |
end
|
|
508
|
+ |
|
|
509
|
+ |
defp policy_report(attestation) do
|
|
510
|
+ |
admitted =
|
|
511
|
+ |
Repo.get_by(PolicyReceipt,
|
|
512
|
+ |
policy_id: attestation.policy_id,
|
|
513
|
+ |
version: attestation.policy_version
|
|
514
|
+ |
)
|
|
515
|
+ |
|
|
516
|
+ |
current = current_policy_version(attestation.policy_id)
|
|
517
|
+ |
|
|
518
|
+ |
%{
|
|
519
|
+ |
"policy_id" => attestation.policy_id,
|
|
520
|
+ |
"version" => attestation.policy_version,
|
|
521
|
+ |
"current_version" => current,
|
|
522
|
+ |
"admitted" => not is_nil(admitted),
|
|
523
|
+ |
"digest_match" =>
|
|
524
|
+ |
not is_nil(admitted) and admitted.policy_digest == attestation.policy_digest,
|
|
525
|
+ |
"superseded" => not is_nil(current) and current > attestation.policy_version
|
|
526
|
+ |
}
|
|
527
|
+ |
end
|
|
528
|
+ |
|
|
529
|
+ |
defp current_policy_version(policy_id) do
|
|
530
|
+ |
Repo.one(
|
|
531
|
+ |
from receipt in PolicyReceipt,
|
|
532
|
+ |
where: receipt.policy_id == ^policy_id,
|
|
533
|
+ |
select: max(receipt.version)
|
|
534
|
+ |
)
|
|
535
|
+ |
end
|
|
536
|
+ |
|
|
537
|
+ |
defp binding_report(attestation, expectation) do
|
|
538
|
+ |
claimed = %{
|
|
539
|
+ |
repository: attestation.claim["scope"]["repository"],
|
|
540
|
+ |
issue_number: attestation.claim["scope"]["issue_number"],
|
|
541
|
+ |
revision: attestation.claim["scope"]["revision"],
|
|
542
|
+ |
subject_id: attestation.claim["subject"]["actor_id"],
|
|
543
|
+ |
event_type: attestation.claim["event_type"],
|
|
544
|
+ |
outcome_ref: attestation.claim["outcome"]["ref"],
|
|
545
|
+ |
policy_id: attestation.claim["verifier"]["policy_id"]
|
|
546
|
+ |
}
|
|
547
|
+ |
|
|
548
|
+ |
mismatches =
|
|
549
|
+ |
expectation
|
|
550
|
+ |
|> Enum.filter(fn {field, expected} -> Map.get(claimed, field) != expected end)
|
|
551
|
+ |
|> Enum.map(fn {field, expected} ->
|
|
552
|
+ |
%{
|
|
553
|
+ |
"field" => to_string(field),
|
|
554
|
+ |
"expected" => expected,
|
|
555
|
+ |
"claimed" => Map.get(claimed, field)
|
|
556
|
+ |
}
|
|
557
|
+ |
end)
|
|
558
|
+ |
|
|
559
|
+ |
columns_match? =
|
|
560
|
+ |
claimed.repository == path(attestation.repository) and
|
|
561
|
+ |
claimed.issue_number == attestation.issue_number and
|
|
562
|
+ |
claimed.subject_id == attestation.subject_id and
|
|
563
|
+ |
claimed.event_type == attestation.event_type and
|
|
564
|
+ |
claimed.revision == attestation.revision
|
|
565
|
+ |
|
|
566
|
+ |
%{
|
|
567
|
+ |
"matches" => mismatches == [] and columns_match?,
|
|
568
|
+ |
"claim_matches_columns" => columns_match?,
|
|
569
|
+ |
"mismatches" => mismatches
|
|
570
|
+ |
}
|
|
571
|
+ |
end
|
|
572
|
+ |
|
|
573
|
+ |
defp evidence_report(attestation) do
|
|
574
|
+ |
max_age = policy_rule(attestation, "evidence_max_age_seconds")
|
|
575
|
+ |
|
|
576
|
+ |
entries =
|
|
577
|
+ |
Enum.map(attestation.claim["evidence"] || [], fn entry ->
|
|
578
|
+ |
age = evidence_age(entry, attestation.attested_at)
|
|
579
|
+ |
|
|
580
|
+ |
Map.merge(entry, %{
|
|
581
|
+ |
"available" => evidence_available?(entry, attestation),
|
|
582
|
+ |
"age_seconds" => age,
|
|
583
|
+ |
"stale" => is_integer(age) and is_integer(max_age) and age > max_age
|
|
584
|
+ |
})
|
|
585
|
+ |
end)
|
|
586
|
+ |
|
|
587
|
+ |
%{
|
|
588
|
+ |
"entries" => entries,
|
|
589
|
+ |
"available" => entries != [] and Enum.all?(entries, & &1["available"]),
|
|
590
|
+ |
"stale" => Enum.any?(entries, & &1["stale"])
|
|
591
|
+ |
}
|
|
592
|
+ |
end
|
|
593
|
+ |
|
|
594
|
+ |
defp policy_rule(attestation, rule) do
|
|
595
|
+ |
case Repo.get_by(PolicyReceipt,
|
|
596
|
+ |
policy_id: attestation.policy_id,
|
|
597
|
+ |
version: attestation.policy_version
|
|
598
|
+ |
) do
|
|
599
|
+ |
nil -> Map.get(@policy_rules, rule)
|
|
600
|
+ |
receipt -> Map.get(receipt.rules, rule)
|
|
601
|
+ |
end
|
|
602
|
+ |
end
|
|
603
|
+ |
|
|
604
|
+ |
defp evidence_age(entry, attested_at) do
|
|
605
|
+ |
with observed when is_binary(observed) <- entry["observed_at"],
|
|
606
|
+ |
{:ok, observed_at, _offset} <- DateTime.from_iso8601(observed) do
|
|
607
|
+ |
DateTime.diff(attested_at, observed_at)
|
|
608
|
+ |
else
|
|
609
|
+ |
_other -> nil
|
|
610
|
+ |
end
|
|
611
|
+ |
end
|
|
612
|
+ |
|
|
613
|
+ |
defp evidence_available?(%{"disclosed" => false}, _attestation), do: false
|
|
614
|
+ |
|
|
615
|
+ |
defp evidence_available?(entry, attestation) do
|
|
616
|
+ |
case entry["kind"] do
|
|
617
|
+ |
"outcome" ->
|
|
618
|
+ |
resolvable_outcome?(attestation.outcome_kind, entry["ref"])
|
|
619
|
+ |
|
|
620
|
+ |
"issue" ->
|
|
621
|
+ |
issue_exists?(attestation.repository_id, entry["ref"])
|
|
622
|
+ |
|
|
623
|
+ |
"repository" ->
|
|
624
|
+ |
entry["ref"] == path(attestation.repository)
|
|
625
|
+ |
|
|
626
|
+ |
"attestation" ->
|
|
627
|
+ |
digest = entry["ref"]
|
|
628
|
+ |
Repo.exists?(from other in Attestation, where: other.claim_digest == ^digest)
|
|
629
|
+ |
|
|
630
|
+ |
_other ->
|
|
631
|
+ |
false
|
|
632
|
+ |
end
|
|
633
|
+ |
end
|
|
634
|
+ |
|
|
635
|
+ |
defp resolvable_outcome?("compensation_outcome_decision", ref) when is_binary(ref),
|
|
636
|
+ |
do:
|
|
637
|
+ |
Repo.exists?(from decision in OutcomeDecision, where: decision.decision_receipt_ref == ^ref)
|
|
638
|
+ |
|
|
639
|
+ |
defp resolvable_outcome?(_kind, _ref), do: false
|
|
640
|
+ |
|
|
641
|
+ |
defp issue_exists?(repository_id, ref) when is_binary(ref) do
|
|
642
|
+ |
case Integer.parse(ref |> String.split("#") |> List.last() || "") do
|
|
643
|
+ |
{number, ""} ->
|
|
644
|
+ |
Repo.exists?(
|
|
645
|
+ |
from issue in Issue,
|
|
646
|
+ |
where: issue.repository_id == ^repository_id and issue.number == ^number
|
|
647
|
+ |
)
|
|
648
|
+ |
|
|
649
|
+ |
_other ->
|
|
650
|
+ |
false
|
|
651
|
+ |
end
|
|
652
|
+ |
end
|
|
653
|
+ |
|
|
654
|
+ |
defp issue_exists?(_repository_id, _ref), do: false
|
|
655
|
+ |
|
|
656
|
+ |
defp revocation_report(attestation) do
|
|
657
|
+ |
%{
|
|
658
|
+ |
"revoked" => not is_nil(attestation.revoked_at),
|
|
659
|
+ |
"revoked_at" => attestation.revoked_at,
|
|
660
|
+ |
"reason_code" => attestation.revocation_reason_code,
|
|
661
|
+ |
"revoked_by" => attestation.revoked_by_id,
|
|
662
|
+ |
"supersedes" => attestation.supersedes_digest
|
|
663
|
+ |
}
|
|
664
|
+ |
end
|
|
665
|
+ |
|
|
666
|
+ |
defp verified?(report) do
|
|
667
|
+ |
private? = report["evidence"]["entries"] |> Enum.any?(&(&1["disclosed"] == false))
|
|
668
|
+ |
|
|
669
|
+ |
report["digest_match"] and report["signature"]["valid"] and
|
|
670
|
+ |
report["signature"]["key_active_at_attestation"] == true and
|
|
671
|
+ |
report["policy"]["digest_match"] and report["binding"]["matches"] and
|
|
672
|
+ |
not report["revocation"]["revoked"] and not report["evidence"]["stale"] and
|
|
673
|
+ |
(report["evidence"]["available"] or private?)
|
|
674
|
+ |
end
|
|
675
|
+ |
|
|
676
|
+ |
defp validate_policy(%PolicyReceipt{} = policy) do
|
|
677
|
+ |
expected = policy_digest(policy.policy_id, policy.version, policy.rules)
|
|
678
|
+ |
|
|
679
|
+ |
if expected == policy.policy_digest, do: :ok, else: {:error, :policy_digest_mismatch}
|
|
680
|
+ |
end
|
|
681
|
+ |
|
|
682
|
+ |
# An invalidating event exists only as the linked successor of the claim it
|
|
683
|
+ |
# invalidates, so it carries the attestation it revokes.
|
|
684
|
+ |
defp validate_event_type(event_type, revokes_id) do
|
|
685
|
+ |
invalidating? = event_type in Attestation.invalidating_event_types()
|
|
686
|
+ |
|
|
687
|
+ |
cond do
|
|
688
|
+ |
event_type not in Attestation.event_types() -> {:error, :event_type_unsupported}
|
|
689
|
+ |
invalidating? and is_nil(revokes_id) -> {:error, :invalidation_requires_prior_attestation}
|
|
690
|
+ |
not invalidating? and not is_nil(revokes_id) -> {:error, :event_type_not_invalidating}
|
|
691
|
+ |
true -> :ok
|
|
692
|
+ |
end
|
|
693
|
+ |
end
|
|
694
|
+ |
|
|
695
|
+ |
defp validate_invalidating_event(event_type) do
|
|
696
|
+ |
if event_type in Attestation.invalidating_event_types(),
|
|
697
|
+ |
do: :ok,
|
|
698
|
+ |
else: {:error, :event_type_not_invalidating}
|
|
699
|
+ |
end
|
|
700
|
+ |
|
|
701
|
+ |
defp validate_reason_code(code) when is_binary(code) and byte_size(code) > 0, do: :ok
|
|
702
|
+ |
defp validate_reason_code(_code), do: {:error, :reason_code_required}
|
|
703
|
+ |
|
|
704
|
+ |
defp validate_confidence(policy, confidence) when is_integer(confidence) do
|
|
705
|
+ |
minimum = Map.get(policy.rules, "minimum_confidence_ppm", 0)
|
|
706
|
+ |
|
|
707
|
+ |
cond do
|
|
708
|
+ |
confidence < 0 or confidence > 1_000_000 -> {:error, :confidence_out_of_range}
|
|
709
|
+ |
confidence < minimum -> {:error, :confidence_below_policy}
|
|
710
|
+ |
true -> :ok
|
|
711
|
+ |
end
|
|
712
|
+ |
end
|
|
713
|
+ |
|
|
714
|
+ |
defp validate_confidence(_policy, _confidence), do: {:error, :confidence_required}
|
|
715
|
+ |
|
|
716
|
+ |
defp fetch_repository(%Repository{} = repository), do: {:ok, repository}
|
|
717
|
+ |
defp fetch_repository(_other), do: {:error, :repository_required}
|
|
718
|
+ |
|
|
719
|
+ |
defp validate_issue_number(repository, number) when is_integer(number) and number > 0 do
|
|
720
|
+ |
if Repo.exists?(
|
|
721
|
+ |
from issue in Issue,
|
|
722
|
+ |
where: issue.repository_id == ^repository.id and issue.number == ^number
|
|
723
|
+ |
),
|
|
724
|
+ |
do: :ok,
|
|
725
|
+ |
else: {:error, :issue_not_found}
|
|
726
|
+ |
end
|
|
727
|
+ |
|
|
728
|
+ |
defp validate_issue_number(_repository, _number), do: {:error, :issue_number_required}
|
|
729
|
+ |
|
|
730
|
+ |
defp validate_tier(repository, tier) do
|
|
731
|
+ |
cond do
|
|
732
|
+ |
tier not in Attestation.transparency_tiers() ->
|
|
733
|
+ |
{:error, :transparency_tier_unsupported}
|
|
734
|
+ |
|
|
735
|
+ |
tier == "public" and not public_disclosure?(repository) ->
|
|
736
|
+ |
{:error, :transparency_tier_exceeds_repository_authority}
|
|
737
|
+ |
|
|
738
|
+ |
true ->
|
|
739
|
+ |
:ok
|
|
740
|
+ |
end
|
|
741
|
+ |
end
|
|
742
|
+ |
|
|
743
|
+ |
defp public_disclosure?(repository) do
|
|
744
|
+ |
repository.visibility == "public" or Visibility.allows?(repository.name, :ledger)
|
|
745
|
+ |
end
|
|
746
|
+ |
|
|
747
|
+ |
defp validate_evidence(policy, repository, entries) when is_list(entries) and entries != [] do
|
|
748
|
+ |
kinds = Map.get(policy.rules, "evidence_kinds", [])
|
|
749
|
+ |
|
|
750
|
+ |
entries
|
|
751
|
+ |
|> Enum.reduce_while({:ok, []}, fn entry, {:ok, validated} ->
|
|
752
|
+ |
normalized = Map.new(entry, fn {key, value} -> {to_string(key), value} end)
|
|
753
|
+ |
|
|
754
|
+ |
case validate_evidence_entry(normalized, kinds, repository) do
|
|
755
|
+ |
:ok -> {:cont, {:ok, [normalized | validated]}}
|
|
756
|
+ |
{:error, reason} -> {:halt, {:error, reason}}
|
|
757
|
+ |
end
|
|
758
|
+ |
end)
|
|
759
|
+ |
|> case do
|
|
760
|
+ |
{:ok, validated} -> {:ok, Enum.reverse(validated)}
|
|
761
|
+ |
error -> error
|
|
762
|
+ |
end
|
|
763
|
+ |
end
|
|
764
|
+ |
|
|
765
|
+ |
defp validate_evidence(_policy, _repository, _entries), do: {:error, :evidence_required}
|
|
766
|
+ |
|
|
767
|
+ |
defp validate_evidence_entry(entry, kinds, repository) do
|
|
768
|
+ |
cond do
|
|
769
|
+ |
entry["kind"] not in kinds ->
|
|
770
|
+ |
{:error, :evidence_kind_unsupported}
|
|
771
|
+ |
|
|
772
|
+ |
not is_binary(entry["ref"]) or entry["ref"] == "" ->
|
|
773
|
+ |
{:error, :evidence_ref_required}
|
|
774
|
+ |
|
|
775
|
+ |
not valid_digest?(entry["digest"]) ->
|
|
776
|
+ |
{:error, :evidence_digest_invalid}
|
|
777
|
+ |
|
|
778
|
+ |
not valid_timestamp?(entry["observed_at"]) ->
|
|
779
|
+ |
{:error, :evidence_observed_at_invalid}
|
|
780
|
+ |
|
|
781
|
+ |
entry["kind"] in ~w(issue repository) and not repository_scoped?(entry["ref"], repository) ->
|
|
782
|
+ |
{:error, :evidence_outside_repository_authority}
|
|
783
|
+ |
|
|
784
|
+ |
true ->
|
|
785
|
+ |
:ok
|
|
786
|
+ |
end
|
|
787
|
+ |
end
|
|
788
|
+ |
|
|
789
|
+ |
defp repository_scoped?(ref, repository) do
|
|
790
|
+ |
path = path(repository)
|
|
791
|
+ |
|
|
792
|
+ |
ref == path or String.starts_with?(ref, path <> "#")
|
|
793
|
+ |
end
|
|
794
|
+ |
|
|
795
|
+ |
defp valid_digest?(digest) when is_binary(digest),
|
|
796
|
+ |
do: Regex.match?(~r/\A[0-9a-f]{64}\z/, digest)
|
|
797
|
+ |
|
|
798
|
+ |
defp valid_digest?(_digest), do: false
|
|
799
|
+ |
|
|
800
|
+ |
defp valid_timestamp?(value) when is_binary(value) do
|
|
801
|
+ |
match?({:ok, _instant, _offset}, DateTime.from_iso8601(value))
|
|
802
|
+ |
end
|
|
803
|
+ |
|
|
804
|
+ |
defp valid_timestamp?(_value), do: false
|
|
805
|
+ |
|
|
806
|
+ |
# The accepted-outcome contract. `compensation_outcome_decision` is the
|
|
807
|
+ |
# accepted-outcome receipt the application records today; verified bounty
|
|
808
|
+ |
# settlement adds one clause here and no new attestation semantics.
|
|
809
|
+ |
defp resolve_outcome(policy, %{kind: kind, ref: ref}) when is_binary(kind) and is_binary(ref) do
|
|
810
|
+ |
if kind in Map.get(policy.rules, "accepted_outcome_kinds", []) do
|
|
811
|
+ |
resolve_outcome_state(policy, kind, ref)
|
|
812
|
+ |
else
|
|
813
|
+ |
{:error, :outcome_kind_unsupported}
|
|
814
|
+ |
end
|
|
815
|
+ |
end
|
|
816
|
+ |
|
|
817
|
+ |
defp resolve_outcome(_policy, _outcome), do: {:error, :outcome_required}
|
|
818
|
+ |
|
|
819
|
+ |
defp resolve_outcome_state(policy, "compensation_outcome_decision" = kind, ref) do
|
|
820
|
+ |
terminal = Map.get(policy.rules, "accepted_terminal_state")
|
|
821
|
+ |
|
|
822
|
+ |
case Repo.get_by(OutcomeDecision, decision_receipt_ref: ref) do
|
|
823
|
+ |
nil ->
|
|
824
|
+ |
{:error, :outcome_not_found}
|
|
825
|
+ |
|
|
826
|
+ |
%OutcomeDecision{decision: ^terminal} = decision ->
|
|
827
|
+ |
{:ok, %{kind: kind, ref: ref, digest: decision.outcome_digest, state: decision.decision}}
|
|
828
|
+ |
|
|
829
|
+ |
%OutcomeDecision{} ->
|
|
830
|
+ |
{:error, :outcome_not_accepted}
|
|
831
|
+ |
end
|
|
832
|
+ |
end
|
|
833
|
+ |
|
|
834
|
+ |
defp fetch_signing_key(signer, attested_at) do
|
|
835
|
+ |
instant = attested_at || DateTime.utc_now()
|
|
836
|
+ |
private_key = Map.get(signer, :private_key)
|
|
837
|
+ |
|
|
838
|
+ |
with {:ok, key} <- lookup_key(Map.get(signer, :key_id)),
|
|
839
|
+ |
:ok <- require_active_key(key, instant),
|
|
840
|
+ |
:ok <- require_matching_key(key, private_key) do
|
|
841
|
+ |
{:ok, key}
|
|
842
|
+ |
end
|
|
843
|
+ |
end
|
|
844
|
+ |
|
|
845
|
+ |
defp lookup_key(key_id) when is_binary(key_id) do
|
|
846
|
+ |
case Repo.get_by(SigningKey, key_id: key_id) do
|
|
847
|
+ |
nil -> {:error, :signing_key_unknown}
|
|
848
|
+ |
key -> {:ok, key}
|
|
849
|
+ |
end
|
|
850
|
+ |
end
|
|
851
|
+ |
|
|
852
|
+ |
defp lookup_key(_key_id), do: {:error, :signing_key_required}
|
|
853
|
+ |
|
|
854
|
+ |
defp require_active_key(key, instant) do
|
|
855
|
+ |
if SigningKey.active_at?(key, instant), do: :ok, else: {:error, :signing_key_retired}
|
|
856
|
+ |
end
|
|
857
|
+ |
|
|
858
|
+ |
defp require_matching_key(key, private_key) when is_binary(private_key) do
|
|
859
|
+ |
if Claim.public_key_for(private_key) == key.public_key,
|
|
860
|
+ |
do: :ok,
|
|
861
|
+ |
else: {:error, :signing_key_mismatch}
|
|
862
|
+ |
end
|
|
863
|
+ |
|
|
864
|
+ |
defp require_matching_key(_key, _private_key), do: {:error, :private_key_required}
|
|
865
|
+ |
|
|
866
|
+ |
defp require_live(%Attestation{revoked_at: nil}), do: :ok
|
|
867
|
+ |
defp require_live(%Attestation{}), do: {:error, :already_revoked}
|
|
868
|
+ |
|
|
869
|
+ |
defp validate_operator(%{authenticated: true} = operator) do
|
|
870
|
+ |
required = [:actor_id, :auth_method, :approval_receipt_ref]
|
|
871
|
+ |
|
|
872
|
+ |
if Enum.all?(required, &is_binary(Map.get(operator, &1))),
|
|
873
|
+ |
do: :ok,
|
|
874
|
+ |
else: {:error, :operator_receipt_incomplete}
|
|
875
|
+ |
end
|
|
876
|
+ |
|
|
877
|
+ |
defp validate_operator(_operator), do: {:error, :operator_unauthenticated}
|
|
878
|
+ |
|
|
879
|
+ |
defp path(%Repository{owner: owner, name: name}), do: "#{owner}/#{name}"
|
|
880
|
+ |
end
|