• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

malach-it / boruta_auth / 954161a5b397cc94069ec938dc16fcb785e37074-PR-29

18 Jan 2025 10:28PM UTC coverage: 85.651% (-4.3%) from 89.944%
954161a5b397cc94069ec938dc16fcb785e37074-PR-29

Pull #29

github

patatoid
refactor verifiable credentials status tokens
Pull Request #29: Agent credentials PoC

188 of 304 new or added lines in 20 files covered. (61.84%)

3 existing lines in 1 file now uncovered.

1552 of 1812 relevant lines covered (85.65%)

85.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.5
/lib/boruta/openid/verifiable_credentials.ex
1
defmodule Boruta.Openid.VerifiableCredentials do
2
  defmodule Hotp do
3
    @moduledoc """
4
    Implements HOTP generation as described in the IETF RFC
5
    [HOTP: An HMAC-Based One-Time Password Algorithm](https://www.ietf.org/rfc/rfc4226.txt)
6
    > This implementation defaults to 6 digits using the sha1 algorithm as hashing function
7
    """
8

9
    import Bitwise
10

11
    @hmac_algorithm :sha
12
    @digits 12
13

14
    @spec generate_hotp(secret :: String.t(), counter :: integer()) :: hotp :: String.t()
15
    def generate_hotp(secret, counter) do
16
      # Step 1: Generate an HMAC-SHA-1 value
17
      hmac_result = :crypto.mac(:hmac, @hmac_algorithm, secret, <<counter::size(64)>>)
3,025✔
18

19
      # Step 2: Dynamic truncation
20
      truncated_hash = truncate_hash(hmac_result)
3,025✔
21

22
      # Step 3: Compute HOTP value (6-digit OTP)
23
      hotp = truncated_hash |> rem(10 ** @digits)
3,025✔
24

25
      format_hotp(hotp)
3,025✔
26
    end
27

28
    defp truncate_hash(hmac_value) do
29
      # NOTE the folowing hard coded values are part of the specification
30
      offset = :binary.at(hmac_value, 19) &&& 0xF
3,025✔
31

32
      with <<_::size(1), result::size(31)>> <- :binary.part(hmac_value, offset, 4) do
3,025✔
33
        result
3,025✔
34
      end
35
    end
36

37
    defp format_hotp(hotp) do
38
      Integer.to_string(hotp, 16) |> String.downcase()
3,025✔
39
    end
40
  end
41

42
  defmodule Status do
43
    @moduledoc """
44
    Implements status tokens as stated in [this specification draft](https://github.com/malach-it/vc-decentralized-status/blob/main/SPECIFICATION.md) helping to annotate identity information.
45
    """
46

47
    @status_table [
48
      :valid,
49
      :suspended,
50
      :revoked
51
    ]
52

53
    @spec shift(status :: atom()) :: shift :: integer()
54
    def shift(status) do
55
      Atom.to_string(status)
56
      |> :binary.decode_unsigned()
3,022✔
57
    end
58

59
    @spec generate_status_token(secret :: String.t(), ttl :: integer(), status :: atom()) ::
60
            status_token :: String.t()
61
    def generate_status_token(secret, ttl, status) do
62
      iat =
1,010✔
63
        :os.system_time(:microsecond)
64
        |> :binary.encode_unsigned()
65
        |> :binary.bin_to_list()
66
        |> :string.right(7, 0)
67

68
      padded_ttl =
1,010✔
69
        :binary.encode_unsigned(ttl)
70
        |> :binary.bin_to_list()
71
        |> :string.right(4, 0)
72

73
      status_list =
1,010✔
74
        iat ++
75
          padded_ttl
76

77
      status_information =
1,010✔
78
        status_list
79
        |> to_string()
1,010✔
80
        |> Base.url_encode64(padding: false)
81

82
      derived_status =
1,010✔
83
        Hotp.generate_hotp(
84
          secret,
85
          div(:os.system_time(:seconds), ttl) + shift(status)
86
        )
87

88
      "#{status_information}~#{derived_status}"
1,010✔
89
    end
90

91
    @spec verify_status_token(secret :: String.t(), status_token :: String.t()) ::
92
            status :: atom()
93
    def verify_status_token(secret, status_token) do
1,007✔
94
      [status_list, hotp] = String.split(status_token, "~")
1,007✔
95

96
      %{ttl: ttl} =
1,006✔
97
        status_list
98
        |> Base.url_decode64!(padding: false)
99
        |> to_charlist()
100
        |> parse_statuslist()
101

102
      Enum.reduce_while(@status_table, :expired, fn status, acc ->
1,006✔
103
        case hotp ==
2,004✔
104
               Hotp.generate_hotp(
105
                 secret,
106
                 div(:os.system_time(:seconds), ttl) + shift(status)
107
               ) do
108
          true -> {:halt, status}
1,006✔
109
          false -> {:cont, acc}
998✔
110
        end
111
      end)
112
    rescue
113
      _ -> :invalid
1✔
114
    end
115

116
    def parse_statuslist(statuslist) do
117
      parse_statuslist(statuslist, {0, %{ttl: [], memory: []}})
1,006✔
118
    end
119

120
    def parse_statuslist([], {_index, result}), do: result
1,006✔
121

122
    def parse_statuslist([_char | t], {index, acc}) when index < 7 do
123
      parse_statuslist(t, {index + 1, acc})
7,042✔
124
    end
125

126
    def parse_statuslist([char | t], {index, acc}) when index < 10 do
127
      acc = Map.put(acc, :memory, acc[:memory] ++ [char])
3,018✔
128
      parse_statuslist(t, {index + 1, acc})
3,018✔
129
    end
130

131
    def parse_statuslist([char | t], {index, acc}) when index == 10 do
132
      acc =
1,006✔
133
        acc
134
        |> Map.put(
135
          :ttl,
136
          (acc[:memory] ++ [char])
137
          |> :erlang.list_to_binary()
138
          |> :binary.decode_unsigned()
139
        )
140
        |> Map.put(:memory, [])
141

142
      parse_statuslist(t, {index + 1, acc})
1,006✔
143
    end
144
  end
145

146
  @moduledoc false
147

148
  alias Boruta.Config
149
  alias Boruta.Did
150
  alias Boruta.Oauth.Client
151
  alias Boruta.Oauth.ResourceOwner
152
  alias Boruta.Oauth.Scope
153
  alias Boruta.Openid.Credential
154
  alias ExJsonSchema.Schema
155
  alias ExJsonSchema.Validator.Error.BorutaFormatter
156

157
  # @public_client_did "did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbrSfZqXLVn\
158
  # TT5rRw7VCjbapSKSfZEUSekzuBrGZhfwxQTfsNVeUYsX5gH2eJ4LdVt6uctFyJsW76VygayYHiHpwnhGwAombi\
159
  # RJiimmRTMXUAa49VQ9NWT7PUK2P7VbBy4Bn"
160
  @individual_claim_default_expiration 3600 * 24 * 365 * 120
161

162
  @authorization_details_schema %{
163
    "type" => "array",
164
    "items" => %{
165
      "type" => "object",
166
      "properties" => %{
167
        "type" => %{"type" => "string", "pattern" => "^openid_credential$"},
168
        "format" => %{"type" => "string"},
169
        "credential_definition" => %{
170
          "type" => "object",
171
          "properties" => %{
172
            "type" => %{
173
              "type" => "array",
174
              "items" => %{"type" => "string"}
175
            }
176
          }
177
        }
178
      },
179
      "required" => ["type", "format"]
180
    }
181
  }
182

183
  @proof_schema %{
184
                  "type" => "object",
185
                  "properties" => %{
186
                    "proof_type" => %{"type" => "string", "pattern" => "^jwt$"},
187
                    "jwt" => %{"type" => "string"}
188
                  },
189
                  "required" => ["proof_type", "jwt"]
190
                }
191
                |> Schema.resolve()
192

193
  defmodule Token do
194
    @moduledoc false
195

196
    use Joken.Config
197

198
    def token_config, do: %{}
21✔
199
  end
200

201
  @spec issue_verifiable_credential(
202
          resource_owner :: ResourceOwner.t(),
203
          credential_params :: map(),
204
          token :: Boruta.Oauth.Token.t(),
205
          default_credential_configuration :: map()
206
        ) :: {:ok, credential :: Credential.t()} | {:error, reason :: String.t()}
207
  def issue_verifiable_credential(
208
        resource_owner,
209
        credential_params,
210
        token,
211
        default_credential_configuration
212
      ) do
213
    proof = credential_params["proof"]
18✔
214

215
    credential_configuration =
18✔
216
      case resource_owner.sub do
18✔
217
        "did:" <> _key -> default_credential_configuration
×
218
        _ -> resource_owner.credential_configuration
18✔
219
      end
220

221
    # TODO filter from resource owner authorization details
222
    with {credential_identifier, credential_configuration} <-
18✔
223
           Enum.find(credential_configuration, fn {identifier, configuration} ->
224
             case configuration[:version] do
16✔
225
               "11" ->
226
                 (credential_params["types"] &&
×
227
                    Enum.empty?(configuration[:types] -- credential_params["types"])) ||
×
228
                   Enum.member?(Scope.split(token.scope), identifier)
×
229

230
               "13" ->
231
                 types = credential_params["vct"] || credential_params["credential_identifier"]
16✔
232

233
                 (types &&
×
234
                    Enum.member?(
16✔
235
                      configuration[:types],
236
                      types
237
                    )) ||
16✔
238
                   Enum.member?(Scope.split(token.scope), identifier)
×
239
             end
240
           end),
241
         {:ok, proof} <- validate_proof_format(proof),
16✔
242
         :ok <- validate_headers(proof["jwt"]),
15✔
243
         :ok <- validate_claims(proof["jwt"]),
11✔
244
         {:ok, claims} <-
10✔
245
           extract_credential_claims(
246
             resource_owner,
247
             credential_configuration
248
           ),
249
         {:ok, jwk, _claims} <- validate_signature(proof["jwt"]),
10✔
250
         {:ok, credential} <-
10✔
251
           generate_credential(
252
             claims,
253
             {credential_identifier, credential_configuration},
254
             {jwk, proof["jwt"]},
255
             token,
256
             credential_configuration[:format]
257
           ) do
258
      credential = %Credential{
10✔
259
        format: credential_configuration[:format],
260
        defered: credential_configuration[:defered],
261
        credential: credential
262
      }
263

264
      {:ok, credential}
265
    else
266
      nil -> {:error, "Credential not found."}
267
      error -> error
6✔
268
    end
269
  end
270

271
  @spec validate_authorization_details(authorization_details :: String.t()) ::
272
          :ok | {:error, reason :: String.t()}
273
  def validate_authorization_details(authorization_details) do
274
    with {:ok, authorization_details} <- Jason.decode(authorization_details),
40✔
275
         :ok <-
40✔
276
           ExJsonSchema.Validator.validate(
277
             @authorization_details_schema,
278
             authorization_details,
279
             error_formatter: BorutaFormatter
280
           ) do
281
      :ok
282
    else
283
      {:error, errors} when is_list(errors) ->
284
        {:error, "authorization_details validation failed. " <> Enum.join(errors, " ")}
285

286
      {:error, error} ->
287
        {:error, "authorization_details validation failed. #{inspect(error)}"}
288
    end
289
  end
290

291
  @spec validate_signature(jwt :: String.t()) ::
292
          {:ok, jwk :: map(), claims :: map()} | {:error, reason :: String.t()}
293
  def validate_signature(jwt) when is_binary(jwt) do
294
    case Joken.peek_header(jwt) do
14✔
295
      {:ok, %{"alg" => alg} = headers} ->
296
        verify_jwt(extract_key(headers), alg, jwt)
13✔
297

298
      error ->
1✔
299
        {:error, inspect(error)}
300
    end
301

302
    # rescue
303
    #   error ->
304
    #     {:error, inspect(error)}
305
  end
306

307
  def validate_signature(_jwt), do: {:error, "Proof does not contain a valid JWT."}
×
308

309
  defp verify_jwt({:did, did}, alg, jwt) do
310
    with {:ok, did_document} <- Did.resolve(did),
10✔
311
         %{"verificationMethod" => methods} <- did_document do
10✔
312
      Enum.reduce_while(
10✔
313
        methods,
314
        {:error, "no did verification method found with did #{did}."},
10✔
315
        fn %{"publicKeyJwk" => jwk}, {:error, errors} ->
316
          signer =
10✔
317
            Joken.Signer.create(alg, %{"pem" => JOSE.JWK.from_map(jwk) |> JOSE.JWK.to_pem()})
318

319
          case Client.Token.verify(jwt, signer) do
10✔
320
            {:ok, claims} ->
10✔
321
              {:halt, {:ok, jwk, claims}}
322

323
            {:error, error} ->
×
324
              {:cont, {:error, errors <> ", #{inspect(error)} with key #{inspect(jwk)}"}}
325
          end
326
        end
327
      )
328
    else
329
      {:error, error} ->
330
        {:error, error}
331

332
      did_document ->
333
        {:error, "Invalid did document: \"#{inspect(did_document)}\""}
334
    end
335
  end
336

337
  defp verify_jwt({:jwk, jwk}, "EdDSA", jwt) do
338
    signer =
×
339
      Joken.Signer.create("ES256", %{"pem" => jwk |> JOSE.JWK.from_map() |> JOSE.JWK.to_pem()})
340

341
    case Token.verify(jwt, signer) do
×
342
      {:ok, claims} ->
343
        {:ok, jwk, claims}
×
344

345
      _ ->
×
346
        {:error, "Bad proof signature"}
347
    end
348
  end
349

350
  defp verify_jwt({:jwk, jwk}, alg, jwt) do
351
    signer = Joken.Signer.create(alg, %{"pem" => jwk |> JOSE.JWK.from_map() |> JOSE.JWK.to_pem()})
3✔
352

353
    case Token.verify(jwt, signer) do
3✔
354
      {:ok, claims} ->
355
        {:ok, jwk, claims}
3✔
356

357
      _ ->
×
358
        {:error, "Bad proof signature"}
359
    end
360
  end
361

362
  defp verify_jwt(error, _alg, _jwt), do: error
×
363

364
  defp validate_proof_format(proof) do
365
    case ExJsonSchema.Validator.validate(
16✔
366
           @proof_schema,
367
           proof,
368
           error_formatter: BorutaFormatter
369
         ) do
370
      :ok ->
15✔
371
        {:ok, proof}
372

373
      {:error, errors} ->
1✔
374
        {:error, "Proof validation failed. " <> Enum.join(errors, " ")}
375
    end
376
  end
377

378
  defp validate_headers(jwt) when is_binary(jwt) do
379
    case Joken.peek_header(jwt) do
15✔
380
      {:ok, %{"alg" => alg, "typ" => typ} = headers} ->
381
        alg_check =
15✔
382
          case alg =~ ~r/^(RS|ES|EdDSA)/ do
383
            true ->
13✔
384
              :ok
385

386
            false ->
2✔
387
              "Proof JWT must be asymetrically signed"
388
          end
389

390
        typ_check =
15✔
391
          case typ =~ ~r/^openid4vci-proof\+jwt|JWT$/ do
392
            true ->
13✔
393
              :ok
394

395
            false ->
2✔
396
              "Proof JWT must have `openid4vci-proof+jwt` or `JWT` typ header"
397
          end
398

399
        key_check =
15✔
400
          case extract_key(headers) do
401
            {:error, reason} ->
402
              reason
2✔
403

404
            _ ->
13✔
405
              :ok
406
          end
407

408
        do_validate_headers([alg_check, typ_check, key_check])
15✔
409

410
      _ ->
×
411
        {:error, "Proof does not contain valid JWT headers, `alg` and `typ` are required."}
412
    end
413
  end
414

415
  defp validate_headers(_jwt), do: {:error, "Proof does not contain a valid JWT."}
×
416

417
  defp validate_claims(jwt) when is_binary(jwt) do
418
    case Joken.peek_claims(jwt) do
11✔
419
      {:ok, %{"aud" => _aud, "iat" => _iat}} ->
10✔
420
        :ok
421

422
      _ ->
1✔
423
        {:error, "Proof does not contain valid JWT claims, `aud` and `iat` claims are required."}
424
    end
425
  end
426

427
  defp validate_claims(_jwt), do: {:error, "Proof does not contain a valid JWT."}
×
428

429
  defp generate_credential(
430
         claims,
431
         {credential_identifier, credential_configuration},
432
         {_jwk, proof},
433
         token,
434
         format
435
       )
436
       when format in ["jwt_vc_json"] do
437
    client = token.client
1✔
438

439
    sub =
1✔
440
      case Joken.peek_header(proof) do
441
        {:ok, headers} ->
442
          case(extract_key(headers)) do
1✔
443
            {_type, key} -> key
1✔
444
          end
445
      end
446

447
    now = :os.system_time(:seconds)
1✔
448
    credential_id = SecureRandom.uuid()
1✔
449
    sub = sub |> String.split("#") |> List.first()
1✔
450

451
    payload = %{
1✔
452
      "sub" => sub,
453
      # TODO store credential
454
      "jti" => Config.issuer() <> "/credentials/#{credential_id}",
1✔
455
      "iss" => Did.controller(client.did) || Config.issuer(),
1✔
456
      "nbf" => now,
457
      "iat" => now,
458
      "exp" => now + credential_configuration[:time_to_live],
459
      "nonce" => token.c_nonce,
1✔
460
      "vc" => %{
461
        "@context" => [
462
          "https://www.w3.org/2018/credentials/v1"
463
        ],
464
        # TODO store credential
465
        "id" => Config.issuer() <> "/credentials/#{credential_id}",
1✔
466
        "issued" => DateTime.from_unix!(now) |> DateTime.to_iso8601(),
467
        "issuanceDate" => DateTime.from_unix!(now) |> DateTime.to_iso8601(),
468
        "type" => credential_configuration[:types],
469
        "issuer" => client.did,
1✔
470
        "validFrom" => DateTime.from_unix!(now) |> DateTime.to_iso8601(),
471
        "credentialSubject" => %{
472
          "id" => sub,
473
          credential_identifier =>
474
            claims
475
            |> Enum.map(fn {name, {claim, _status, _expiration}} -> {name, claim} end)
1✔
476
            |> Enum.into(%{})
477
            |> Map.put("id", client.did)
1✔
478
        },
479
        "credentialSchema" => %{
480
          "id" =>
481
            "https://api-pilot.ebsi.eu/trusted-schemas-registry/v2/schemas/z3MgUFUkb722uq4x3dv5yAJmnNmzDFeK5UC8x83QoeLJM",
482
          "type" => "FullJsonSchemaValidator2021"
483
        }
484
      }
485
    }
486

487
    case Client.signatures_adapter(client).verifiable_credential_sign(payload, client, format) do
1✔
NEW
488
      {:error, error} ->
×
489
        {:error, error}
490

491
      credential ->
1✔
492
        {:ok, credential}
493
    end
494
  end
495

496
  # https://www.w3.org/TR/vc-data-model-2.0/
497
  defp generate_credential(
498
         claims,
499
         {credential_identifier, credential_configuration},
500
         {jwk, proof},
501
         token,
502
         format
503
       )
504
       when format in ["jwt_vc"] do
505
    client = token.client
4✔
506

507
    sub =
4✔
508
      case Joken.peek_header(proof) do
509
        {:ok, headers} ->
510
          case extract_key(headers) do
4✔
511
            {_type, key} -> key
4✔
512
          end
513
      end
514

515
    credential_id = SecureRandom.uuid()
4✔
516
    now = :os.system_time(:seconds)
4✔
517

518
    payload = %{
4✔
519
      "@context" => [
520
        "https://www.w3.org/ns/credentials/v2"
521
      ],
522
      # TODO store credential
523
      "id" => Config.issuer() <> "/credentials/#{credential_id}",
4✔
524
      "issued" => DateTime.from_unix!(now) |> DateTime.to_iso8601(),
525
      "issuanceDate" => DateTime.from_unix!(now) |> DateTime.to_iso8601(),
526
      "type" => credential_configuration[:types],
527
      "issuer" => Did.controller(client.did),
4✔
528
      "validFrom" => DateTime.utc_now() |> DateTime.to_iso8601(),
529
      "credentialSubject" => %{
530
        "id" => sub,
531
        credential_identifier =>
532
          claims
533
          |> Enum.map(fn {name, {claim, _status, _expiration}} -> {name, claim} end)
4✔
534
          |> Enum.into(%{})
535
          |> Map.put("id", client.did)
4✔
536
      },
537
      "cnf" => %{
538
        "jwk" => jwk
539
      }
540
    }
541

542
    case Client.signatures_adapter(client).verifiable_credential_sign(payload, client, format) do
4✔
NEW
543
      {:error, error} ->
×
544
        {:error, error}
545

546
      credential ->
4✔
547
        {:ok, credential}
548
    end
549
  end
550

551
  defp generate_credential(
552
         claims,
553
         {_credential_identifier, credential_configuration},
554
         {jwk, proof},
555
         token,
556
         format
557
       )
558
       when format in ["vc+sd-jwt"] do
559
    client = token.client
5✔
560

561
    sub =
5✔
562
      case Joken.peek_header(proof) do
563
        {:ok, headers} ->
564
          case(extract_key(headers)) do
5✔
565
            {_type, key} -> key
5✔
566
          end
567
      end
568

569
    claims_with_salt =
5✔
570
      Enum.map(claims, fn {name, {value, status, ttl}} ->
6✔
571
        {{name, value},
572
         Status.generate_status_token(client.private_key, ttl, String.to_atom(status))}
6✔
573
      end)
574

575
    disclosures =
5✔
576
      claims_with_salt
577
      |> Enum.map(fn {{name, value}, salt} ->
6✔
578
        [salt, name, value]
579
      end)
580

581
    sd =
5✔
582
      disclosures
583
      # NOTE no space in disclosure array
584
      |> Enum.map(fn disclosure -> Jason.encode!(disclosure) end)
6✔
585
      |> Enum.map(fn disclosure -> Base.url_encode64(disclosure, padding: false) end)
6✔
586
      |> Enum.map(fn disclosure ->
587
        :crypto.hash(:sha256, disclosure) |> Base.url_encode64(padding: false)
6✔
588
      end)
589

590
    payload = %{
5✔
591
      "sub" => sub,
592
      "vct" => credential_configuration[:vct],
593
      "iss" => Did.controller(client.did) || Config.issuer(),
5✔
594
      "iat" => :os.system_time(:seconds),
595
      "exp" => :os.system_time(:seconds) + credential_configuration[:time_to_live],
596
      "_sd" => sd,
597
      "cnf" => %{
598
        "jwk" => jwk
599
      }
600
    }
601

602
    case Client.signatures_adapter(client).verifiable_credential_sign(payload, client, format) do
5✔
NEW
603
      {:error, error} ->
×
604
        {:error, error}
605

606
      credential ->
607
        tokens =
5✔
608
          [credential] ++
609
            (disclosures
610
             |> Enum.map(&Jason.encode!/1)
611
             |> Enum.map(&Base.url_encode64(&1, padding: false)))
6✔
612

613
        {:ok, Enum.join(tokens, "~") <> "~"}
614
    end
615
  end
616

617
  defp generate_credential(_claims, _credential_configuration, _proof, _client, _format),
×
618
    do: {:error, "Unkown format."}
619

620
  defp extract_credential_claims(resource_owner, credential_configuration) do
621
    claims =
10✔
622
      credential_configuration[:claims]
623
      |> Enum.map(fn
624
        %{"name" => name, "pointer" => pointer} = claim ->
625
          resource_owner_claim =
6✔
626
            case get_in(resource_owner.extra_claims, String.split(pointer, ".")) do
6✔
627
              value when is_binary(value) -> %{"value" => value}
1✔
628
              claim -> claim
5✔
629
            end
630

631
          {name,
632
           {resource_owner_claim["value"], resource_owner_claim["status"] || "valid",
6✔
633
            (claim["expiration"] && String.to_integer(claim["expiration"])) ||
6✔
634
              @individual_claim_default_expiration}}
635

636
        attribute when is_binary(attribute) ->
5✔
637
          {attribute,
638
           {get_in(resource_owner.extra_claims, String.split(attribute, ".")), "valid",
5✔
639
            @individual_claim_default_expiration}}
640
      end)
641
      |> Enum.into(%{})
6✔
642

643
    {:ok, claims}
644
  end
645

646
  defp extract_key(%{"kid" => did}), do: {:did, did}
27✔
647
  defp extract_key(%{"jwk" => jwk}), do: {:jwk, jwk}
9✔
648
  defp extract_key(_headers), do: {:error, "No proof key material found in JWT headers"}
2✔
649

650
  defp do_validate_headers(checks) do
651
    do_validate_headers(checks, [])
15✔
652
  end
653

654
  defp do_validate_headers([], []), do: :ok
11✔
655
  defp do_validate_headers([], errors), do: {:error, Enum.join(errors, ", ") <> "."}
4✔
656
  defp do_validate_headers([:ok | checks], errors), do: do_validate_headers(checks, errors)
39✔
657

658
  defp do_validate_headers([error | checks], errors),
659
    do: do_validate_headers(checks, errors ++ [error])
6✔
660
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc