From c12d5c3842b3c3d4b1a2f8a652444772efd2201c Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 21 Oct 2026 13:25:38 -0400 Subject: [PATCH] feat(s142): complete slice 142 (the N6 codec and code registry follow draft v0.1-r7: the text rule on pinned Unicode 15.0.0 tables, the profile's step order, r7's codes, agreement with the publisher's cut vectors) Signed-off-by: Ayla Croft --- CHANGELOG.md | 15 + LICENSES/Unicode-3.0.txt | 39 ++ bin/verify_receipt.exs | 2 +- docs/07-security-model.md | 36 ++ lib/trinity/n6.ex | 6 +- lib/trinity/n6/body.ex | 278 ++++++++++---- lib/trinity/n6/cbor.ex | 199 ++++++---- lib/trinity/n6/cose.ex | 215 +++++++---- lib/trinity/n6/digest.ex | 25 +- lib/trinity/n6/ed25519.ex | 59 +++ lib/trinity/n6/profile.ex | 214 ++++++----- lib/trinity/n6/unicode15.ex | 339 ++++++++++++++++++ lib/trinity/receipts/v4.ex | 13 +- scripts/n6_vectors.exs | 23 +- scripts/unicode15_tables.exs | 207 +++++++++++ test/fixtures/n6/README.md | 16 +- test/fixtures/n6/SHA384SUMS | 24 +- .../no_name.cbor | Bin .../negative/field_missing/no_name.expect | 1 + .../name_as_uint.cbor | Bin .../field_type_mismatch/name_as_uint.expect | 1 + .../required_key_missing/no_name.expect | 1 - .../negative/wrong_type/name_as_uint.expect | 1 - .../negative/digest_not_array/bstr.expect | 2 +- .../text_nfd/e_combining_acute.expect | 2 +- .../text_nfd/nested_in_map_value.expect | 2 +- .../negative/alg_missing/no_label_1.expect | 2 +- .../negative/kid_missing/no_label_4.expect | 2 +- .../negative/kid_wrong_size/kid_32.expect | 2 +- .../negative/typ_missing/no_label_16.expect | 2 +- test/support/n6_r7_vectors.ex | 248 +++++++++++++ test/trinity/n6/body_digest_test.exs | 2 +- test/trinity/n6/carrier_test.exs | 2 +- test/trinity/n6/cbor_test.exs | 24 +- test/trinity/n6/corpus_test.exs | 4 +- test/trinity/n6/cose_test.exs | 4 +- test/trinity/n6/publisher_vectors_test.exs | 40 +++ test/trinity/n6/r7_alignment_test.exs | 126 +++++++ test/trinity/receipts/v4_test.exs | 6 +- 39 files changed, 1787 insertions(+), 397 deletions(-) create mode 100644 LICENSES/Unicode-3.0.txt create mode 100644 lib/trinity/n6/ed25519.ex create mode 100644 lib/trinity/n6/unicode15.ex create mode 100644 scripts/unicode15_tables.exs rename test/fixtures/n6/body/negative/{required_key_missing => field_missing}/no_name.cbor (100%) create mode 100644 test/fixtures/n6/body/negative/field_missing/no_name.expect rename test/fixtures/n6/body/negative/{wrong_type => field_type_mismatch}/name_as_uint.cbor (100%) create mode 100644 test/fixtures/n6/body/negative/field_type_mismatch/name_as_uint.expect delete mode 100644 test/fixtures/n6/body/negative/required_key_missing/no_name.expect delete mode 100644 test/fixtures/n6/body/negative/wrong_type/name_as_uint.expect create mode 100644 test/support/n6_r7_vectors.ex create mode 100644 test/trinity/n6/publisher_vectors_test.exs create mode 100644 test/trinity/n6/r7_alignment_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index decb0f6..28aba40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -144,6 +144,21 @@ both profiles and refusing on the national security profile by mode or by build pass on that profile shown by the decision's own tests, since no CI runtime carries the validated provider. +### `slice/142`: signed statements follow the profile's v0.1 draft + +The checks Trinity runs on its signed-statement format now follow the profile publisher's v0.1 +draft in what they check and in which order, so Trinity names the same first defect as every other +implementation. Text in signed statements is judged by one pinned Unicode version (15.0.0), never +the runtime's, and refuses unassigned code points, invisible and bidirectional format controls, and +text that is not plainly in normal form. Bodies, headers and keys are checked in the draft's order, +with its names for each defect. + +Verified by the test suite: agreement with every vector the publisher has cut, 593 across five +kinds, by a test that reads the publisher's set from a path and checks its digests first, and that +was shown to fail on a planted disagreement; the Unicode tables' counts against the draft's and +their regeneration byte for byte from the published data files; and a census that no normaliser is +called. + ## 2026-10-08 ### `slice/136`: the web pages ask who is there diff --git a/LICENSES/Unicode-3.0.txt b/LICENSES/Unicode-3.0.txt new file mode 100644 index 0000000..56da589 --- /dev/null +++ b/LICENSES/Unicode-3.0.txt @@ -0,0 +1,39 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/bin/verify_receipt.exs b/bin/verify_receipt.exs index 21ede0d..5ad770a 100755 --- a/bin/verify_receipt.exs +++ b/bin/verify_receipt.exs @@ -51,7 +51,7 @@ defmodule VerifyReceipt do @signed_kinds ~w(decision effect boot cap) # The sources a v4 row needs, relative to Trinity's lib directory, in the order they are loaded. - @n6_sources ~w(n6/profile.ex n6/cbor.ex n6/digest.ex n6/body.ex n6/ec2.ex n6/cose.ex n6/carrier.ex receipts/v4.ex) + @n6_sources ~w(n6/profile.ex n6/unicode15.ex n6/cbor.ex n6/digest.ex n6/body.ex n6/ec2.ex n6/ed25519.ex n6/cose.ex n6/carrier.ex receipts/v4.ex) # Named at run time, so compiling this file before the sources are loaded warns about nothing. defp v4, do: Module.concat(["Trinity", "Receipts", "V4"]) diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 60a0a6d..9591e92 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -694,6 +694,42 @@ sign through the layer. New rows and checkpoints are written under `receipt_v4_< `Trinity.Receipts.V4` from Trinity's `lib/` (`--n6` names it) and runs the same rules as the in-app verifier, which a test holds it to; a chain with no v4 row still needs nothing but OTP. +### The profile's v0.1 draft, and the publisher's vectors (Slice 142, as built) + +The profile's publisher posted draft v0.1-r7 on 2026-10-21. It fixes the order every check runs in +(steps D0 to D18 for the codec, B1 to B8 for a body, H1 to H4 for a protected header, K1 to K3 for +a key) and closes the list of codes a verifier may answer. Trinity's N6 layer now follows it, and +agrees with every vector the publisher has cut: 347 encoding, 124 body, 85 header, 18 thumbprint +and 19 digest vectors, measured against the publisher's set by a test anyone can point at it +(`TRINITY_N6_PUBLISHER_VECTORS`, `test/trinity/n6/publisher_vectors_test.exs`). + +- **Text is judged by one pinned Unicode version, never the runtime's.** A text string is refused + if it holds a code point unassigned in Unicode 15.0.0, a format control (the bidirectional + controls, zero-width characters, the soft hyphen, the byte-order mark, the tag characters), a + combining mark out of canonical order, or a code point whose NFC quick check is No or Maybe. + Format controls are refused because they make what a reviewer sees differ from what was signed. + The four tables are generated from the Unicode 15.0.0 data files, each checked by SHA-256 + (`scripts/unicode15_tables.exs`, `Trinity.N6.Unicode15`), and no Unicode normaliser runs anywhere + in the layer: runtimes ship different Unicode versions, and the one OTP 28 carries already calls + some 15.0.0-unassigned code points letters. +- **Every head is checked in the profile's order.** A map key whose head is malformed keeps that + defect's code before its type is looked at; an argument's missing bytes are found before a tag or + float is refused; a container's declared contents are counted against the item limit at its head, + before anything is read. +- **A body is one scan.** Required keys, undeclared keys and each value's type, range, grammar and + set order are checked in one pass over the keys in ascending order, so the first defect is the + same one in every implementation. Field types now carry ranges, enumerations, byte bounds and text + grammars. +- **A header is judged by its `typ` first,** because the `typ` selects which labels may appear; a + missing label is `header_missing`, a value of the wrong shape `header_value_invalid`. A strictly + decoded Ed25519 key (RFC 8032 section 5.1.3) is required where one is registered; OpenSSL accepts + some encodings that rule refuses. + +Not covered here: what a verifier answers about a defect it finds after a signature has verified, +and the publisher's further Ed25519 rule on small-order points, which are the next increment. One +code Trinity answers, `alg_unavailable` (the runtime cannot run the header's algorithm), is not in +the draft's list; it is kept and has been put to the publisher. + ## The effect population, published (Slice 027, as built) The set of things that can cause an effect is small, derived from the tree, and published as diff --git a/lib/trinity/n6.ex b/lib/trinity/n6.ex index 0fba24a..a327848 100644 --- a/lib/trinity/n6.ex +++ b/lib/trinity/n6.ex @@ -10,6 +10,8 @@ defmodule Trinity.N6 do * `Trinity.N6.CBOR`: the encoder and the strict decoder (rules E1 to E11); * `Trinity.N6.Body`: a statement body against its declared schema; + * `Trinity.N6.Unicode15`: the Unicode 15.0.0 tables the text rule reads (generated); + * `Trinity.N6.Ed25519`: the strict decoding of an Ed25519 point; * `Trinity.N6.Digest`: SHA-384 references to foreign bytes; * `Trinity.N6.COSE`: the header rules, the key thumbprint, signing and verifying; * `Trinity.N6.EC2`: ESP384's P-384 keys, thumbprint and `r || s` signatures (slice 140); @@ -18,8 +20,8 @@ defmodule Trinity.N6 do This is a top-level boundary with no dependency on the rest of Trinity, as `Trinity.Keys` is, so it stays extractable: the compiler refuses a call from here into any other Trinity module. - The profile is provisional (v0, draft r3, and the publisher's answers of 2026-10-19 and - 2026-10-20); see `Trinity.N6.Profile`. Nothing Trinity signed + The profile is provisional (v0.1, draft v0.1-r7, since slice 142); see `Trinity.N6.Profile`. + Nothing Trinity signed before this boundary existed is changed by it. """ diff --git a/lib/trinity/n6/body.ex b/lib/trinity/n6/body.ex index c2aae32..014c087 100644 --- a/lib/trinity/n6/body.ex +++ b/lib/trinity/n6/body.ex @@ -2,14 +2,13 @@ # SPDX-License-Identifier: Apache-2.0 defmodule Trinity.N6.Body do @moduledoc """ - The body rules of the N6 profile (its section 3; slice 138): the shape every signed statement's - body takes, checked against a declared schema. + The body rules of the N6 profile (draft v0.1-r7 section 3; slices 138 and 142): the shape every + signed statement's body takes, checked against a declared schema. A body is a map. Key 0 is the schema version, an unsigned integer (major type 0 only), so it - always encodes first. The pair of statement kind and schema version must be one the caller - declares; within it every key must be declared (there is no "ignore unknown" mode: a new field is - a new schema version), every required key present, and every value of its declared type. Key 0 - values 0xFFFF0000 to 0xFFFFFFFF are reserved for carrier structures and are refused in a body. + always encodes first. Within a known schema every key must be declared (there is no "ignore + unknown" mode: a new field is a new schema version), every required key present, and every value + of its declared type, range and grammar. An array is a sequence unless its field is declared a set. A set is encoded sorted by the encoded bytes of its elements, with no duplicates. The bytes alone cannot say which arrays are sets, @@ -17,54 +16,90 @@ defmodule Trinity.N6.Body do ## Schemas - %{kind: "trinity.test", version: 1, - fields: %{16 => {:name, :tstr, :required}, 18 => {:members, {:set, :uint}, :optional}}} - - Types: `:uint`, `:int`, `:tstr`, `:bstr`, `:bool`, `:any`, `{:array, type}`, `{:set, type}`, - `:digest` (`Trinity.N6.Digest`), and `{:map, fields}` for a nested map with no schema version. - - Checks run in this order, each answering the first failure: not a map (`body_not_map`); key 0 - missing (`schema_version_missing`), not an unsigned integer (`key_not_uint`), reserved - (`schema_reserved`), in the shared test block 0x80 outside a declared test mode - (`test_schema_outside_test_mode`, slice 140), unknown with this kind (`unknown_schema`), the order - of the publisher's correction of 2026-10-20 17:27Z; then keys in ascending order, - undeclared (`unknown_key`); then required keys (`required_key_missing`); then each value in key - order (`wrong_type`, `set_not_canonical`, or a digest's own code). `body_not_map`, - `required_key_missing` and `wrong_type` are this tree's codes where the profile has none yet. + %{kind: "trinity.test", version: 0x80000101, typ: "application/...+cose", + fields: %{1 => {:issuer, {:tstr, 1, 255, :printable_ascii}, :required}, + 20 => {:tags, {:set, {:int, 0, 0xFFFFFFFF}}, :optional}}} + + `version` is the whole key-0 value. `typ`, when given, is the schema's one `typ` (B7). + + Types: + + * integers: `:uint` (0 to 2^64-1), `:int` (the profile's whole range), `{:int, min, max}`, and + `{:enum, length, reserved_zero?}`, an index `0 <= i < length`, 0 refused when reserved; + * strings: `:tstr` and `:bstr` (bounded only by the profile), `{:tstr, min, max, grammar}` with + `grammar` one of `:any`, `:printable_ascii` (0x21 to 0x7E) or `:media_type` (r7 section 4), + and `{:bstr, min, max}`; lengths are bytes; + * `:bool`, `:any`, `:digest` (`Trinity.N6.Digest`), `:foreign_ref` (r7 section 4); + * `{:array, type}`, `{:set, type}`, `{:tuple, [type]}` (an array of fixed shape), + `{:map, fields}` (a nested map with no schema version), `{:one_of, [type]}` (the first whose + CBOR type matches), and `{:nullable, type}`, the only place `null` is accepted. + + ## Order + + r7 section 3.6, steps B1 to B8, each answering the first failure: not a map (`body_not_map`); key + 0 missing (`schema_version_missing`), not an unsigned integer (`key_not_uint`), reserved + (`schema_reserved`), in the shared test block outside a declared test mode + (`test_schema_outside_test_mode`), not issued (`unknown_schema`); presented under a `typ` that is + not the schema's (`typ_schema_mismatch`, B7, only when a `typ` is given); then **one scan** over + the keys present and the keys required, in ascending order (B8). At each key: required and + absent is `issuer_missing` for the top level's key 1 and `field_missing` otherwise; present and + undeclared is `unknown_key`; present and declared is checked by its type: the CBOR type + (`field_type_mismatch`), then range, length or enum index (`value_out_of_range`), then grammar + (`text_grammar_mismatch`), then a set's order (`set_not_canonical`, each element checked as the + scan reaches it and then compared with the previous one), then a nested map by the same scan. """ alias Trinity.N6.{CBOR, Digest, Profile} @reserved 0xFFFF_0000..0xFFFF_FFFF + @uint64_max 0xFFFF_FFFF_FFFF_FFFF + @int64_min -0x8000_0000_0000_0000 @typedoc "A field type." @type type :: :uint | :int + | {:int, integer(), integer()} + | {:enum, pos_integer(), boolean()} | :tstr + | {:tstr, non_neg_integer(), non_neg_integer(), :any | :printable_ascii | :media_type} | :bstr + | {:bstr, non_neg_integer(), non_neg_integer()} | :bool | :any | :digest + | :foreign_ref | {:array, type()} | {:set, type()} + | {:tuple, [type()]} | {:map, %{optional(non_neg_integer()) => field()}} + | {:one_of, [type()]} + | {:nullable, type()} @typedoc "A field: its name, its type, and whether it must be present." @type field :: {atom(), type(), :required | :optional} @typedoc "A schema: one statement kind at one schema version." @type schema :: %{ - kind: String.t(), - version: non_neg_integer(), - fields: %{optional(pos_integer()) => field()} + required(:kind) => String.t(), + required(:version) => non_neg_integer(), + required(:fields) => %{optional(pos_integer()) => field()}, + optional(:typ) => String.t() } + # r7 section 4: ForeignRef = {0: Digest, 1: length, 2: media type, ? 3: locator}. + @foreign_ref_fields %{ + 0 => {:digest, :digest, :required}, + 1 => {:length, :uint, :required}, + 2 => {:media_type, {:tstr, 1, 255, :media_type}, :required}, + 3 => {:locator, {:tstr, 1, 2048, :printable_ascii}, :optional} + } + @doc """ Decodes bytes as a body of `kind` under one of `schemas`: the strict decode of - `Trinity.N6.CBOR`, then `check/4`. `opts`: `test_mode: true` admits the shared test block. + `Trinity.N6.CBOR` (every section 2 code first), then `check/4`. `opts` as `check/4` takes them. """ - @spec decode(binary(), String.t(), [schema()], keyword()) :: + @spec decode(binary(), String.t() | :any, [schema()], keyword()) :: {:ok, map(), schema()} | {:error, Profile.code(), non_neg_integer() | nil} def decode(bytes, kind, schemas, opts \\ []) when is_binary(bytes) do with {:ok, value} <- CBOR.decode(bytes), @@ -80,7 +115,7 @@ defmodule Trinity.N6.Body do Encodes a body after checking it, so a body this tree builds is one it would accept. The decode limits apply too: a body that could not be read back is not built. """ - @spec encode(map(), String.t(), [schema()], keyword()) :: + @spec encode(map(), String.t() | :any, [schema()], keyword()) :: {:ok, binary()} | {:error, Profile.code()} def encode(value, kind, schemas, opts \\ []) do with {:ok, _schema} <- check(value, kind, schemas, opts), @@ -94,10 +129,12 @@ defmodule Trinity.N6.Body do end @doc """ - Checks a decoded value against the body rules and the schema its key 0 names. `opts`: - `test_mode: true` admits the shared test block (0x80), which every other verifier refuses. + Checks a decoded value against the body rules and the schema its key 0 names. `kind` narrows + the schemas to one statement kind, or is `:any`. `opts`: `test_mode: true` admits the shared test + block (0x80), which every other verifier refuses; `typ:` is the `typ` the body is presented + under, which runs B7. """ - @spec check(CBOR.value(), String.t(), [schema()], keyword()) :: + @spec check(CBOR.value(), String.t() | :any, [schema()], keyword()) :: {:ok, schema()} | {:error, Profile.code()} def check(value, kind, schemas, opts \\ []) @@ -107,18 +144,18 @@ defmodule Trinity.N6.Body do with {:ok, version} <- version(value), :ok <- test_mode(version, Keyword.get(opts, :test_mode, false)), {:ok, schema} <- schema(kind, version, schemas), - :ok <- fields(Map.delete(value, 0), schema.fields) do + :ok <- typ(schema, Keyword.get(opts, :typ)), + :ok <- scan(Map.delete(value, 0), schema.fields, :top) do {:ok, schema} end end - # Key 0 is the schema version at the top level only; a nested map has no version key, so every - # key it holds is checked against its fields. - defp fields(map, fields) do - with :ok <- known_keys(map, fields), - :ok <- required(map, fields), - do: types(map, fields) - end + @doc """ + Checks one decoded value against one field type, by the B8 rules (`:foreign_ref` included): + what a ForeignRef or a field is checked by when it is judged on its own. + """ + @spec check_value(CBOR.value(), type()) :: :ok | {:error, Profile.code()} + def check_value(v, type), do: value(v, type) defp version(%{0 => v}) when is_integer(v) and v in @reserved, do: {:error, :schema_reserved} defp version(%{0 => v}) when is_integer(v) and v >= 0, do: {:ok, v} @@ -132,76 +169,163 @@ defmodule Trinity.N6.Body do defp test_mode(_version, true), do: :ok defp schema(kind, version, schemas) do - case Enum.find(schemas, &(&1.kind == kind and &1.version == version)) do + case Enum.find(schemas, &(&1.version == version and (kind == :any or &1.kind == kind))) do nil -> {:error, :unknown_schema} schema -> {:ok, schema} end end - defp known_keys(value, fields) do - case value |> Map.keys() |> Enum.sort() |> Enum.find(&(not is_map_key(fields, &1))) do - nil -> :ok - _key -> {:error, :unknown_key} - end - end + defp typ(_schema, nil), do: :ok + defp typ(%{typ: typ}, typ), do: :ok + defp typ(_schema, _typ), do: {:error, :typ_schema_mismatch} - defp required(value, fields) do - missing = for {key, {_name, _type, :required}} <- fields, not is_map_key(value, key), do: key - if missing == [], do: :ok, else: {:error, :required_key_missing} - end + # B8: one ascending scan over the union of the keys present and the keys required. + defp scan(map, fields, level) do + required = for {key, {_name, _type, :required}} <- fields, do: key - defp types(value, fields) do - value + (Map.keys(map) ++ required) + |> Enum.uniq() |> Enum.sort() - |> Enum.find_value(:ok, fn {key, v} -> - {_name, type, _presence} = Map.fetch!(fields, key) - - case of_type(v, type) do + |> Enum.find_value(:ok, fn key -> + case at_key(map, fields, key, level) do :ok -> nil error -> error end end) end - defp of_type(v, :uint) when is_integer(v) and v >= 0, do: :ok - defp of_type(v, :int) when is_integer(v), do: :ok - defp of_type({:text, _}, :tstr), do: :ok - defp of_type({:bytes, _}, :bstr), do: :ok - defp of_type(v, :bool) when is_boolean(v), do: :ok - defp of_type(_v, :any), do: :ok + defp at_key(map, fields, key, level) do + case {Map.fetch(map, key), Map.fetch(fields, key)} do + {:error, _required} when key == 1 and level == :top -> {:error, :issuer_missing} + {:error, _required} -> {:error, :field_missing} + {{:ok, _v}, :error} -> {:error, :unknown_key} + {{:ok, v}, {:ok, {_name, type, _presence}}} -> value(v, type) + end + end + + # One value, in the order r7 section 3.6 gives: type, then range, then grammar, then a set's + # order, then what a nested structure holds. + defp value(nil, {:nullable, _type}), do: :ok + defp value(v, {:nullable, type}), do: value(v, type) - defp of_type(v, :digest) do - case Digest.check(v) do - :ok -> :ok - {:error, code} -> {:error, code} + defp value(v, {:one_of, types}) do + case Enum.find(types, &type_matches?(v, &1)) do + nil -> {:error, :field_type_mismatch} + type -> value(v, type) end end - defp of_type(items, {:array, type}) when is_list(items), do: all_of(items, type) + defp value(v, :digest), do: Digest.check(v) + + defp value(v, :foreign_ref) when is_map(v), do: scan(v, @foreign_ref_fields, :nested) + + defp value(items, {:array, type}) when is_list(items), do: each(items, type) - defp of_type(items, {:set, type}) when is_list(items) do - with :ok <- all_of(items, type), do: canonical_set(items) + defp value(items, {:set, type}) when is_list(items), do: set(items, type, nil) + + defp value(items, {:tuple, types}) when is_list(items) and length(items) == length(types), + do: items |> Enum.zip(types) |> first_error(fn {v, t} -> value(v, t) end) + + defp value(_v, {:tuple, _types}), do: {:error, :field_type_mismatch} + + defp value(map, {:map, nested}) when is_map(map), do: scan(map, nested, :nested) + + defp value(v, type) do + cond do + not type_matches?(v, type) -> {:error, :field_type_mismatch} + not in_range?(v, type) -> {:error, :value_out_of_range} + not grammar?(v, type) -> {:error, :text_grammar_mismatch} + true -> :ok + end end - defp of_type(map, {:map, nested}) when is_map(map), do: fields(map, nested) + defp each(items, type), do: first_error(items, &value(&1, type)) + + # Each element is checked as the scan reaches it, then compared with the previous one, so + # `[5, 3, 2^32]` is `set_not_canonical` at 3 and `[2^32, 3]` is out of range at the first. + defp set([], _type, _prev), do: :ok - defp of_type(_v, _type), do: {:error, :wrong_type} + defp set([item | rest], type, prev) do + with :ok <- value(item, type) do + bytes = CBOR.encode!(item) + + if prev == nil or bytes > prev, + do: set(rest, type, bytes), + else: {:error, :set_not_canonical} + end + end - defp all_of(items, type) do + defp first_error(items, fun) do Enum.find_value(items, :ok, fn item -> - case of_type(item, type) do + case fun.(item) do :ok -> nil error -> error end end) end - defp canonical_set(items) do - encoded = Enum.map(items, &CBOR.encode!/1) - - strictly_ascending? = - encoded |> Enum.chunk_every(2, 1, :discard) |> Enum.all?(fn [a, b] -> a < b end) + defp type_matches?(nil, {:nullable, _}), do: true + defp type_matches?(v, {:nullable, type}), do: type_matches?(v, type) + defp type_matches?(v, {:one_of, types}), do: Enum.any?(types, &type_matches?(v, &1)) + defp type_matches?(v, type), do: cbor_type(v) == base(type) or base(type) == :any + + defp cbor_type(v) when is_integer(v), do: :int + defp cbor_type(v) when is_boolean(v), do: :bool + defp cbor_type({:text, _}), do: :text + defp cbor_type({:bytes, _}), do: :bytes + defp cbor_type(v) when is_list(v), do: :array + defp cbor_type(v) when is_map(v), do: :map + defp cbor_type(nil), do: :null + + defp base(type) when type in [:uint, :int], do: :int + defp base({kind, _, _}) when kind in [:int, :enum], do: :int + defp base(:tstr), do: :text + defp base({:tstr, _, _, _}), do: :text + defp base(:bstr), do: :bytes + defp base({:bstr, _, _}), do: :bytes + defp base(:bool), do: :bool + defp base(:any), do: :any + defp base(:digest), do: :array + defp base({kind, _}) when kind in [:array, :set, :tuple], do: :array + defp base(:foreign_ref), do: :map + defp base({:map, _}), do: :map + + defp in_range?(v, :uint), do: v >= 0 and v <= @uint64_max + defp in_range?(v, :int), do: v >= @int64_min and v <= @uint64_max + defp in_range?(v, {:int, min, max}), do: v >= min and v <= max + + defp in_range?(v, {:enum, length, reserved_zero?}), + do: v >= 0 and v < length and not (reserved_zero? and v == 0) + + defp in_range?({_, s}, {kind, min, max, _grammar}) when kind == :tstr, + do: byte_size(s) in min..max//1 + + defp in_range?({_, s}, {:bstr, min, max}), do: byte_size(s) in min..max//1 + defp in_range?(_v, _type), do: true + + defp grammar?({:text, s}, {:tstr, _min, _max, :printable_ascii}), do: printable_ascii?(s) + defp grammar?({:text, s}, {:tstr, _min, _max, :media_type}), do: media_type?(s) + defp grammar?(_v, _type), do: true + + defp printable_ascii?(s), do: s |> :binary.bin_to_list() |> Enum.all?(&(&1 in 0x21..0x7E)) - if strictly_ascending?, do: :ok, else: {:error, :set_not_canonical} + @doc """ + Whether text is a media type under r7 section 4's grammar: `type "/" subtype`, lowercase, no + parameters, each part `[a-z0-9][a-z0-9!#$&^_.+-]{0,126}`. + """ + @spec media_type?(binary()) :: boolean() + def media_type?(s) do + case :binary.split(s, "/", [:global]) do + [type, subtype] -> media_part?(type) and media_part?(subtype) + _ -> false + end end + + defp media_part?(<>) when byte_size(rest) <= 126, + do: media_first?(first) and rest |> :binary.bin_to_list() |> Enum.all?(&media_rest?/1) + + defp media_part?(_), do: false + + defp media_first?(c), do: c in ?a..?z or c in ?0..?9 + defp media_rest?(c), do: media_first?(c) or c in ~c"!#$&^_.+-" end diff --git a/lib/trinity/n6/cbor.ex b/lib/trinity/n6/cbor.ex index 71c65fd..5616add 100644 --- a/lib/trinity/n6/cbor.ex +++ b/lib/trinity/n6/cbor.ex @@ -10,9 +10,10 @@ defmodule Trinity.N6.CBOR do * integers in [-2^63, 2^64-1]; * `{:bytes, binary}` for a byte string and `{:text, binary}` for a text string, which must be - valid UTF-8 in Unicode NFC. A bare binary is refused: in signed data, bytes and text differ in - meaning, and an untagged binary that happens to be valid UTF-8 would otherwise change type; - * lists (arrays); + valid UTF-8 and pass the text rule (`Trinity.N6.Unicode15`). A bare binary is refused: in + signed data, bytes and text differ in meaning, and an untagged binary that happens to be valid + UTF-8 would otherwise change type; + * lists (arrays); * maps whose keys are integers in 0..2^32-1; * `true`, `false` and `nil`. @@ -23,33 +24,39 @@ defmodule Trinity.N6.CBOR do ## Decoding `decode/1` reads the input once, left to right and depth first, and stops at the first defect, - answering its code and byte offset. Nothing from an input that fails is returned. At each head - the checks run in this order, which is this tree's provisional answer to the profile's open - question of precedence (its VB1): - - 1. the head byte exists (`truncated`); - 2. additional information 28 to 30, or 31 where no indefinite form exists, or a lone break - (`not_well_formed`); 31 on a byte string, text string, array or map (`indefinite_length`); - 3. a tag (`tag_forbidden`); a float (`float_forbidden`); a simple value other than false, true - and null (`simple_forbidden`, or `not_well_formed` for `0xf8` below 32); - 4. the argument's bytes exist (`truncated`) and it is in its shortest form (`noncanonical`); - 5. the item count (`too_many_items`); - 6. a negative integer below -2^63 (`int_out_of_range`); a string's bytes exist (`truncated`); - a text string's length, validity and form (`text_too_long`, `invalid_utf8`, `not_nfc`); a - container's depth (`too_deep`); - 7. inside a map, for each key: its major type (`key_not_uint`), the key itself by the rules - above, its value at most 2^32-1 (`key_not_uint`), and its encoding against the previous - key's: equal is `duplicate_key`, lower is `noncanonical` (the profile's W2). - - The whole input is checked against the size limit first (`too_large`), and bytes after the - top-level item are `trailing_bytes`. Last, the value is re-encoded and compared byte for byte - with the input (E11). With the checks above complete, that comparison cannot fail; if it ever - does, it is answered as `noncanonical`, and the suite treats it as a rule this pass missed. + answering its code and byte offset. Nothing from an input that fails is returned. The checks are + the profile's steps D0 to D18 (N6 draft v0.1-r7 section 2.2), which fix both what is checked and + in which order. D0 runs once; at every head, in order: + + * D1: a byte is left for the head (`truncated`); + * D2: additional information 28 to 30, or 31 on major type 0, 1, 6 or 7 (`not_well_formed`); + * D3: 31 on a byte string, text string, array or map (`indefinite_length`); + * D4: the argument's bytes are all present (`truncated`), floats and simple values included; + * D5: `0xf8` with an argument below 32 (`not_well_formed`); + * D6: in key position, a major type other than 0 (`key_not_uint`), before any D7 check; + * D7: a tag (`tag_forbidden`), a float (`float_forbidden`), a simple value other than false, + true and null (`simple_forbidden`); + * D8: the argument in its shortest form (`noncanonical`); + * D9 and D10: a key above 2^32-1 (`key_not_uint`), then the key's encoding against the previous + key's: equal is `duplicate_key`, lower is `noncanonical`; + * D11: a negative integer below -2^63 (`int_out_of_range`); + * D12: an array or map: its depth (`too_deep`), then the fit check, its count against the bytes + from after the head to the end of the input, one per element or two per pair (`truncated`), + then the item count raised by its declared contents (`too_many_items`); + * D13 to D16: a string: the fit check (`truncated`), a text string's length (`text_too_long`), + UTF-8 over the whole string (`invalid_utf8`), then the text rule at its first failing code + point (`text_unassigned`, `text_format_control`, `not_nfc`, `nfc_maybe`). + + Bytes after the top-level item are `trailing_bytes` (D17). Last, the value is re-encoded and + compared byte for byte with the input (E11, D18). With the checks above complete, that comparison + cannot fail; if it ever does, it is answered as `noncanonical`, and the suite treats it as a rule + this pass missed. """ import Bitwise alias Trinity.N6.Profile + alias Trinity.N6.Unicode15 @typedoc "A value in the N6 subset." @type value :: @@ -135,15 +142,31 @@ defmodule Trinity.N6.CBOR do defp head(major, n) when n <= 0xFFFF_FFFF, do: <> defp head(major, n), do: <> - # Erlang's UTF-8 matching refuses overlong forms and surrogates, which is what E8 asks. - defp text_defect(s) do + # D15 over the whole string, then D16. Erlang's UTF-8 matching refuses overlong forms and + # surrogates, which is what E8 asks. + defp text_defect(s), do: if(String.valid?(s), do: text_rule(s, 0), else: :invalid_utf8) + + # The text rule (r7 section 2.4): left to right, `last` the previous code point's combining + # class; the first code point that fails gives the code. No normaliser is called. + defp text_rule(<<>>, _last), do: nil + # ASCII is assigned, not Cf, class 0 and NFC_QC Yes, so it only resets `last`. + defp text_rule(<>, _last) when cp < 0x80, do: text_rule(rest, 0) + + defp text_rule(<>, last) do + ccc = Unicode15.ccc(cp) + cond do - not String.valid?(s) -> :invalid_utf8 - :unicode.characters_to_nfc_binary(s) != s -> :not_nfc - true -> nil + Unicode15.unassigned?(cp) -> :text_unassigned + Unicode15.format_control?(cp) -> :text_format_control + ccc != 0 and ccc < last -> :not_nfc + true -> quick_check(Unicode15.nfc_qc(cp), rest, ccc) end end + defp quick_check(:yes, rest, ccc), do: text_rule(rest, ccc) + defp quick_check(:no, _rest, _ccc), do: :not_nfc + defp quick_check(:maybe, _rest, _ccc), do: :nfc_maybe + ## Decoding @doc """ @@ -169,7 +192,8 @@ defmodule Trinity.N6.CBOR do total = byte_size(bin) try do - case item(bin, 0, 0) do + # The top-level item counts itself; every container adds its declared contents (D12). + case item(bin, 0, 1, :value) do {value, <<>>, _count} -> {:ok, value} {_value, rest, _count} -> {:error, :trailing_bytes, total - byte_size(rest)} end @@ -178,60 +202,73 @@ defmodule Trinity.N6.CBOR do end end - # `depth` is the number of containers around this item; `count` the items read so far. - defp item(<<>> = here, _depth, _count), do: fail(:truncated, here) + # `depth` is the number of containers around this item, `count` the items declared so far, and + # `position` whether the item is a map key. Each clause below is one step, in the profile's order. + defp item(<<>> = here, _depth, _count, _position), do: fail(:truncated, here) - defp item(<> = here, depth, count) do + defp item(<> = here, depth, count, position) do major = ib >>> 5 ai = ib &&& 0x1F cond do ai in 28..30 -> fail(:not_well_formed, here) - ai == 31 and major in 2..5 -> fail(:indefinite_length, here) - ai == 31 -> fail(:not_well_formed, here) - major == 6 -> fail(:tag_forbidden, here) - major == 7 -> simple(ai, tail, here, count) - true -> argument_item(major, ai, tail, here, depth, count) + ai == 31 and major in [0, 1, 6, 7] -> fail(:not_well_formed, here) + ai == 31 -> fail(:indefinite_length, here) + true -> head_item(major, ai, argument(ai, tail, here), here, depth, count, position) end end - defp simple(20, tail, here, count), do: {false, tail, counted(count, here)} - defp simple(21, tail, here, count), do: {true, tail, counted(count, here)} - defp simple(22, tail, here, count), do: {nil, tail, counted(count, here)} - defp simple(ai, _tail, here, _count) when ai in 25..27, do: fail(:float_forbidden, here) - defp simple(24, <<>>, here, _count), do: fail(:truncated, here) - defp simple(24, <>, here, _count) when v < 32, do: fail(:not_well_formed, here) - defp simple(_ai, _tail, here, _count), do: fail(:simple_forbidden, here) + defp head_item(7, 24, {v, _rest}, here, _depth, _count, _position) when v < 32, + do: fail(:not_well_formed, here) + + defp head_item(major, _ai, _arg, here, _depth, _count, :key) when major != 0, + do: fail(:key_not_uint, here) - defp argument_item(major, ai, tail, here, depth, count) do - {arg, rest} = argument(ai, tail, here) - count = counted(count, here) + defp head_item(6, _ai, _arg, here, _depth, _count, _position), do: fail(:tag_forbidden, here) + defp head_item(7, ai, arg, here, _depth, count, _position), do: simple(ai, arg, here, count) + defp head_item(major, ai, {arg, rest}, here, depth, count, position) do + if not shortest?(ai, arg), do: fail(:noncanonical, here) + if position == :key and arg > @key_max, do: fail(:key_not_uint, here) + value_item(major, arg, rest, here, depth, count) + end + + # D11 to D16, by major type. + defp value_item(major, arg, rest, here, depth, count) do case major do 0 -> {arg, rest, count} 1 when arg > @nint_arg_max -> fail(:int_out_of_range, here) 1 -> {-1 - arg, rest, count} 2 -> string(:bytes, arg, rest, here, count) 3 -> string(:text, arg, rest, here, count) - 4 -> array(arg, rest, here, depth + 1, count) - 5 -> map(arg, rest, here, depth + 1, count) + 4 -> container(:array, arg, rest, here, depth + 1, count) + 5 -> container(:map, arg, rest, here, depth + 1, count) end end - defp counted(count, here) when count + 1 > @max_items, do: fail(:too_many_items, here) - defp counted(count, _here), do: count + 1 + defp simple(20, {_, rest}, _here, count), do: {false, rest, count} + defp simple(21, {_, rest}, _here, count), do: {true, rest, count} + defp simple(22, {_, rest}, _here, count), do: {nil, rest, count} + defp simple(ai, _arg, here, _count) when ai in 25..27, do: fail(:float_forbidden, here) + defp simple(_ai, _arg, here, _count), do: fail(:simple_forbidden, here) + # D4: the argument's bytes. A float's bytes are its argument here, so a truncated float is + # `truncated` before it is `float_forbidden`. defp argument(ai, tail, _here) when ai < 24, do: {ai, tail} - defp argument(24, <>, here), do: shortest(v, 24, rest, here) - defp argument(25, <>, here), do: shortest(v, 0x100, rest, here) - defp argument(26, <>, here), do: shortest(v, 0x1_0000, rest, here) - defp argument(27, <>, here), do: shortest(v, 0x1_0000_0000, rest, here) + defp argument(24, <>, _here), do: {v, rest} + defp argument(25, <>, _here), do: {v, rest} + defp argument(26, <>, _here), do: {v, rest} + defp argument(27, <>, _here), do: {v, rest} defp argument(_ai, _tail, here), do: fail(:truncated, here) - defp shortest(v, min, _rest, here) when v < min, do: fail(:noncanonical, here) - defp shortest(v, _min, rest, _here), do: {v, rest} + defp shortest?(ai, _v) when ai < 24, do: true + defp shortest?(24, v), do: v >= 24 + defp shortest?(25, v), do: v >= 0x100 + defp shortest?(26, v), do: v >= 0x1_0000 + defp shortest?(27, v), do: v >= 0x1_0000_0000 - defp string(_kind, len, rest, here, _count) when byte_size(rest) < len, + # D13 compares the declared length with the bytes left (L > R), so it cannot overflow. + defp string(_kind, len, rest, here, _count) when len > byte_size(rest), do: fail(:truncated, here) defp string(:bytes, len, rest, _here, count) do @@ -251,33 +288,39 @@ defmodule Trinity.N6.CBOR do end end - # Every item takes at least one byte, so a count beyond what is left is refused before - # anything is read: a forged count cannot make the decoder loop. - defp array(_n, _rest, here, depth, _count) when depth > @max_depth, do: fail(:too_deep, here) - defp array(n, rest, here, _depth, _count) when n > byte_size(rest), do: fail(:truncated, here) - defp array(n, rest, _here, depth, count), do: elements(n, rest, depth, count, []) + # D12: depth, then the fit check (the count against the bytes left divided by the smallest + # element, never the count multiplied), then the item count. Nothing is read or reserved for a + # declared count before all three pass. + defp container(_kind, _n, _rest, here, depth, _count) when depth > @max_depth, + do: fail(:too_deep, here) + + defp container(:array, n, rest, here, _depth, _count) when n > byte_size(rest), + do: fail(:truncated, here) + + defp container(:map, n, rest, here, _depth, _count) when n > div(byte_size(rest), 2), + do: fail(:truncated, here) + + defp container(kind, n, rest, here, depth, count) do + count = count + if(kind == :map, do: 2 * n, else: n) + if count > @max_items, do: fail(:too_many_items, here) + + case kind do + :array -> elements(n, rest, depth, count, []) + :map -> pairs(n, rest, depth, count, nil, []) + end + end defp elements(0, rest, _depth, count, acc), do: {Enum.reverse(acc), rest, count} defp elements(n, rest, depth, count, acc) do - {value, rest, count} = item(rest, depth, count) + {value, rest, count} = item(rest, depth, count, :value) elements(n - 1, rest, depth, count, [value | acc]) end - defp map(_n, _rest, here, depth, _count) when depth > @max_depth, do: fail(:too_deep, here) - defp map(n, rest, here, _depth, _count) when 2 * n > byte_size(rest), do: fail(:truncated, here) - defp map(n, rest, _here, depth, count), do: pairs(n, rest, depth, count, nil, []) - defp pairs(0, rest, _depth, count, _prev, acc), do: {Map.new(acc), rest, count} defp pairs(n, rest, depth, count, prev, acc) do - case rest do - <> when kb >>> 5 != 0 -> fail(:key_not_uint, rest) - _ -> :ok - end - - {key, after_key, count} = item(rest, depth, count) - if key > @key_max, do: fail(:key_not_uint, rest) + {key, after_key, count} = item(rest, depth, count, :key) key_bytes = binary_part(rest, 0, byte_size(rest) - byte_size(after_key)) cond do @@ -286,7 +329,7 @@ defmodule Trinity.N6.CBOR do true -> fail(:noncanonical, rest) end - {value, after_value, count} = item(after_key, depth, count) + {value, after_value, count} = item(after_key, depth, count, :value) pairs(n - 1, after_value, depth, count, key_bytes, [{key, value} | acc]) end diff --git a/lib/trinity/n6/cose.ex b/lib/trinity/n6/cose.ex index d43dc63..34f88a1 100644 --- a/lib/trinity/n6/cose.ex +++ b/lib/trinity/n6/cose.ex @@ -40,13 +40,31 @@ defmodule Trinity.N6.COSE do ## Entry points The caller says which entry point it is (`entry:`, `:nss` when it says nothing). At `:nss` only - -50 is permitted; at `:commercial`, -50, -19 and -51. Both refusals are `alg_not_permitted`. - - The protected label set, for both algorithms, is closed to {1, 4, 15, 16}: content type (3) is refused with its own - code, and every other label, `crit` (2) and the hash-envelope labels (258 to 260) among them, - with `header_label_forbidden`. The header is strictly decoded (`Trinity.N6.CBOR`); a key that is - not an unsigned integer is `header_label_forbidden` and every other encoding defect - `header_noncanonical` (this tree's reading of the profile's W7). + -50 is permitted; at `:commercial`, -50, -19 and -51, and every `typ` of the table. An entry point + may also be given as a map, `%{algs: [alg], typs: [typ], single: true}`, as draft v0.1-r7's + entry points are (one surface, one algorithm, its own `typ` set); a single-signer one refuses a + pair `typ` (`wrong_carrier_for_typ`). + + ## Decoding a protected header + + Draft v0.1-r7 section 5.6, steps H1 to H4, each answering the first failure: + + * H1: the bytes decoded by `Trinity.N6.CBOR` (a zero-length string is the empty map); a label + that is not an unsigned integer below 2^32, at any depth, is `header_label_forbidden`, and + every other encoding defect `header_noncanonical`; + * H2: not a map, `header_noncanonical`; + * H3: `typ` first, because it selects the label set: absent `header_missing`, not text + `typ_unexpected`, a pair `typ` at a single-signer entry point `wrong_carrier_for_typ`, not in + the entry point's set `typ_unexpected`; + * H4: one scan over the labels present and the labels the `typ` requires, ascending, 16 + skipped: required and absent `header_missing`; label 3 `content_type_forbidden`; outside the + set `header_label_forbidden`; a value of the wrong shape `header_value_invalid`; an `alg` + that is not the entry point's `alg_not_permitted`; a hash algorithm (258) other than -43 or + -44 `digest_alg_not_permitted`. + + A full-payload `typ`'s set is {1, 4, 16}, with 15 (CWT claims, `{1: iss, 2: sub}`) required + exactly when it is SCITT-renderable. A `form: :hash` entry (RFC 9995) requires {1, 4, 16, 258, + 259} and admits 2 (only as `[258]`) and 260. ## What is signed @@ -86,7 +104,7 @@ defmodule Trinity.N6.COSE do ESP384 private key is its 48-byte scalar, and its public key the 97-byte uncompressed point. """ - alias Trinity.N6.{CBOR, EC2, Profile} + alias Trinity.N6.{Body, CBOR, EC2, Ed25519, Profile} @mldsa87 -50 @ed25519 -19 @@ -99,7 +117,6 @@ defmodule Trinity.N6.COSE do @permitted %{nss: [@mldsa87], commercial: [@mldsa87, @ed25519, @esp384]} @akp_prefix <<0xA3, 0x01, 0x07, 0x03, 0x38, 0x31, 0x20, 0x59, 0x0A, 0x20>> @okp_prefix <<0xA3, 0x01, 0x01, 0x20, 0x06, 0x21, 0x58, 0x20>> - @labels [1, 4, 15, 16] @required [1, 4, 16] @countersignature [7, 9, 11, 12] @tag18 0xD2 @@ -166,26 +183,39 @@ defmodule Trinity.N6.COSE do end @doc """ - Strictly decodes protected header bytes under the header rules, a `typ` table and an entry point - (`:nss` when none is given). The result carries the `typ`'s table entry, for the carrier rules. + Strictly decodes protected header bytes under the header rules (H1 to H4), a `typ` table and an + entry point (`:nss` when none is given). The result carries the `typ`'s table entry, for the + carrier rules. """ - @spec decode_protected(binary(), typ_table(), entry_point()) :: + @spec decode_protected(binary(), typ_table(), entry_point() | map()) :: {:ok, %{alg: alg(), kid: binary(), typ: String.t(), cwt: map() | nil, entry: map()}} | {:error, Profile.code()} def decode_protected(bytes, table, entry_point \\ :nss) - when is_binary(bytes) and is_map(table) and is_map_key(@permitted, entry_point) do + when is_binary(bytes) and is_map(table) do + point = entry(entry_point, table) + with {:ok, header} <- decode_header_map(bytes), - :ok <- labels(header), - :ok <- present(header), - {:ok, alg} <- alg(header), - :ok <- permitted(alg, entry_point), - {:ok, kid} <- kid(header), - {:ok, typ, entry} <- typ(header, table), - {:ok, cwt} <- cwt(header, entry) do - {:ok, %{alg: alg, kid: kid, typ: typ, cwt: cwt, entry: entry}} + {:ok, typ, entry} <- typ(header, table, point), + :ok <- label_scan(header, entry, point) do + {:ok, + %{ + alg: header[1], + kid: elem(header[4], 1), + typ: typ, + cwt: cwt(header[15]), + entry: entry + }} end end + defp entry(name, table) when is_map_key(@permitted, name), + do: %{algs: Map.fetch!(@permitted, name), typs: Map.keys(table), single: false} + + defp entry(%{algs: _, typs: _} = point, _table), do: Map.put_new(point, :single, true) + + # H1 and H2. + defp decode_header_map(<<>>), do: {:ok, %{}} + defp decode_header_map(bytes) do case CBOR.decode(bytes) do {:ok, header} when is_map(header) -> {:ok, header} @@ -195,54 +225,114 @@ defmodule Trinity.N6.COSE do end end - defp labels(header) do - case header |> Map.keys() |> Enum.sort() |> Enum.find(&(&1 not in @labels)) do - nil -> :ok - 3 -> {:error, :content_type_forbidden} - _label -> {:error, :header_label_forbidden} + # H3. + defp typ(header, table, point) do + case Map.fetch(header, 16) do + :error -> + {:error, :header_missing} + + {:ok, {:text, typ}} -> + entry = Map.get(table, typ) + + cond do + point.single and match?(%{signers: :pair}, entry) -> {:error, :wrong_carrier_for_typ} + entry == nil or typ not in point.typs -> {:error, :typ_unexpected} + true -> {:ok, typ, entry} + end + + {:ok, _other} -> + {:error, :typ_unexpected} end end - defp present(header) do - if Enum.all?(@required, &is_map_key(header, &1)), - do: :ok, - else: {:error, :header_not_protected} + # H4. + defp label_scan(header, entry, point) do + {required, allowed} = label_set(entry) + + (Map.keys(header) ++ required) + |> Enum.uniq() + |> Enum.sort() + |> Enum.reject(&(&1 == 16)) + |> Enum.find_value(:ok, fn label -> + case at_label(header, label, required, allowed, point) do + :ok -> nil + error -> error + end + end) end - defp alg(%{1 => alg}) when is_map_key(@sizes, alg), do: {:ok, alg} - defp alg(_header), do: {:error, :alg_not_permitted} + defp label_set(entry) do + scitt = if Map.get(entry, :scitt, false), do: [15], else: [] - defp permitted(alg, entry_point) do - if alg in Map.fetch!(@permitted, entry_point), do: :ok, else: {:error, :alg_not_permitted} + case Map.get(entry, :form, :full) do + :full -> {[1, 4, 16] ++ scitt, [1, 4, 16] ++ scitt} + :hash -> {[1, 4, 16, 258, 259] ++ scitt, [1, 2, 4, 16, 258, 259, 260] ++ scitt} + end end - defp kid(%{4 => {:bytes, kid}}) when byte_size(kid) == 48, do: {:ok, kid} - defp kid(_header), do: {:error, :kid_not_thumbprint} + defp at_label(header, label, required, allowed, point) do + case Map.fetch(header, label) do + :error -> + if label in required, do: {:error, :header_missing}, else: :ok + + {:ok, _value} when label == 3 -> + {:error, :content_type_forbidden} - defp typ(%{16 => {:text, typ}}, table) do - case Map.fetch(table, typ) do - {:ok, entry} -> {:ok, typ, entry} - :error -> {:error, :typ_unexpected} + {:ok, value} -> + if label in allowed, + do: label_value(label, value, point), + else: {:error, :header_label_forbidden} end end - defp typ(_header, _table), do: {:error, :typ_unexpected} + defp label_value(1, alg, point) when is_integer(alg), + do: if(alg in point.algs, do: :ok, else: {:error, :alg_not_permitted}) - defp cwt(header, %{scitt: true}) do - case Map.fetch(header, 15) do - {:ok, %{1 => {:text, iss}, 2 => {:text, sub}} = claims} when map_size(claims) == 2 -> - {:ok, %{iss: iss, sub: sub}} + defp label_value(4, {:bytes, kid}, _point) when byte_size(kid) == 48, do: :ok + defp label_value(2, [258], _point), do: :ok - {:ok, _other} -> - {:error, :header_label_forbidden} + defp label_value(15, %{1 => {:text, iss}, 2 => {:text, sub}} = claims, _point) + when map_size(claims) == 2, + do: if(claim?(iss) and claim?(sub), do: :ok, else: {:error, :header_value_invalid}) - :error -> - {:error, :header_not_protected} - end - end + defp label_value(258, alg, _point) when is_integer(alg), + do: if(alg in [-43, -44], do: :ok, else: {:error, :digest_alg_not_permitted}) + + defp label_value(259, n, _point) when is_integer(n) and n in 0..65_535, do: :ok + defp label_value(259, {:text, t}, _point), do: valid_text(Body.media_type?(t)) + defp label_value(260, {:text, t}, _point), do: valid_text(locator?(t)) + defp label_value(_label, _value, _point), do: {:error, :header_value_invalid} + + defp valid_text(true), do: :ok + defp valid_text(false), do: {:error, :header_value_invalid} + + defp claim?(s), do: byte_size(s) in 1..255 and printable_ascii?(s) + defp locator?(s), do: byte_size(s) in 1..2048 and printable_ascii?(s) + defp printable_ascii?(s), do: s |> :binary.bin_to_list() |> Enum.all?(&(&1 in 0x21..0x7E)) + + defp cwt(%{1 => {:text, iss}, 2 => {:text, sub}}), do: %{iss: iss, sub: sub} + defp cwt(nil), do: nil + + @doc """ + The key checks of draft v0.1-r7 section 5.2, K1 and K2, for a key presented with its type and + algorithm (`kty` `:akp`, `:ec2` or `:okp`; `crv` the COSE curve, 2 for P-384 and 6 for Ed25519). + """ + @spec check_key(:akp | :ec2 | :okp, integer() | nil, integer(), binary()) :: + :ok | {:error, Profile.code()} + def check_key(kty, crv, alg, pub) do + cond do + {kty, alg} not in [{:akp, @mldsa87}, {:ec2, @esp384}, {:okp, @ed25519}] -> + {:error, :alg_not_permitted} + + kty == :ec2 and crv != 2 -> + {:error, :key_invalid} - defp cwt(header, _entry) do - if is_map_key(header, 15), do: {:error, :header_label_forbidden}, else: {:ok, nil} + kty == :okp and crv != 6 -> + {:error, :key_invalid} + + true -> + key_valid(alg, pub) + end end @doc "RFC 9052 section 4.4's ToBeSigned for a Sign1, external_aad empty." @@ -341,21 +431,22 @@ defmodule Trinity.N6.COSE do defp known_key(_unknown, _alg), do: {:error, :policy_stale_or_unknown_key} + # K2. defp key_valid(@esp384, pub), do: if(EC2.valid_key?(pub), do: :ok, else: {:error, :key_invalid}) - defp key_valid(_alg, _pub), do: :ok + defp key_valid(@ed25519, pub), + do: if(match?({:ok, _}, Ed25519.decode(pub)), do: :ok, else: {:error, :key_invalid}) + + defp key_valid(@mldsa87, pub), + do: if(byte_size(pub) == 2592, do: :ok, else: {:error, :key_invalid}) + + # K3. defp thumbprint_matches(alg, pub, kid) do - if byte_size(pub) == @sizes[alg].public and thumbprint(alg, pub) == kid, - do: :ok, - else: {:error, :kid_not_thumbprint} + if thumbprint(alg, pub) == kid, do: :ok, else: {:error, :kid_not_thumbprint} end defp signature_size(alg, sig) do - cond do - byte_size(sig) == @sizes[alg].signature -> :ok - alg == @esp384 -> {:error, :signature_encoding} - true -> {:error, :carrier_malformed} - end + if byte_size(sig) == @sizes[alg].signature, do: :ok, else: {:error, :signature_encoding} end defp signature_valid(alg, tbs, sig, pub) do diff --git a/lib/trinity/n6/digest.ex b/lib/trinity/n6/digest.ex index 542a17c..db9b18b 100644 --- a/lib/trinity/n6/digest.ex +++ b/lib/trinity/n6/digest.ex @@ -13,10 +13,10 @@ defmodule Trinity.N6.Digest do exact number of bytes received. SHA-384 is the only algorithm accepted. The profile leaves SHA-512 (-44) Open for v0; until it - is decided this tree refuses it with the rest (`digest_alg_not_permitted`). A value whose length - does not match its algorithm is `digest_length_mismatch`, and anything that is not a two-element - array of an integer and a byte string is `digest_malformed`; both are this tree's codes until - the profile names its own. + is decided this tree refuses it with the rest (`digest_alg_not_permitted`). The checks run in + draft v0.1-r7's order: anything that is not a two-element array of an integer and a byte string + is `field_type_mismatch`; then the algorithm (`digest_alg_not_permitted`); then the value's + length (`digest_length_mismatch`). """ alias Trinity.N6.{CBOR, Profile} @@ -50,7 +50,22 @@ defmodule Trinity.N6.Digest do def check([@sha384, {:bytes, value}]) when byte_size(value) == 48, do: :ok def check([@sha384, {:bytes, _value}]), do: {:error, :digest_length_mismatch} def check([alg, {:bytes, _value}]) when is_integer(alg), do: {:error, :digest_alg_not_permitted} - def check(_other), do: {:error, :digest_malformed} + def check(_other), do: {:error, :field_type_mismatch} + + @doc """ + Judges the bytes a well-formed ForeignRef names (r7 section 4.1): no bytes is + `foreign_bytes_missing`; bytes whose length or digest differ from the reference's is + `foreign_digest_mismatch`. + """ + @spec attachment(%{non_neg_integer() => CBOR.value()}, binary() | nil) :: + :ok | {:error, :foreign_bytes_missing | :foreign_digest_mismatch} + def attachment(_ref, nil), do: {:error, :foreign_bytes_missing} + + def attachment(%{0 => digest, 1 => length}, bytes) when is_binary(bytes) do + if byte_size(bytes) == length and matches?(digest, bytes), + do: :ok, + else: {:error, :foreign_digest_mismatch} + end @doc "Whether a Digest this profile accepts is the digest of these bytes." @spec matches?(CBOR.value(), binary()) :: boolean() diff --git a/lib/trinity/n6/ed25519.ex b/lib/trinity/n6/ed25519.ex new file mode 100644 index 0000000..9426213 --- /dev/null +++ b/lib/trinity/n6/ed25519.ex @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.Ed25519 do + @moduledoc """ + Ed25519 points as the N6 profile reads them (draft v0.1-r7 section 5.2, K2; slice 142): the + strict decoding of RFC 8032 section 5.1.3, which `:crypto` does not apply (measured on OpenSSL + 3.0.13 and 3.5.9: a key whose y is not below p is accepted). + + A 32-byte string decodes when y, its low 255 bits, is below p; x, recovered from y, exists; and x + is not 0 with the sign bit set. Only public values pass through here, so the arithmetic needs no + care for timing. Signing never uses this module. + """ + + import Bitwise + + @p (1 <<< 255) - 19 + # d = -121665 / 121666 mod p (RFC 8032 section 5.1). + @d 37_095_705_934_669_439_343_138_083_508_754_565_189_542_113_879_843_219_016_388_785_533_085_940_283_555 + # sqrt(-1) mod p, 2^((p - 1) / 4). + @sqrt_m1 19_681_161_376_707_505_956_807_079_304_988_542_015_446_066_515_923_890_162_744_021_073_123_829_784_752 + + @typedoc "A point in affine coordinates." + @type point :: {non_neg_integer(), non_neg_integer()} + + @doc "Strictly decodes a 32-byte encoding of a point (RFC 8032 section 5.1.3)." + @spec decode(binary()) :: {:ok, point()} | :error + def decode(<<_::binary-32>> = bytes) do + <> = bytes + sign = n >>> 255 + y = n &&& (1 <<< 255) - 1 + if y >= @p, do: :error, else: recover_x(y, sign) + end + + def decode(_bytes), do: :error + + defp recover_x(y, sign) do + u = mod(y * y - 1) + v = mod(@d * y * y + 1) + x = mod(u * pow(v, 3) * pow(mod(u * pow(v, 7)), div(@p - 5, 8))) + vx2 = mod(v * x * x) + + x = + cond do + vx2 == u -> x + vx2 == mod(-u) -> mod(x * @sqrt_m1) + true -> nil + end + + cond do + x == nil -> :error + x == 0 and sign == 1 -> :error + (x &&& 1) != sign -> {:ok, {@p - x, y}} + true -> {:ok, {x, y}} + end + end + + defp mod(n), do: Integer.mod(n, @p) + defp pow(b, e), do: :crypto.mod_pow(b, e, @p) |> :binary.decode_unsigned() +end diff --git a/lib/trinity/n6/profile.ex b/lib/trinity/n6/profile.ex index 338cdd0..4143e30 100644 --- a/lib/trinity/n6/profile.ex +++ b/lib/trinity/n6/profile.ex @@ -7,61 +7,54 @@ defmodule Trinity.N6.Profile do (slice 139). N6 is a profile of deterministic CBOR (RFC 8949 section 4.2.1) and COSE_Sign1 (RFC 9052) for - signed statements. The version implemented here is **v0, draft r3, provisional**: every rule may - change until a frozen v1 is published with its vectors. `pin/0` names the draft and the SHA-384 - of its text, so a later draft is a visible change of pin, not a silent one. - - Slice 139 adds what the profile publisher's answers of 2026-10-19 add to r3 for a single signer, - ahead of the draft that will carry them (v0.1): the single-signer carrier, and Ed25519 (-19) as the - one classical algorithm on the `commercial` profile. Slice 140 adds the publisher's Decision of - 2026-10-20 (17:24Z, with its 17:27Z correction): ESP384 (-51) for FIPS-mode nodes, the schema - numbers, `statement_id` as the link, one `typ` per kind, and ForeignRefs for text-keyed content. - `pin/0` names both. + signed statements. The version implemented here is **v0.1, draft v0.1-r7, provisional** (slice + 142; slices 138 to 140 implemented v0 draft r3 and the publisher's answers that followed it): + every rule may change until a frozen v1 is published with its vectors. `pin/0` names the draft + and the digest of its posted source, so a later draft is a visible change of pin, not a silent + one. ## Schema numbers - Key 0 of a carrier is a number in the reserved range 0xFFFF0000 to 0xFFFFFFFF. r3 assigns two, - still Open in it (its section 12.1 item 3); the publisher's Decision of 2026-10-20 assigns the - single-signer carrier's, 0xFFFF0003, the value slice 139 had used as a placeholder. + Key 0 of a carrier is a number in the reserved range 0xFFFF0000 to 0xFFFFFFFF: 0xFFFF0001 the + twin envelope, 0xFFFF0002 the `statement_id` preimage, 0xFFFF0003 the single-signer carrier (r7 + section 12.1 item 3, closed). A statement kind's key 0 is `(block << 24) | (kind << 8) | version` (`statement_schema/3`): a block of 0x80 is the shared test block, accepted only in a verifier's declared test mode; blocks - 0x81 to 0x8F are provisional, one per publishing board, Trinity's 0x83; numbers below 0x80000000 - are N21's, none issued yet. Everything provisional is re-issued under N21 at v1. + 0x81 to 0x8F are provisional, one per publishing party, Trinity's 0x83; numbers below 0x80000000 + are the registry's, none issued yet. Everything provisional is re-issued at v1. ## Codes - Every refusal in `Trinity.N6` is one of the atoms in `codes/0`. Each carries its exit class - (0 verified, 1 invalid, 2 usage, 5 no trust) and whether the draft defines it (`:draft`) or this - tree does, where the draft leaves the code Open (`:trinity`). A `:trinity` code is provisional - twice over: it changes when the draft settles that point. + Every refusal in `Trinity.N6` is one of the atoms in `codes/0`, which is r7's closed registry + (its section 9.4) restricted to what this tree can emit, plus the codes marked `:trinity`. Each + carries its exit class (0 verified, 1 invalid, 2 usage, 5 no trust) and the steps that emit it. ## Limits - The draft's numbers, Open in it (its section 12.1 item 2), counted as follows, which the draft - also leaves Open (its VB2) and so is this tree's provisional reading: + r7 section 2.1, each checked where its step says: - * **depth** is the number of arrays and maps around an item, the top-level container being 1: - 16 nested arrays are accepted, 17 are refused; - * **items** counts every data item, map keys and the top-level item included; + * **depth** is the number of arrays and maps on the path to an item, counting the item itself + if it is one; the top-level container is 1: 16 nested arrays are accepted, 17 are refused; + * **items** counts every data item: the top-level item, and each container's declared contents + at its head (n for an array, 2n for a map), before they are read; * **text** is the length of one text string in UTF-8 bytes; * **size** is the whole input in bytes. There is no separate limit on byte strings. - Each is accepted at its value and refused one past it. Limits apply to bytes being decoded, not - to bytes this tree constructs: a ToBeSigned may be larger than any body it carries. + Limits apply to bytes being decoded, not to bytes this tree constructs: a ToBeSigned may be + larger than any body it carries. """ @pin %{ - version: "n6/v0", - draft: "r3", + version: "n6/v0.1", + draft: "v0.1-r7", answers: [ - "the profile publisher's answers of 2026-10-19 (single-signer carrier, Ed25519 on commercial)", - "the profile publisher's Decision of 2026-10-20 17:24Z and its 17:27Z correction (ESP384, " <> - "schema numbers, statement_id, one typ per kind, ForeignRefs)" + "the profile publisher's Decisions of 2026-10-20 and 2026-10-21 that draft v0.1-r7 carries", + "the publisher's Ed25519 verification rule of 2026-10-21 09:12Z (r7 section 5.3, decided)" ], status: :provisional, - draft_sha384: - "54ab5e549259b87b18c51b4d1a3dbbbbb230b74ae562d5c2ef8ec43288c6c2dafc6376bff51ae205da6043bb9da7b7f2" + # The SHA-256 the publisher posted with the draft's source file. + draft_sha256: "0b0d41382f5221fee48ccb16bce3f33e47f4cd59dba4f7b0bf8d9f5c1fcd1a25" } @limits %{depth: 16, items: 65_536, text: 65_536, size: 1_048_576} @@ -70,90 +63,88 @@ defmodule Trinity.N6.Profile do @schemas %{ envelope: {0xFFFF0001, :draft}, statement_id: {0xFFFF0002, :draft}, - single: {0xFFFF0003, :publisher} + single: {0xFFFF0003, :draft} } @test_block 0x80 @trinity_block 0x83 - # code => {exit class, who defines it} + # code => {exit class, who defines it, where it is emitted}. The steps are draft v0.1-r7's: D0 to + # D18 (section 2.2, the decoder), B1 to B8 (section 3.6, the body), H1 to H4 (section 5.6, the + # protected header), K1 to K3 (section 5.2, a key), and the verifier steps section 9.4 names. @codes %{ # Section 2, the encoding rules. - noncanonical: {1, :draft}, - indefinite_length: {1, :draft}, - key_not_uint: {1, :draft}, - duplicate_key: {1, :draft}, - int_out_of_range: {1, :draft}, - float_forbidden: {1, :draft}, - tag_forbidden: {1, :draft}, - simple_forbidden: {1, :draft}, - invalid_utf8: {1, :draft}, - not_nfc: {1, :draft}, - too_deep: {1, :draft}, - too_large: {1, :draft}, - too_many_items: {1, :draft}, - text_too_long: {1, :draft}, - trailing_bytes: {1, :draft}, - truncated: {1, :draft}, - # A head the draft's code table does not cover yet (its W6): additional information 28 to 30, - # 31 on a major type that has no indefinite form, a lone break, 0xf8 below 32. - not_well_formed: {1, :trinity}, - # A term the encoder cannot express in the subset (an atom, a bare binary, a tuple). - value_not_n6: {1, :trinity}, + too_large: {1, :draft, [:d0]}, + truncated: {1, :draft, [:d1, :d4, :d12, :d13]}, + not_well_formed: {1, :draft, [:d2, :d5]}, + indefinite_length: {1, :draft, [:d3]}, + key_not_uint: {1, :draft, [:d6, :d9, :b3]}, + tag_forbidden: {1, :draft, [:d7]}, + float_forbidden: {1, :draft, [:d7]}, + simple_forbidden: {1, :draft, [:d7]}, + noncanonical: {1, :draft, [:d8, :d10, :d18]}, + duplicate_key: {1, :draft, [:d10]}, + int_out_of_range: {1, :draft, [:d11]}, + too_deep: {1, :draft, [:d12]}, + too_many_items: {1, :draft, [:d12]}, + text_too_long: {1, :draft, [:d14]}, + invalid_utf8: {1, :draft, [:d15]}, + text_unassigned: {1, :draft, [:d16]}, + text_format_control: {1, :draft, [:d16]}, + not_nfc: {1, :draft, [:d16]}, + nfc_maybe: {1, :draft, [:d16]}, + trailing_bytes: {1, :draft, [:d17]}, + # A term the encoder cannot express in the subset (an atom, a bare binary, a tuple). The encoder + # refuses it; no verifier emits it, so it is never a verdict. + value_not_n6: {1, :trinity, [:encoder]}, # Section 3, the body rules. - schema_version_missing: {1, :draft}, - unknown_schema: {1, :draft}, - unknown_key: {1, :draft}, - schema_reserved: {1, :draft}, - # The publisher's Decision of 2026-10-20, point 2, checked between `schema_reserved` and - # `unknown_schema` (its 17:27Z correction). - test_schema_outside_test_mode: {1, :publisher}, - set_not_canonical: {1, :draft}, - body_not_map: {1, :trinity}, - required_key_missing: {1, :trinity}, - wrong_type: {1, :trinity}, + body_not_map: {1, :draft, [:b1]}, + schema_version_missing: {1, :draft, [:b2]}, + schema_reserved: {1, :draft, [:b4]}, + test_schema_outside_test_mode: {1, :draft, [:b5]}, + unknown_schema: {1, :draft, [:b6]}, + typ_schema_mismatch: {1, :draft, [:b7]}, + issuer_missing: {1, :draft, [:b8]}, + field_missing: {1, :draft, [:b8]}, + unknown_key: {1, :draft, [:b8]}, + field_type_mismatch: {1, :draft, [:b8]}, + value_out_of_range: {1, :draft, [:b8]}, + text_grammar_mismatch: {1, :draft, [:b8]}, + set_not_canonical: {1, :draft, [:b8]}, # Section 4, digests. - digest_alg_not_permitted: {1, :draft}, - # The draft's own name for it (its W29), not yet in its code list. - digest_length_mismatch: {1, :trinity}, - digest_malformed: {1, :trinity}, - # Section 5, COSE_Sign1. - alg_not_permitted: {1, :draft}, - header_not_protected: {1, :draft}, - header_label_forbidden: {1, :draft}, - header_noncanonical: {1, :draft}, - content_type_forbidden: {1, :draft}, - countersignature_forbidden: {1, :draft}, - unprotected_forbidden: {1, :draft}, - kid_not_thumbprint: {1, :draft}, - typ_unexpected: {1, :draft}, - carrier_malformed: {1, :draft}, - wrong_carrier_for_typ: {1, :draft}, - # The draft names signature failures per twin role (signature_invalid_kv, _kh); a - # single-signer statement has no role yet (its section 12.1 item 1). - signature_invalid: {1, :trinity}, - # The publisher's Decision of 2026-10-20: an ESP384 signature that is not the 96-byte r || s - # (point 1), a key-0 value whose `typ` is another kind's (point 3), a full-payload - # single-signer body without key 1 (point 3). - signature_encoding: {1, :publisher}, - typ_schema_mismatch: {1, :publisher}, - issuer_missing: {1, :publisher}, - # A registered key that is not what its algorithm needs: for ESP384, a compressed point, another - # curve, or a point not on P-384. The Decision asks for the check and names no code. - key_invalid: {1, :trinity}, - # Point 4: the bytes a ForeignRef names. Missing bytes are a finding (no trust where the content - # must be judged); bytes that do not match are invalid for the attachment, never for the signed - # statement. - foreign_bytes_missing: {5, :publisher}, - foreign_digest_mismatch: {1, :publisher}, + digest_alg_not_permitted: {1, :draft, [:b8, :h4]}, + digest_length_mismatch: {1, :draft, [:b8, :carrier]}, + # Sections 5.2 and 5.6, keys and the protected header. + header_noncanonical: {1, :draft, [:h1, :h2]}, + header_label_forbidden: {1, :draft, [:h1, :h4]}, + header_missing: {1, :draft, [:h3, :h4]}, + typ_unexpected: {1, :draft, [:h3]}, + wrong_carrier_for_typ: {1, :draft, [:h3, :carrier]}, + content_type_forbidden: {1, :draft, [:h4]}, + header_value_invalid: {1, :draft, [:h4]}, + alg_not_permitted: {1, :draft, [:h4, :k1]}, + key_invalid: {1, :draft, [:k2]}, + kid_not_thumbprint: {1, :draft, [:k3]}, + # Section 5, the carrier and the signature. + carrier_malformed: {1, :draft, [:carrier]}, + header_not_protected: {1, :draft, [:carrier]}, + unprotected_forbidden: {1, :draft, [:carrier]}, + countersignature_forbidden: {1, :draft, [:carrier]}, + signature_encoding: {1, :draft, [:signature]}, + signature_invalid: {1, :draft, [:signature]}, + # Section 4.1: the bytes a ForeignRef names. Missing bytes are a finding (no trust where the + # content must be judged); bytes that do not match are invalid for the attachment, never for + # the signed statement. + foreign_bytes_missing: {5, :draft, [:attachment]}, + foreign_digest_mismatch: {1, :draft, [:attachment]}, # Section 9. - claim: {0, :draft}, - usage: {2, :draft}, - policy_stale_or_unknown_key: {5, :draft}, + claim: {0, :draft, [:verdict]}, + usage: {2, :draft, [:invocation]}, + policy_stale_or_unknown_key: {5, :draft, [:key_resolution]}, # The runtime cannot run the header's algorithm (ML-DSA-87, Ed25519 or ESP384; FIPS mode refuses # Ed25519, and Red Hat's provider ML-DSA-87), so the verifier cannot judge: no trust, never a pass - # and never a crash. - alg_unavailable: {5, :trinity} + # and never a crash. Not in draft v0.1-r7's registry; asked of the publisher on 2026-10-21. + alg_unavailable: {5, :trinity, [:signature]} } @typedoc "A refusal code, or `:claim` for a full verdict." @@ -172,15 +163,18 @@ defmodule Trinity.N6.Profile do } def limits, do: @limits - @doc "Every code, with its exit class and whether the draft defines it." - @spec codes() :: %{code() => {0 | 1 | 2 | 5, :draft | :publisher | :trinity}} + @doc """ + Every code, with its exit class, whether the draft defines it (`:draft`) or this tree does + (`:trinity`), and the steps that emit it. + """ + @spec codes() :: %{code() => {0 | 1 | 2 | 5, :draft | :trinity, [atom()]}} def codes, do: @codes @doc """ - The reserved-range schema numbers of the carriers, each with who assigned it: `:draft` (r3, still - Open there) or `:publisher` (the publisher's answers, ahead of the draft that will carry them). + The reserved-range schema numbers of the carriers, each with who assigned it (all `:draft` since + v0.1-r7 closed them). """ - @spec schemas() :: %{atom() => {non_neg_integer(), :draft | :publisher}} + @spec schemas() :: %{atom() => {non_neg_integer(), :draft}} def schemas, do: @schemas @doc "The key 0 value of a carrier." diff --git a/lib/trinity/n6/unicode15.ex b/lib/trinity/n6/unicode15.ex new file mode 100644 index 0000000..f4c63d5 --- /dev/null +++ b/lib/trinity/n6/unicode15.ex @@ -0,0 +1,339 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-FileCopyrightText: Unicode, Inc. +# SPDX-License-Identifier: Apache-2.0 AND Unicode-3.0 +# +# Generated by scripts/unicode15_tables.exs from the Unicode 15.0.0 data files. Do not edit. +defmodule Trinity.N6.Unicode15 do + @moduledoc """ + The four Unicode 15.0.0 tables the N6 text rule reads (N6 draft v0.1-r7 section 2.4; slice + 142): General_Category Cn and Cf, the canonical combining class, and NFC_Quick_Check No and + Maybe. Generated from these files, checked by SHA-256 before they were read: + + * `DerivedGeneralCategory.txt`: `fe29a45c0882500e591140aaa5c4f5067e6a5d746806148af34400c48b9c06f9` + * `DerivedNormalizationProps.txt`: `d5687a48c95c7d6e1ec59cb29c0f2e8b052018eb069a4371b7368d0561e12a29` + * `UnicodeData.txt`: `806e9aed65037197f1ec85e12be6e8cd870fc5608b4de0fffd990f689f376a73` + + The runtime's Unicode data is never consulted, because the profile pins one version and + runtimes ship others. Raising the version is a profile change. + """ + + @cn {0x0378, 0x0379, 0x0380, 0x0383, 0x038B, 0x038B, 0x038D, 0x038D, 0x03A2, 0x03A2, 0x0530, + 0x0530, 0x0557, 0x0558, 0x058B, 0x058C, 0x0590, 0x0590, 0x05C8, 0x05CF, 0x05EB, 0x05EE, + 0x05F5, 0x05FF, 0x070E, 0x070E, 0x074B, 0x074C, 0x07B2, 0x07BF, 0x07FB, 0x07FC, 0x082E, + 0x082F, 0x083F, 0x083F, 0x085C, 0x085D, 0x085F, 0x085F, 0x086B, 0x086F, 0x088F, 0x088F, + 0x0892, 0x0897, 0x0984, 0x0984, 0x098D, 0x098E, 0x0991, 0x0992, 0x09A9, 0x09A9, 0x09B1, + 0x09B1, 0x09B3, 0x09B5, 0x09BA, 0x09BB, 0x09C5, 0x09C6, 0x09C9, 0x09CA, 0x09CF, 0x09D6, + 0x09D8, 0x09DB, 0x09DE, 0x09DE, 0x09E4, 0x09E5, 0x09FF, 0x0A00, 0x0A04, 0x0A04, 0x0A0B, + 0x0A0E, 0x0A11, 0x0A12, 0x0A29, 0x0A29, 0x0A31, 0x0A31, 0x0A34, 0x0A34, 0x0A37, 0x0A37, + 0x0A3A, 0x0A3B, 0x0A3D, 0x0A3D, 0x0A43, 0x0A46, 0x0A49, 0x0A4A, 0x0A4E, 0x0A50, 0x0A52, + 0x0A58, 0x0A5D, 0x0A5D, 0x0A5F, 0x0A65, 0x0A77, 0x0A80, 0x0A84, 0x0A84, 0x0A8E, 0x0A8E, + 0x0A92, 0x0A92, 0x0AA9, 0x0AA9, 0x0AB1, 0x0AB1, 0x0AB4, 0x0AB4, 0x0ABA, 0x0ABB, 0x0AC6, + 0x0AC6, 0x0ACA, 0x0ACA, 0x0ACE, 0x0ACF, 0x0AD1, 0x0ADF, 0x0AE4, 0x0AE5, 0x0AF2, 0x0AF8, + 0x0B00, 0x0B00, 0x0B04, 0x0B04, 0x0B0D, 0x0B0E, 0x0B11, 0x0B12, 0x0B29, 0x0B29, 0x0B31, + 0x0B31, 0x0B34, 0x0B34, 0x0B3A, 0x0B3B, 0x0B45, 0x0B46, 0x0B49, 0x0B4A, 0x0B4E, 0x0B54, + 0x0B58, 0x0B5B, 0x0B5E, 0x0B5E, 0x0B64, 0x0B65, 0x0B78, 0x0B81, 0x0B84, 0x0B84, 0x0B8B, + 0x0B8D, 0x0B91, 0x0B91, 0x0B96, 0x0B98, 0x0B9B, 0x0B9B, 0x0B9D, 0x0B9D, 0x0BA0, 0x0BA2, + 0x0BA5, 0x0BA7, 0x0BAB, 0x0BAD, 0x0BBA, 0x0BBD, 0x0BC3, 0x0BC5, 0x0BC9, 0x0BC9, 0x0BCE, + 0x0BCF, 0x0BD1, 0x0BD6, 0x0BD8, 0x0BE5, 0x0BFB, 0x0BFF, 0x0C0D, 0x0C0D, 0x0C11, 0x0C11, + 0x0C29, 0x0C29, 0x0C3A, 0x0C3B, 0x0C45, 0x0C45, 0x0C49, 0x0C49, 0x0C4E, 0x0C54, 0x0C57, + 0x0C57, 0x0C5B, 0x0C5C, 0x0C5E, 0x0C5F, 0x0C64, 0x0C65, 0x0C70, 0x0C76, 0x0C8D, 0x0C8D, + 0x0C91, 0x0C91, 0x0CA9, 0x0CA9, 0x0CB4, 0x0CB4, 0x0CBA, 0x0CBB, 0x0CC5, 0x0CC5, 0x0CC9, + 0x0CC9, 0x0CCE, 0x0CD4, 0x0CD7, 0x0CDC, 0x0CDF, 0x0CDF, 0x0CE4, 0x0CE5, 0x0CF0, 0x0CF0, + 0x0CF4, 0x0CFF, 0x0D0D, 0x0D0D, 0x0D11, 0x0D11, 0x0D45, 0x0D45, 0x0D49, 0x0D49, 0x0D50, + 0x0D53, 0x0D64, 0x0D65, 0x0D80, 0x0D80, 0x0D84, 0x0D84, 0x0D97, 0x0D99, 0x0DB2, 0x0DB2, + 0x0DBC, 0x0DBC, 0x0DBE, 0x0DBF, 0x0DC7, 0x0DC9, 0x0DCB, 0x0DCE, 0x0DD5, 0x0DD5, 0x0DD7, + 0x0DD7, 0x0DE0, 0x0DE5, 0x0DF0, 0x0DF1, 0x0DF5, 0x0E00, 0x0E3B, 0x0E3E, 0x0E5C, 0x0E80, + 0x0E83, 0x0E83, 0x0E85, 0x0E85, 0x0E8B, 0x0E8B, 0x0EA4, 0x0EA4, 0x0EA6, 0x0EA6, 0x0EBE, + 0x0EBF, 0x0EC5, 0x0EC5, 0x0EC7, 0x0EC7, 0x0ECF, 0x0ECF, 0x0EDA, 0x0EDB, 0x0EE0, 0x0EFF, + 0x0F48, 0x0F48, 0x0F6D, 0x0F70, 0x0F98, 0x0F98, 0x0FBD, 0x0FBD, 0x0FCD, 0x0FCD, 0x0FDB, + 0x0FFF, 0x10C6, 0x10C6, 0x10C8, 0x10CC, 0x10CE, 0x10CF, 0x1249, 0x1249, 0x124E, 0x124F, + 0x1257, 0x1257, 0x1259, 0x1259, 0x125E, 0x125F, 0x1289, 0x1289, 0x128E, 0x128F, 0x12B1, + 0x12B1, 0x12B6, 0x12B7, 0x12BF, 0x12BF, 0x12C1, 0x12C1, 0x12C6, 0x12C7, 0x12D7, 0x12D7, + 0x1311, 0x1311, 0x1316, 0x1317, 0x135B, 0x135C, 0x137D, 0x137F, 0x139A, 0x139F, 0x13F6, + 0x13F7, 0x13FE, 0x13FF, 0x169D, 0x169F, 0x16F9, 0x16FF, 0x1716, 0x171E, 0x1737, 0x173F, + 0x1754, 0x175F, 0x176D, 0x176D, 0x1771, 0x1771, 0x1774, 0x177F, 0x17DE, 0x17DF, 0x17EA, + 0x17EF, 0x17FA, 0x17FF, 0x181A, 0x181F, 0x1879, 0x187F, 0x18AB, 0x18AF, 0x18F6, 0x18FF, + 0x191F, 0x191F, 0x192C, 0x192F, 0x193C, 0x193F, 0x1941, 0x1943, 0x196E, 0x196F, 0x1975, + 0x197F, 0x19AC, 0x19AF, 0x19CA, 0x19CF, 0x19DB, 0x19DD, 0x1A1C, 0x1A1D, 0x1A5F, 0x1A5F, + 0x1A7D, 0x1A7E, 0x1A8A, 0x1A8F, 0x1A9A, 0x1A9F, 0x1AAE, 0x1AAF, 0x1ACF, 0x1AFF, 0x1B4D, + 0x1B4F, 0x1B7F, 0x1B7F, 0x1BF4, 0x1BFB, 0x1C38, 0x1C3A, 0x1C4A, 0x1C4C, 0x1C89, 0x1C8F, + 0x1CBB, 0x1CBC, 0x1CC8, 0x1CCF, 0x1CFB, 0x1CFF, 0x1F16, 0x1F17, 0x1F1E, 0x1F1F, 0x1F46, + 0x1F47, 0x1F4E, 0x1F4F, 0x1F58, 0x1F58, 0x1F5A, 0x1F5A, 0x1F5C, 0x1F5C, 0x1F5E, 0x1F5E, + 0x1F7E, 0x1F7F, 0x1FB5, 0x1FB5, 0x1FC5, 0x1FC5, 0x1FD4, 0x1FD5, 0x1FDC, 0x1FDC, 0x1FF0, + 0x1FF1, 0x1FF5, 0x1FF5, 0x1FFF, 0x1FFF, 0x2065, 0x2065, 0x2072, 0x2073, 0x208F, 0x208F, + 0x209D, 0x209F, 0x20C1, 0x20CF, 0x20F1, 0x20FF, 0x218C, 0x218F, 0x2427, 0x243F, 0x244B, + 0x245F, 0x2B74, 0x2B75, 0x2B96, 0x2B96, 0x2CF4, 0x2CF8, 0x2D26, 0x2D26, 0x2D28, 0x2D2C, + 0x2D2E, 0x2D2F, 0x2D68, 0x2D6E, 0x2D71, 0x2D7E, 0x2D97, 0x2D9F, 0x2DA7, 0x2DA7, 0x2DAF, + 0x2DAF, 0x2DB7, 0x2DB7, 0x2DBF, 0x2DBF, 0x2DC7, 0x2DC7, 0x2DCF, 0x2DCF, 0x2DD7, 0x2DD7, + 0x2DDF, 0x2DDF, 0x2E5E, 0x2E7F, 0x2E9A, 0x2E9A, 0x2EF4, 0x2EFF, 0x2FD6, 0x2FEF, 0x2FFC, + 0x2FFF, 0x3040, 0x3040, 0x3097, 0x3098, 0x3100, 0x3104, 0x3130, 0x3130, 0x318F, 0x318F, + 0x31E4, 0x31EF, 0x321F, 0x321F, 0xA48D, 0xA48F, 0xA4C7, 0xA4CF, 0xA62C, 0xA63F, 0xA6F8, + 0xA6FF, 0xA7CB, 0xA7CF, 0xA7D2, 0xA7D2, 0xA7D4, 0xA7D4, 0xA7DA, 0xA7F1, 0xA82D, 0xA82F, + 0xA83A, 0xA83F, 0xA878, 0xA87F, 0xA8C6, 0xA8CD, 0xA8DA, 0xA8DF, 0xA954, 0xA95E, 0xA97D, + 0xA97F, 0xA9CE, 0xA9CE, 0xA9DA, 0xA9DD, 0xA9FF, 0xA9FF, 0xAA37, 0xAA3F, 0xAA4E, 0xAA4F, + 0xAA5A, 0xAA5B, 0xAAC3, 0xAADA, 0xAAF7, 0xAB00, 0xAB07, 0xAB08, 0xAB0F, 0xAB10, 0xAB17, + 0xAB1F, 0xAB27, 0xAB27, 0xAB2F, 0xAB2F, 0xAB6C, 0xAB6F, 0xABEE, 0xABEF, 0xABFA, 0xABFF, + 0xD7A4, 0xD7AF, 0xD7C7, 0xD7CA, 0xD7FC, 0xD7FF, 0xFA6E, 0xFA6F, 0xFADA, 0xFAFF, 0xFB07, + 0xFB12, 0xFB18, 0xFB1C, 0xFB37, 0xFB37, 0xFB3D, 0xFB3D, 0xFB3F, 0xFB3F, 0xFB42, 0xFB42, + 0xFB45, 0xFB45, 0xFBC3, 0xFBD2, 0xFD90, 0xFD91, 0xFDC8, 0xFDCE, 0xFDD0, 0xFDEF, 0xFE1A, + 0xFE1F, 0xFE53, 0xFE53, 0xFE67, 0xFE67, 0xFE6C, 0xFE6F, 0xFE75, 0xFE75, 0xFEFD, 0xFEFE, + 0xFF00, 0xFF00, 0xFFBF, 0xFFC1, 0xFFC8, 0xFFC9, 0xFFD0, 0xFFD1, 0xFFD8, 0xFFD9, 0xFFDD, + 0xFFDF, 0xFFE7, 0xFFE7, 0xFFEF, 0xFFF8, 0xFFFE, 0xFFFF, 0x1000C, 0x1000C, 0x10027, 0x10027, + 0x1003B, 0x1003B, 0x1003E, 0x1003E, 0x1004E, 0x1004F, 0x1005E, 0x1007F, 0x100FB, 0x100FF, + 0x10103, 0x10106, 0x10134, 0x10136, 0x1018F, 0x1018F, 0x1019D, 0x1019F, 0x101A1, 0x101CF, + 0x101FE, 0x1027F, 0x1029D, 0x1029F, 0x102D1, 0x102DF, 0x102FC, 0x102FF, 0x10324, 0x1032C, + 0x1034B, 0x1034F, 0x1037B, 0x1037F, 0x1039E, 0x1039E, 0x103C4, 0x103C7, 0x103D6, 0x103FF, + 0x1049E, 0x1049F, 0x104AA, 0x104AF, 0x104D4, 0x104D7, 0x104FC, 0x104FF, 0x10528, 0x1052F, + 0x10564, 0x1056E, 0x1057B, 0x1057B, 0x1058B, 0x1058B, 0x10593, 0x10593, 0x10596, 0x10596, + 0x105A2, 0x105A2, 0x105B2, 0x105B2, 0x105BA, 0x105BA, 0x105BD, 0x105FF, 0x10737, 0x1073F, + 0x10756, 0x1075F, 0x10768, 0x1077F, 0x10786, 0x10786, 0x107B1, 0x107B1, 0x107BB, 0x107FF, + 0x10806, 0x10807, 0x10809, 0x10809, 0x10836, 0x10836, 0x10839, 0x1083B, 0x1083D, 0x1083E, + 0x10856, 0x10856, 0x1089F, 0x108A6, 0x108B0, 0x108DF, 0x108F3, 0x108F3, 0x108F6, 0x108FA, + 0x1091C, 0x1091E, 0x1093A, 0x1093E, 0x10940, 0x1097F, 0x109B8, 0x109BB, 0x109D0, 0x109D1, + 0x10A04, 0x10A04, 0x10A07, 0x10A0B, 0x10A14, 0x10A14, 0x10A18, 0x10A18, 0x10A36, 0x10A37, + 0x10A3B, 0x10A3E, 0x10A49, 0x10A4F, 0x10A59, 0x10A5F, 0x10AA0, 0x10ABF, 0x10AE7, 0x10AEA, + 0x10AF7, 0x10AFF, 0x10B36, 0x10B38, 0x10B56, 0x10B57, 0x10B73, 0x10B77, 0x10B92, 0x10B98, + 0x10B9D, 0x10BA8, 0x10BB0, 0x10BFF, 0x10C49, 0x10C7F, 0x10CB3, 0x10CBF, 0x10CF3, 0x10CF9, + 0x10D28, 0x10D2F, 0x10D3A, 0x10E5F, 0x10E7F, 0x10E7F, 0x10EAA, 0x10EAA, 0x10EAE, 0x10EAF, + 0x10EB2, 0x10EFC, 0x10F28, 0x10F2F, 0x10F5A, 0x10F6F, 0x10F8A, 0x10FAF, 0x10FCC, 0x10FDF, + 0x10FF7, 0x10FFF, 0x1104E, 0x11051, 0x11076, 0x1107E, 0x110C3, 0x110CC, 0x110CE, 0x110CF, + 0x110E9, 0x110EF, 0x110FA, 0x110FF, 0x11135, 0x11135, 0x11148, 0x1114F, 0x11177, 0x1117F, + 0x111E0, 0x111E0, 0x111F5, 0x111FF, 0x11212, 0x11212, 0x11242, 0x1127F, 0x11287, 0x11287, + 0x11289, 0x11289, 0x1128E, 0x1128E, 0x1129E, 0x1129E, 0x112AA, 0x112AF, 0x112EB, 0x112EF, + 0x112FA, 0x112FF, 0x11304, 0x11304, 0x1130D, 0x1130E, 0x11311, 0x11312, 0x11329, 0x11329, + 0x11331, 0x11331, 0x11334, 0x11334, 0x1133A, 0x1133A, 0x11345, 0x11346, 0x11349, 0x1134A, + 0x1134E, 0x1134F, 0x11351, 0x11356, 0x11358, 0x1135C, 0x11364, 0x11365, 0x1136D, 0x1136F, + 0x11375, 0x113FF, 0x1145C, 0x1145C, 0x11462, 0x1147F, 0x114C8, 0x114CF, 0x114DA, 0x1157F, + 0x115B6, 0x115B7, 0x115DE, 0x115FF, 0x11645, 0x1164F, 0x1165A, 0x1165F, 0x1166D, 0x1167F, + 0x116BA, 0x116BF, 0x116CA, 0x116FF, 0x1171B, 0x1171C, 0x1172C, 0x1172F, 0x11747, 0x117FF, + 0x1183C, 0x1189F, 0x118F3, 0x118FE, 0x11907, 0x11908, 0x1190A, 0x1190B, 0x11914, 0x11914, + 0x11917, 0x11917, 0x11936, 0x11936, 0x11939, 0x1193A, 0x11947, 0x1194F, 0x1195A, 0x1199F, + 0x119A8, 0x119A9, 0x119D8, 0x119D9, 0x119E5, 0x119FF, 0x11A48, 0x11A4F, 0x11AA3, 0x11AAF, + 0x11AF9, 0x11AFF, 0x11B0A, 0x11BFF, 0x11C09, 0x11C09, 0x11C37, 0x11C37, 0x11C46, 0x11C4F, + 0x11C6D, 0x11C6F, 0x11C90, 0x11C91, 0x11CA8, 0x11CA8, 0x11CB7, 0x11CFF, 0x11D07, 0x11D07, + 0x11D0A, 0x11D0A, 0x11D37, 0x11D39, 0x11D3B, 0x11D3B, 0x11D3E, 0x11D3E, 0x11D48, 0x11D4F, + 0x11D5A, 0x11D5F, 0x11D66, 0x11D66, 0x11D69, 0x11D69, 0x11D8F, 0x11D8F, 0x11D92, 0x11D92, + 0x11D99, 0x11D9F, 0x11DAA, 0x11EDF, 0x11EF9, 0x11EFF, 0x11F11, 0x11F11, 0x11F3B, 0x11F3D, + 0x11F5A, 0x11FAF, 0x11FB1, 0x11FBF, 0x11FF2, 0x11FFE, 0x1239A, 0x123FF, 0x1246F, 0x1246F, + 0x12475, 0x1247F, 0x12544, 0x12F8F, 0x12FF3, 0x12FFF, 0x13456, 0x143FF, 0x14647, 0x167FF, + 0x16A39, 0x16A3F, 0x16A5F, 0x16A5F, 0x16A6A, 0x16A6D, 0x16ABF, 0x16ABF, 0x16ACA, 0x16ACF, + 0x16AEE, 0x16AEF, 0x16AF6, 0x16AFF, 0x16B46, 0x16B4F, 0x16B5A, 0x16B5A, 0x16B62, 0x16B62, + 0x16B78, 0x16B7C, 0x16B90, 0x16E3F, 0x16E9B, 0x16EFF, 0x16F4B, 0x16F4E, 0x16F88, 0x16F8E, + 0x16FA0, 0x16FDF, 0x16FE5, 0x16FEF, 0x16FF2, 0x16FFF, 0x187F8, 0x187FF, 0x18CD6, 0x18CFF, + 0x18D09, 0x1AFEF, 0x1AFF4, 0x1AFF4, 0x1AFFC, 0x1AFFC, 0x1AFFF, 0x1AFFF, 0x1B123, 0x1B131, + 0x1B133, 0x1B14F, 0x1B153, 0x1B154, 0x1B156, 0x1B163, 0x1B168, 0x1B16F, 0x1B2FC, 0x1BBFF, + 0x1BC6B, 0x1BC6F, 0x1BC7D, 0x1BC7F, 0x1BC89, 0x1BC8F, 0x1BC9A, 0x1BC9B, 0x1BCA4, 0x1CEFF, + 0x1CF2E, 0x1CF2F, 0x1CF47, 0x1CF4F, 0x1CFC4, 0x1CFFF, 0x1D0F6, 0x1D0FF, 0x1D127, 0x1D128, + 0x1D1EB, 0x1D1FF, 0x1D246, 0x1D2BF, 0x1D2D4, 0x1D2DF, 0x1D2F4, 0x1D2FF, 0x1D357, 0x1D35F, + 0x1D379, 0x1D3FF, 0x1D455, 0x1D455, 0x1D49D, 0x1D49D, 0x1D4A0, 0x1D4A1, 0x1D4A3, 0x1D4A4, + 0x1D4A7, 0x1D4A8, 0x1D4AD, 0x1D4AD, 0x1D4BA, 0x1D4BA, 0x1D4BC, 0x1D4BC, 0x1D4C4, 0x1D4C4, + 0x1D506, 0x1D506, 0x1D50B, 0x1D50C, 0x1D515, 0x1D515, 0x1D51D, 0x1D51D, 0x1D53A, 0x1D53A, + 0x1D53F, 0x1D53F, 0x1D545, 0x1D545, 0x1D547, 0x1D549, 0x1D551, 0x1D551, 0x1D6A6, 0x1D6A7, + 0x1D7CC, 0x1D7CD, 0x1DA8C, 0x1DA9A, 0x1DAA0, 0x1DAA0, 0x1DAB0, 0x1DEFF, 0x1DF1F, 0x1DF24, + 0x1DF2B, 0x1DFFF, 0x1E007, 0x1E007, 0x1E019, 0x1E01A, 0x1E022, 0x1E022, 0x1E025, 0x1E025, + 0x1E02B, 0x1E02F, 0x1E06E, 0x1E08E, 0x1E090, 0x1E0FF, 0x1E12D, 0x1E12F, 0x1E13E, 0x1E13F, + 0x1E14A, 0x1E14D, 0x1E150, 0x1E28F, 0x1E2AF, 0x1E2BF, 0x1E2FA, 0x1E2FE, 0x1E300, 0x1E4CF, + 0x1E4FA, 0x1E7DF, 0x1E7E7, 0x1E7E7, 0x1E7EC, 0x1E7EC, 0x1E7EF, 0x1E7EF, 0x1E7FF, 0x1E7FF, + 0x1E8C5, 0x1E8C6, 0x1E8D7, 0x1E8FF, 0x1E94C, 0x1E94F, 0x1E95A, 0x1E95D, 0x1E960, 0x1EC70, + 0x1ECB5, 0x1ED00, 0x1ED3E, 0x1EDFF, 0x1EE04, 0x1EE04, 0x1EE20, 0x1EE20, 0x1EE23, 0x1EE23, + 0x1EE25, 0x1EE26, 0x1EE28, 0x1EE28, 0x1EE33, 0x1EE33, 0x1EE38, 0x1EE38, 0x1EE3A, 0x1EE3A, + 0x1EE3C, 0x1EE41, 0x1EE43, 0x1EE46, 0x1EE48, 0x1EE48, 0x1EE4A, 0x1EE4A, 0x1EE4C, 0x1EE4C, + 0x1EE50, 0x1EE50, 0x1EE53, 0x1EE53, 0x1EE55, 0x1EE56, 0x1EE58, 0x1EE58, 0x1EE5A, 0x1EE5A, + 0x1EE5C, 0x1EE5C, 0x1EE5E, 0x1EE5E, 0x1EE60, 0x1EE60, 0x1EE63, 0x1EE63, 0x1EE65, 0x1EE66, + 0x1EE6B, 0x1EE6B, 0x1EE73, 0x1EE73, 0x1EE78, 0x1EE78, 0x1EE7D, 0x1EE7D, 0x1EE7F, 0x1EE7F, + 0x1EE8A, 0x1EE8A, 0x1EE9C, 0x1EEA0, 0x1EEA4, 0x1EEA4, 0x1EEAA, 0x1EEAA, 0x1EEBC, 0x1EEEF, + 0x1EEF2, 0x1EFFF, 0x1F02C, 0x1F02F, 0x1F094, 0x1F09F, 0x1F0AF, 0x1F0B0, 0x1F0C0, 0x1F0C0, + 0x1F0D0, 0x1F0D0, 0x1F0F6, 0x1F0FF, 0x1F1AE, 0x1F1E5, 0x1F203, 0x1F20F, 0x1F23C, 0x1F23F, + 0x1F249, 0x1F24F, 0x1F252, 0x1F25F, 0x1F266, 0x1F2FF, 0x1F6D8, 0x1F6DB, 0x1F6ED, 0x1F6EF, + 0x1F6FD, 0x1F6FF, 0x1F777, 0x1F77A, 0x1F7DA, 0x1F7DF, 0x1F7EC, 0x1F7EF, 0x1F7F1, 0x1F7FF, + 0x1F80C, 0x1F80F, 0x1F848, 0x1F84F, 0x1F85A, 0x1F85F, 0x1F888, 0x1F88F, 0x1F8AE, 0x1F8AF, + 0x1F8B2, 0x1F8FF, 0x1FA54, 0x1FA5F, 0x1FA6E, 0x1FA6F, 0x1FA7D, 0x1FA7F, 0x1FA89, 0x1FA8F, + 0x1FABE, 0x1FABE, 0x1FAC6, 0x1FACD, 0x1FADC, 0x1FADF, 0x1FAE9, 0x1FAEF, 0x1FAF9, 0x1FAFF, + 0x1FB93, 0x1FB93, 0x1FBCB, 0x1FBEF, 0x1FBFA, 0x1FFFF, 0x2A6E0, 0x2A6FF, 0x2B73A, 0x2B73F, + 0x2B81E, 0x2B81F, 0x2CEA2, 0x2CEAF, 0x2EBE1, 0x2F7FF, 0x2FA1E, 0x2FFFF, 0x3134B, 0x3134F, + 0x323B0, 0xE0000, 0xE0002, 0xE001F, 0xE0080, 0xE00FF, 0xE01F0, 0xEFFFF, 0xFFFFE, 0xFFFFF, + 0x10FFFE, 0x10FFFF} + @cf {0x00AD, 0x00AD, 0x0600, 0x0605, 0x061C, 0x061C, 0x06DD, 0x06DD, 0x070F, 0x070F, 0x0890, + 0x0891, 0x08E2, 0x08E2, 0x180E, 0x180E, 0x200B, 0x200F, 0x202A, 0x202E, 0x2060, 0x2064, + 0x2066, 0x206F, 0xFEFF, 0xFEFF, 0xFFF9, 0xFFFB, 0x110BD, 0x110BD, 0x110CD, 0x110CD, + 0x13430, 0x1343F, 0x1BCA0, 0x1BCA3, 0x1D173, 0x1D17A, 0xE0001, 0xE0001, 0xE0020, 0xE007F} + @qc_no {0x0340, 0x0341, 0x0343, 0x0344, 0x0374, 0x0374, 0x037E, 0x037E, 0x0387, 0x0387, 0x0958, + 0x095F, 0x09DC, 0x09DD, 0x09DF, 0x09DF, 0x0A33, 0x0A33, 0x0A36, 0x0A36, 0x0A59, 0x0A5B, + 0x0A5E, 0x0A5E, 0x0B5C, 0x0B5D, 0x0F43, 0x0F43, 0x0F4D, 0x0F4D, 0x0F52, 0x0F52, 0x0F57, + 0x0F57, 0x0F5C, 0x0F5C, 0x0F69, 0x0F69, 0x0F73, 0x0F73, 0x0F75, 0x0F76, 0x0F78, 0x0F78, + 0x0F81, 0x0F81, 0x0F93, 0x0F93, 0x0F9D, 0x0F9D, 0x0FA2, 0x0FA2, 0x0FA7, 0x0FA7, 0x0FAC, + 0x0FAC, 0x0FB9, 0x0FB9, 0x1F71, 0x1F71, 0x1F73, 0x1F73, 0x1F75, 0x1F75, 0x1F77, 0x1F77, + 0x1F79, 0x1F79, 0x1F7B, 0x1F7B, 0x1F7D, 0x1F7D, 0x1FBB, 0x1FBB, 0x1FBE, 0x1FBE, 0x1FC9, + 0x1FC9, 0x1FCB, 0x1FCB, 0x1FD3, 0x1FD3, 0x1FDB, 0x1FDB, 0x1FE3, 0x1FE3, 0x1FEB, 0x1FEB, + 0x1FEE, 0x1FEF, 0x1FF9, 0x1FF9, 0x1FFB, 0x1FFB, 0x1FFD, 0x1FFD, 0x2000, 0x2001, 0x2126, + 0x2126, 0x212A, 0x212B, 0x2329, 0x232A, 0x2ADC, 0x2ADC, 0xF900, 0xFA0D, 0xFA10, 0xFA10, + 0xFA12, 0xFA12, 0xFA15, 0xFA1E, 0xFA20, 0xFA20, 0xFA22, 0xFA22, 0xFA25, 0xFA26, 0xFA2A, + 0xFA6D, 0xFA70, 0xFAD9, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB1F, 0xFB2A, 0xFB36, 0xFB38, 0xFB3C, + 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFB4E, 0x1D15E, 0x1D164, + 0x1D1BB, 0x1D1C0, 0x2F800, 0x2FA1D} + @qc_maybe {0x0300, 0x0304, 0x0306, 0x030C, 0x030F, 0x030F, 0x0311, 0x0311, 0x0313, 0x0314, + 0x031B, 0x031B, 0x0323, 0x0328, 0x032D, 0x032E, 0x0330, 0x0331, 0x0338, 0x0338, + 0x0342, 0x0342, 0x0345, 0x0345, 0x0653, 0x0655, 0x093C, 0x093C, 0x09BE, 0x09BE, + 0x09D7, 0x09D7, 0x0B3E, 0x0B3E, 0x0B56, 0x0B57, 0x0BBE, 0x0BBE, 0x0BD7, 0x0BD7, + 0x0C56, 0x0C56, 0x0CC2, 0x0CC2, 0x0CD5, 0x0CD6, 0x0D3E, 0x0D3E, 0x0D57, 0x0D57, + 0x0DCA, 0x0DCA, 0x0DCF, 0x0DCF, 0x0DDF, 0x0DDF, 0x102E, 0x102E, 0x1161, 0x1175, + 0x11A8, 0x11C2, 0x1B35, 0x1B35, 0x3099, 0x309A, 0x110BA, 0x110BA, 0x11127, 0x11127, + 0x1133E, 0x1133E, 0x11357, 0x11357, 0x114B0, 0x114B0, 0x114BA, 0x114BA, 0x114BD, + 0x114BD, 0x115AF, 0x115AF, 0x11930, 0x11930} + @ccc {0x0300, 0x0314, 230, 0x0315, 0x0315, 232, 0x0316, 0x0319, 220, 0x031A, 0x031A, 232, + 0x031B, 0x031B, 216, 0x031C, 0x0320, 220, 0x0321, 0x0322, 202, 0x0323, 0x0326, 220, + 0x0327, 0x0328, 202, 0x0329, 0x0333, 220, 0x0334, 0x0338, 1, 0x0339, 0x033C, 220, 0x033D, + 0x0344, 230, 0x0345, 0x0345, 240, 0x0346, 0x0346, 230, 0x0347, 0x0349, 220, 0x034A, + 0x034C, 230, 0x034D, 0x034E, 220, 0x0350, 0x0352, 230, 0x0353, 0x0356, 220, 0x0357, + 0x0357, 230, 0x0358, 0x0358, 232, 0x0359, 0x035A, 220, 0x035B, 0x035B, 230, 0x035C, + 0x035C, 233, 0x035D, 0x035E, 234, 0x035F, 0x035F, 233, 0x0360, 0x0361, 234, 0x0362, + 0x0362, 233, 0x0363, 0x036F, 230, 0x0483, 0x0487, 230, 0x0591, 0x0591, 220, 0x0592, + 0x0595, 230, 0x0596, 0x0596, 220, 0x0597, 0x0599, 230, 0x059A, 0x059A, 222, 0x059B, + 0x059B, 220, 0x059C, 0x05A1, 230, 0x05A2, 0x05A7, 220, 0x05A8, 0x05A9, 230, 0x05AA, + 0x05AA, 220, 0x05AB, 0x05AC, 230, 0x05AD, 0x05AD, 222, 0x05AE, 0x05AE, 228, 0x05AF, + 0x05AF, 230, 0x05B0, 0x05B0, 10, 0x05B1, 0x05B1, 11, 0x05B2, 0x05B2, 12, 0x05B3, 0x05B3, + 13, 0x05B4, 0x05B4, 14, 0x05B5, 0x05B5, 15, 0x05B6, 0x05B6, 16, 0x05B7, 0x05B7, 17, + 0x05B8, 0x05B8, 18, 0x05B9, 0x05BA, 19, 0x05BB, 0x05BB, 20, 0x05BC, 0x05BC, 21, 0x05BD, + 0x05BD, 22, 0x05BF, 0x05BF, 23, 0x05C1, 0x05C1, 24, 0x05C2, 0x05C2, 25, 0x05C4, 0x05C4, + 230, 0x05C5, 0x05C5, 220, 0x05C7, 0x05C7, 18, 0x0610, 0x0617, 230, 0x0618, 0x0618, 30, + 0x0619, 0x0619, 31, 0x061A, 0x061A, 32, 0x064B, 0x064B, 27, 0x064C, 0x064C, 28, 0x064D, + 0x064D, 29, 0x064E, 0x064E, 30, 0x064F, 0x064F, 31, 0x0650, 0x0650, 32, 0x0651, 0x0651, + 33, 0x0652, 0x0652, 34, 0x0653, 0x0654, 230, 0x0655, 0x0656, 220, 0x0657, 0x065B, 230, + 0x065C, 0x065C, 220, 0x065D, 0x065E, 230, 0x065F, 0x065F, 220, 0x0670, 0x0670, 35, 0x06D6, + 0x06DC, 230, 0x06DF, 0x06E2, 230, 0x06E3, 0x06E3, 220, 0x06E4, 0x06E4, 230, 0x06E7, + 0x06E8, 230, 0x06EA, 0x06EA, 220, 0x06EB, 0x06EC, 230, 0x06ED, 0x06ED, 220, 0x0711, + 0x0711, 36, 0x0730, 0x0730, 230, 0x0731, 0x0731, 220, 0x0732, 0x0733, 230, 0x0734, 0x0734, + 220, 0x0735, 0x0736, 230, 0x0737, 0x0739, 220, 0x073A, 0x073A, 230, 0x073B, 0x073C, 220, + 0x073D, 0x073D, 230, 0x073E, 0x073E, 220, 0x073F, 0x0741, 230, 0x0742, 0x0742, 220, + 0x0743, 0x0743, 230, 0x0744, 0x0744, 220, 0x0745, 0x0745, 230, 0x0746, 0x0746, 220, + 0x0747, 0x0747, 230, 0x0748, 0x0748, 220, 0x0749, 0x074A, 230, 0x07EB, 0x07F1, 230, + 0x07F2, 0x07F2, 220, 0x07F3, 0x07F3, 230, 0x07FD, 0x07FD, 220, 0x0816, 0x0819, 230, + 0x081B, 0x0823, 230, 0x0825, 0x0827, 230, 0x0829, 0x082D, 230, 0x0859, 0x085B, 220, + 0x0898, 0x0898, 230, 0x0899, 0x089B, 220, 0x089C, 0x089F, 230, 0x08CA, 0x08CE, 230, + 0x08CF, 0x08D3, 220, 0x08D4, 0x08E1, 230, 0x08E3, 0x08E3, 220, 0x08E4, 0x08E5, 230, + 0x08E6, 0x08E6, 220, 0x08E7, 0x08E8, 230, 0x08E9, 0x08E9, 220, 0x08EA, 0x08EC, 230, + 0x08ED, 0x08EF, 220, 0x08F0, 0x08F0, 27, 0x08F1, 0x08F1, 28, 0x08F2, 0x08F2, 29, 0x08F3, + 0x08F5, 230, 0x08F6, 0x08F6, 220, 0x08F7, 0x08F8, 230, 0x08F9, 0x08FA, 220, 0x08FB, + 0x08FF, 230, 0x093C, 0x093C, 7, 0x094D, 0x094D, 9, 0x0951, 0x0951, 230, 0x0952, 0x0952, + 220, 0x0953, 0x0954, 230, 0x09BC, 0x09BC, 7, 0x09CD, 0x09CD, 9, 0x09FE, 0x09FE, 230, + 0x0A3C, 0x0A3C, 7, 0x0A4D, 0x0A4D, 9, 0x0ABC, 0x0ABC, 7, 0x0ACD, 0x0ACD, 9, 0x0B3C, + 0x0B3C, 7, 0x0B4D, 0x0B4D, 9, 0x0BCD, 0x0BCD, 9, 0x0C3C, 0x0C3C, 7, 0x0C4D, 0x0C4D, 9, + 0x0C55, 0x0C55, 84, 0x0C56, 0x0C56, 91, 0x0CBC, 0x0CBC, 7, 0x0CCD, 0x0CCD, 9, 0x0D3B, + 0x0D3C, 9, 0x0D4D, 0x0D4D, 9, 0x0DCA, 0x0DCA, 9, 0x0E38, 0x0E39, 103, 0x0E3A, 0x0E3A, 9, + 0x0E48, 0x0E4B, 107, 0x0EB8, 0x0EB9, 118, 0x0EBA, 0x0EBA, 9, 0x0EC8, 0x0ECB, 122, 0x0F18, + 0x0F19, 220, 0x0F35, 0x0F35, 220, 0x0F37, 0x0F37, 220, 0x0F39, 0x0F39, 216, 0x0F71, + 0x0F71, 129, 0x0F72, 0x0F72, 130, 0x0F74, 0x0F74, 132, 0x0F7A, 0x0F7D, 130, 0x0F80, + 0x0F80, 130, 0x0F82, 0x0F83, 230, 0x0F84, 0x0F84, 9, 0x0F86, 0x0F87, 230, 0x0FC6, 0x0FC6, + 220, 0x1037, 0x1037, 7, 0x1039, 0x103A, 9, 0x108D, 0x108D, 220, 0x135D, 0x135F, 230, + 0x1714, 0x1715, 9, 0x1734, 0x1734, 9, 0x17D2, 0x17D2, 9, 0x17DD, 0x17DD, 230, 0x18A9, + 0x18A9, 228, 0x1939, 0x1939, 222, 0x193A, 0x193A, 230, 0x193B, 0x193B, 220, 0x1A17, + 0x1A17, 230, 0x1A18, 0x1A18, 220, 0x1A60, 0x1A60, 9, 0x1A75, 0x1A7C, 230, 0x1A7F, 0x1A7F, + 220, 0x1AB0, 0x1AB4, 230, 0x1AB5, 0x1ABA, 220, 0x1ABB, 0x1ABC, 230, 0x1ABD, 0x1ABD, 220, + 0x1ABF, 0x1AC0, 220, 0x1AC1, 0x1AC2, 230, 0x1AC3, 0x1AC4, 220, 0x1AC5, 0x1AC9, 230, + 0x1ACA, 0x1ACA, 220, 0x1ACB, 0x1ACE, 230, 0x1B34, 0x1B34, 7, 0x1B44, 0x1B44, 9, 0x1B6B, + 0x1B6B, 230, 0x1B6C, 0x1B6C, 220, 0x1B6D, 0x1B73, 230, 0x1BAA, 0x1BAB, 9, 0x1BE6, 0x1BE6, + 7, 0x1BF2, 0x1BF3, 9, 0x1C37, 0x1C37, 7, 0x1CD0, 0x1CD2, 230, 0x1CD4, 0x1CD4, 1, 0x1CD5, + 0x1CD9, 220, 0x1CDA, 0x1CDB, 230, 0x1CDC, 0x1CDF, 220, 0x1CE0, 0x1CE0, 230, 0x1CE2, + 0x1CE8, 1, 0x1CED, 0x1CED, 220, 0x1CF4, 0x1CF4, 230, 0x1CF8, 0x1CF9, 230, 0x1DC0, 0x1DC1, + 230, 0x1DC2, 0x1DC2, 220, 0x1DC3, 0x1DC9, 230, 0x1DCA, 0x1DCA, 220, 0x1DCB, 0x1DCC, 230, + 0x1DCD, 0x1DCD, 234, 0x1DCE, 0x1DCE, 214, 0x1DCF, 0x1DCF, 220, 0x1DD0, 0x1DD0, 202, + 0x1DD1, 0x1DF5, 230, 0x1DF6, 0x1DF6, 232, 0x1DF7, 0x1DF8, 228, 0x1DF9, 0x1DF9, 220, + 0x1DFA, 0x1DFA, 218, 0x1DFB, 0x1DFB, 230, 0x1DFC, 0x1DFC, 233, 0x1DFD, 0x1DFD, 220, + 0x1DFE, 0x1DFE, 230, 0x1DFF, 0x1DFF, 220, 0x20D0, 0x20D1, 230, 0x20D2, 0x20D3, 1, 0x20D4, + 0x20D7, 230, 0x20D8, 0x20DA, 1, 0x20DB, 0x20DC, 230, 0x20E1, 0x20E1, 230, 0x20E5, 0x20E6, + 1, 0x20E7, 0x20E7, 230, 0x20E8, 0x20E8, 220, 0x20E9, 0x20E9, 230, 0x20EA, 0x20EB, 1, + 0x20EC, 0x20EF, 220, 0x20F0, 0x20F0, 230, 0x2CEF, 0x2CF1, 230, 0x2D7F, 0x2D7F, 9, 0x2DE0, + 0x2DFF, 230, 0x302A, 0x302A, 218, 0x302B, 0x302B, 228, 0x302C, 0x302C, 232, 0x302D, + 0x302D, 222, 0x302E, 0x302F, 224, 0x3099, 0x309A, 8, 0xA66F, 0xA66F, 230, 0xA674, 0xA67D, + 230, 0xA69E, 0xA69F, 230, 0xA6F0, 0xA6F1, 230, 0xA806, 0xA806, 9, 0xA82C, 0xA82C, 9, + 0xA8C4, 0xA8C4, 9, 0xA8E0, 0xA8F1, 230, 0xA92B, 0xA92D, 220, 0xA953, 0xA953, 9, 0xA9B3, + 0xA9B3, 7, 0xA9C0, 0xA9C0, 9, 0xAAB0, 0xAAB0, 230, 0xAAB2, 0xAAB3, 230, 0xAAB4, 0xAAB4, + 220, 0xAAB7, 0xAAB8, 230, 0xAABE, 0xAABF, 230, 0xAAC1, 0xAAC1, 230, 0xAAF6, 0xAAF6, 9, + 0xABED, 0xABED, 9, 0xFB1E, 0xFB1E, 26, 0xFE20, 0xFE26, 230, 0xFE27, 0xFE2D, 220, 0xFE2E, + 0xFE2F, 230, 0x101FD, 0x101FD, 220, 0x102E0, 0x102E0, 220, 0x10376, 0x1037A, 230, 0x10A0D, + 0x10A0D, 220, 0x10A0F, 0x10A0F, 230, 0x10A38, 0x10A38, 230, 0x10A39, 0x10A39, 1, 0x10A3A, + 0x10A3A, 220, 0x10A3F, 0x10A3F, 9, 0x10AE5, 0x10AE5, 230, 0x10AE6, 0x10AE6, 220, 0x10D24, + 0x10D27, 230, 0x10EAB, 0x10EAC, 230, 0x10EFD, 0x10EFF, 220, 0x10F46, 0x10F47, 220, + 0x10F48, 0x10F4A, 230, 0x10F4B, 0x10F4B, 220, 0x10F4C, 0x10F4C, 230, 0x10F4D, 0x10F50, + 220, 0x10F82, 0x10F82, 230, 0x10F83, 0x10F83, 220, 0x10F84, 0x10F84, 230, 0x10F85, + 0x10F85, 220, 0x11046, 0x11046, 9, 0x11070, 0x11070, 9, 0x1107F, 0x1107F, 9, 0x110B9, + 0x110B9, 9, 0x110BA, 0x110BA, 7, 0x11100, 0x11102, 230, 0x11133, 0x11134, 9, 0x11173, + 0x11173, 7, 0x111C0, 0x111C0, 9, 0x111CA, 0x111CA, 7, 0x11235, 0x11235, 9, 0x11236, + 0x11236, 7, 0x112E9, 0x112E9, 7, 0x112EA, 0x112EA, 9, 0x1133B, 0x1133C, 7, 0x1134D, + 0x1134D, 9, 0x11366, 0x1136C, 230, 0x11370, 0x11374, 230, 0x11442, 0x11442, 9, 0x11446, + 0x11446, 7, 0x1145E, 0x1145E, 230, 0x114C2, 0x114C2, 9, 0x114C3, 0x114C3, 7, 0x115BF, + 0x115BF, 9, 0x115C0, 0x115C0, 7, 0x1163F, 0x1163F, 9, 0x116B6, 0x116B6, 9, 0x116B7, + 0x116B7, 7, 0x1172B, 0x1172B, 9, 0x11839, 0x11839, 9, 0x1183A, 0x1183A, 7, 0x1193D, + 0x1193E, 9, 0x11943, 0x11943, 7, 0x119E0, 0x119E0, 9, 0x11A34, 0x11A34, 9, 0x11A47, + 0x11A47, 9, 0x11A99, 0x11A99, 9, 0x11C3F, 0x11C3F, 9, 0x11D42, 0x11D42, 7, 0x11D44, + 0x11D45, 9, 0x11D97, 0x11D97, 9, 0x11F41, 0x11F42, 9, 0x16AF0, 0x16AF4, 1, 0x16B30, + 0x16B36, 230, 0x16FF0, 0x16FF1, 6, 0x1BC9E, 0x1BC9E, 1, 0x1D165, 0x1D166, 216, 0x1D167, + 0x1D169, 1, 0x1D16D, 0x1D16D, 226, 0x1D16E, 0x1D172, 216, 0x1D17B, 0x1D182, 220, 0x1D185, + 0x1D189, 230, 0x1D18A, 0x1D18B, 220, 0x1D1AA, 0x1D1AD, 230, 0x1D242, 0x1D244, 230, + 0x1E000, 0x1E006, 230, 0x1E008, 0x1E018, 230, 0x1E01B, 0x1E021, 230, 0x1E023, 0x1E024, + 230, 0x1E026, 0x1E02A, 230, 0x1E08F, 0x1E08F, 230, 0x1E130, 0x1E136, 230, 0x1E2AE, + 0x1E2AE, 230, 0x1E2EC, 0x1E2EF, 230, 0x1E4EC, 0x1E4ED, 232, 0x1E4EE, 0x1E4EE, 220, + 0x1E4EF, 0x1E4EF, 230, 0x1E8D0, 0x1E8D6, 220, 0x1E944, 0x1E949, 230, 0x1E94A, 0x1E94A, 7} + + @doc "The Unicode version of the tables." + @spec version() :: String.t() + def version, do: "15.0.0" + + @doc "Code points per table, as the generator counted them." + @spec counts() :: %{atom() => non_neg_integer()} + def counts, + do: %{ + cn: 825_345, + cf: 170, + qc_no: 1120, + qc_maybe: 111, + ccc_nonzero: 922 + } + + @doc "General_Category is Cn (unassigned, noncharacters included)." + @spec unassigned?(non_neg_integer()) :: boolean() + def unassigned?(cp), do: in_ranges?(@cn, cp) + + @doc "General_Category is Cf (format controls)." + @spec format_control?(non_neg_integer()) :: boolean() + def format_control?(cp), do: in_ranges?(@cf, cp) + + @doc "NFC_Quick_Check: `:yes`, `:no` or `:maybe`." + @spec nfc_qc(non_neg_integer()) :: :yes | :no | :maybe + def nfc_qc(cp) do + cond do + in_ranges?(@qc_no, cp) -> :no + in_ranges?(@qc_maybe, cp) -> :maybe + true -> :yes + end + end + + @doc "The canonical combining class." + @spec ccc(non_neg_integer()) :: 0..254 + def ccc(cp) do + case find(@ccc, 3, cp) do + nil -> 0 + i -> elem(@ccc, i * 3 + 2) + end + end + + defp in_ranges?(table, cp), do: find(table, 2, cp) != nil + + # The index of the range holding `cp` in a flat tuple of `width`-wide records sorted by their + # first element, or nil. + defp find(table, width, cp), do: search(table, width, cp, 0, div(tuple_size(table), width) - 1) + + defp search(_table, _width, _cp, lo, hi) when lo > hi, do: nil + + defp search(table, width, cp, lo, hi) do + mid = div(lo + hi, 2) + first = elem(table, mid * width) + last = elem(table, mid * width + 1) + + cond do + cp < first -> search(table, width, cp, lo, mid - 1) + cp > last -> search(table, width, cp, mid + 1, hi) + true -> mid + end + end +end diff --git a/lib/trinity/receipts/v4.ex b/lib/trinity/receipts/v4.ex index 92e22d7..68ef6d5 100644 --- a/lib/trinity/receipts/v4.ex +++ b/lib/trinity/receipts/v4.ex @@ -371,13 +371,12 @@ defmodule Trinity.Receipts.V4 do defp attachment(_ref, nil, field, seq), do: {:foreign_bytes_missing, seq, field} - defp attachment(%{0 => digest, 1 => length}, b64, field, seq) do - case Base.decode64(b64) do - {:ok, bytes} when byte_size(bytes) == length -> - if Digest.matches?(digest, bytes), do: nil, else: {:foreign_digest_mismatch, seq, field} - - _ -> - {:foreign_digest_mismatch, seq, field} + defp attachment(ref, b64, field, seq) do + with {:ok, bytes} <- Base.decode64(b64), + :ok <- Digest.attachment(ref, bytes) do + nil + else + _ -> {:foreign_digest_mismatch, seq, field} end end diff --git a/scripts/n6_vectors.exs b/scripts/n6_vectors.exs index 15394c9..2bace07 100644 --- a/scripts/n6_vectors.exs +++ b/scripts/n6_vectors.exs @@ -320,8 +320,10 @@ defmodule N6Vectors do {"tag_1_datetime", "epoch", "c1 1a 51 4b 67 b0", "tag_forbidden", a}, {"tag_1_datetime", "nested_in_array", "81 c1 1a 51 4b 67 b0", "tag_forbidden", "hand"}, {"tag_cose_sign1", "tag_18", "d2 80", "tag_forbidden", "hand: COSE_Sign1's tag inside N6 bytes"}, - {"text_nfd", "e_combining_acute", "63 65 cc 81", "not_nfc", "hand: U+0065 U+0301"}, - {"text_nfd", "nested_in_map_value", "a1 01 63 65 cc 81", "not_nfc", "hand"}, + # Slice 142: U+0301 is NFC_QC Maybe, so the decomposed form is `nfc_maybe` (draft v0.1-r7 + # section 10.4). + {"text_nfd", "e_combining_acute", "63 65 cc 81", "nfc_maybe", "hand: U+0065 U+0301"}, + {"text_nfd", "nested_in_map_value", "a1 01 63 65 cc 81", "nfc_maybe", "hand"}, {"text_bad_utf8", "lone_ff", "62 61 ff", "invalid_utf8", "hand"}, {"text_bad_utf8", "truncated_sequence", "62 61 c3", "invalid_utf8", "hand"}, {"text_overlong_utf8", "slash", "62 c0 af", "invalid_utf8", "hand: '/' in two bytes"}, @@ -406,8 +408,9 @@ defmodule N6Vectors do {"unknown_schema", "reserved_block_0x90", "a2 00 1a 90 00 00 00 10 61 78", "unknown_schema"}, {"unknown_schema", "trinity_block_unissued", "a2 00 1a 83 00 ff 01 10 61 78", "unknown_schema"}, {"unknown_key", "key_19", "a3 00 01 10 61 78 13 00", "unknown_key"}, - {"required_key_missing", "no_name", "a1 00 01", "required_key_missing"}, - {"wrong_type", "name_as_uint", "a2 00 01 10 01", "wrong_type"}, + # Slice 142: draft v0.1-r7's names for these two (section 3.6, B8). + {"field_missing", "no_name", "a1 00 01", "field_missing"}, + {"field_type_mismatch", "name_as_uint", "a2 00 01 10 01", "field_type_mismatch"}, {"set_unsorted", "2_then_1", "a3 00 01 10 61 78 12 82 02 01", "set_not_canonical"}, {"set_unsorted", "longer_head_first", "a3 00 01 10 61 78 12 82 18 18 17", "set_not_canonical"}, {"set_duplicate", "1_twice", "a3 00 01 10 61 78 12 82 01 01", "set_not_canonical"}, @@ -466,13 +469,15 @@ defmodule N6Vectors do {"label_5", "iv", h("a4") <> alg <> kid_pair <> h("05 41 00") <> typ, "header_label_forbidden"}, {"label_negative", "minus_1", h("a4") <> alg <> kid_pair <> typ <> h("20 00"), "header_label_forbidden"}, {"label_hash_envelope", "label_258", h("a4") <> alg <> kid_pair <> typ <> h("19 01 02 38 2a"), "header_label_forbidden"}, - {"alg_missing", "no_label_1", h("a2") <> kid_pair <> typ, "header_not_protected"}, - {"kid_missing", "no_label_4", h("a2") <> alg <> typ, "header_not_protected"}, - {"typ_missing", "no_label_16", h("a2") <> alg <> kid_pair, "header_not_protected"}, + # Slice 142: draft v0.1-r7 H3 and H4 name a missing label `header_missing` and a `kid` of the + # wrong shape `header_value_invalid`. + {"alg_missing", "no_label_1", h("a2") <> kid_pair <> typ, "header_missing"}, + {"kid_missing", "no_label_4", h("a2") <> alg <> typ, "header_missing"}, + {"typ_missing", "no_label_16", h("a2") <> alg <> kid_pair, "header_missing"}, {"typ_uint", "typ_1", h("a3") <> alg <> kid_pair <> h("10 01"), "typ_unexpected"}, {"typ_not_in_table", "other", h("a3") <> alg <> kid_pair <> typ_other, "typ_unexpected"}, {"cwt_without_scitt_typ", "cwt_on_plain_typ", h("a4") <> alg <> kid_pair <> cwt <> typ, "header_label_forbidden"}, - {"kid_wrong_size", "kid_32", h("a3") <> alg <> h("04 58 20") <> binary_part(kid, 0, 32) <> typ, "kid_not_thumbprint"}, + {"kid_wrong_size", "kid_32", h("a3") <> alg <> h("04 58 20") <> binary_part(kid, 0, 32) <> typ, "header_value_invalid"}, {"header_noncanonical", "typ_before_kid", h("a3") <> alg <> typ <> kid_pair, "header_noncanonical"}, {"header_noncanonical", "nonminimal_label", h("a3 18 01 38 31") <> kid_pair <> typ, "header_noncanonical"}, {"header_noncanonical", "trailing_byte", h("a3") <> alg <> kid_pair <> typ <> h("00"), "header_noncanonical"} @@ -587,7 +592,7 @@ defmodule N6Vectors do {"digest_alg_not_permitted", "sha256", h("82 2f 58 20") <> d32, "digest_alg_not_permitted"}, {"digest_alg_not_permitted", "shake256", h("82 38 2c 58 40") <> :binary.copy(<<0xAB>>, 64), "digest_alg_not_permitted"}, {"digest_length_mismatch", "sha384_32_bytes", h("82 38 2a 58 20") <> d32, "digest_length_mismatch"}, - {"digest_not_array", "bstr", h("58 30") <> d48, "digest_malformed"} + {"digest_not_array", "bstr", h("58 30") <> d48, "field_type_mismatch"} ] end diff --git a/scripts/unicode15_tables.exs b/scripts/unicode15_tables.exs new file mode 100644 index 0000000..cf86981 --- /dev/null +++ b/scripts/unicode15_tables.exs @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Writes `lib/trinity/n6/unicode15.ex`, the four tables the N6 text rule reads (slice 142; N6 draft +# v0.1-r7 section 2.4), from the Unicode 15.0.0 data files in the directory given: +# +# elixir scripts/unicode15_tables.exs lib/trinity/n6/unicode15.ex +# +# The directory holds the three files as published at https://www.unicode.org/Public/15.0.0/ucd/ +# (DerivedGeneralCategory.txt is under extracted/ there). Each is checked against the SHA-256 below +# before it is read, so the tables can only come from those bytes. The runtime's own Unicode data is +# never consulted: the profile pins 15.0.0 because runtimes ship different versions. +# +# The output is formatted here, so running the script twice writes the same bytes and `mix format` +# leaves the file alone. + +defmodule Unicode15Tables do + @sources [ + {"DerivedGeneralCategory.txt", "fe29a45c0882500e591140aaa5c4f5067e6a5d746806148af34400c48b9c06f9"}, + {"DerivedNormalizationProps.txt", + "d5687a48c95c7d6e1ec59cb29c0f2e8b052018eb069a4371b7368d0561e12a29"}, + {"UnicodeData.txt", "806e9aed65037197f1ec85e12be6e8cd870fc5608b4de0fffd990f689f376a73"} + ] + + def main([dir, out]) do + files = + Map.new(@sources, fn {name, sha} -> + bytes = File.read!(Path.join(dir, name)) + got = :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) + got == sha || raise "#{name}: sha256 #{got}, expected #{sha}" + {name, bytes} + end) + + gc = files["DerivedGeneralCategory.txt"] + qc = files["DerivedNormalizationProps.txt"] + + tables = %{ + cn: merge(property(gc, &(&1 == ["Cn"]))), + cf: merge(property(gc, &(&1 == ["Cf"]))), + qc_no: merge(property(qc, &(&1 == ["NFC_QC", "N"]))), + qc_maybe: merge(property(qc, &(&1 == ["NFC_QC", "M"]))), + ccc: ccc(files["UnicodeData.txt"]) + } + + File.write!(out, source(tables)) + end + + # `{first, last}` for every line whose fields after the code point match. + defp property(text, match?) do + for line <- String.split(text, "\n"), + [data | _] = String.split(line, "#", parts: 2), + [cps | fields] = data |> String.split(";") |> Enum.map(&String.trim/1), + cps != "", + match?.(fields) do + case String.split(cps, "..") do + [a] -> {hex(a), hex(a)} + [a, b] -> {hex(a), hex(b)} + end + end + end + + # Adjacent and overlapping ranges joined, sorted by first code point. + defp merge(ranges) do + ranges + |> Enum.sort() + |> Enum.reduce([], fn + {a, b}, [{pa, pb} | rest] when a <= pb + 1 -> [{pa, max(b, pb)} | rest] + r, acc -> [r | acc] + end) + |> Enum.reverse() + end + + # `{first, last, ccc}` for every run of consecutive code points with the same non-zero class. + # UnicodeData.txt lists every code point with a non-zero class on its own line. + defp ccc(text) do + text + |> String.split("\n", trim: true) + |> Enum.map(&String.split(&1, ";")) + |> Enum.flat_map(fn [cp, _name, _gc, ccc | _] -> + if ccc == "0", do: [], else: [{hex(cp), String.to_integer(ccc)}] + end) + |> Enum.sort() + |> Enum.reduce([], fn + {cp, c}, [{a, b, c} | rest] when cp == b + 1 -> [{a, cp, c} | rest] + {cp, c}, acc -> [{cp, cp, c} | acc] + end) + |> Enum.reverse() + end + + defp hex(s), do: String.to_integer(s, 16) + + defp count(ranges), do: Enum.reduce(ranges, 0, fn r, n -> n + elem(r, 1) - elem(r, 0) + 1 end) + + # The tuples are flat, so the lookup is one binary search over `elem/2`. Code points are written + # in hex, as Unicode writes them; a combining class (the third element of a `ccc` record) in + # decimal. + defp flat(ranges, width \\ 2) do + items = + ranges + |> Enum.flat_map(&Tuple.to_list/1) + |> Enum.with_index() + |> Enum.map_join(", ", fn {n, i} -> + if rem(i, width) == 2, do: Integer.to_string(n), else: hex_literal(n) + end) + + "{" <> items <> "}" + end + + defp hex_literal(n), do: "0x" <> String.pad_leading(Integer.to_string(n, 16), 4, "0") + + defp source(t) do + sources = Enum.map_join(@sources, "\n", fn {name, sha} -> " * `#{name}`: `#{sha}`" end) + + """ + # SPDX-FileCopyrightText: Sudo Apt Holdings LLC + # SPDX-FileCopyrightText: Unicode, Inc. + # SPDX-License-Identifier: Apache-2.0 AND Unicode-3.0 + # + # Generated by scripts/unicode15_tables.exs from the Unicode 15.0.0 data files. Do not edit. + defmodule Trinity.N6.Unicode15 do + @moduledoc \"\"\" + The four Unicode 15.0.0 tables the N6 text rule reads (N6 draft v0.1-r7 section 2.4; slice + 142): General_Category Cn and Cf, the canonical combining class, and NFC_Quick_Check No and + Maybe. Generated from these files, checked by SHA-256 before they were read: + + #{sources} + + The runtime's Unicode data is never consulted, because the profile pins one version and + runtimes ship others. Raising the version is a profile change. + \"\"\" + + @cn #{flat(t.cn)} + @cf #{flat(t.cf)} + @qc_no #{flat(t.qc_no)} + @qc_maybe #{flat(t.qc_maybe)} + @ccc #{flat(t.ccc, 3)} + + @doc "The Unicode version of the tables." + @spec version() :: String.t() + def version, do: "15.0.0" + + @doc "Code points per table, as the generator counted them." + @spec counts() :: %{atom() => non_neg_integer()} + def counts, + do: %{ + cn: #{count(t.cn)}, + cf: #{count(t.cf)}, + qc_no: #{count(t.qc_no)}, + qc_maybe: #{count(t.qc_maybe)}, + ccc_nonzero: #{count(t.ccc)} + } + + @doc "General_Category is Cn (unassigned, noncharacters included)." + @spec unassigned?(non_neg_integer()) :: boolean() + def unassigned?(cp), do: in_ranges?(@cn, cp) + + @doc "General_Category is Cf (format controls)." + @spec format_control?(non_neg_integer()) :: boolean() + def format_control?(cp), do: in_ranges?(@cf, cp) + + @doc "NFC_Quick_Check: `:yes`, `:no` or `:maybe`." + @spec nfc_qc(non_neg_integer()) :: :yes | :no | :maybe + def nfc_qc(cp) do + cond do + in_ranges?(@qc_no, cp) -> :no + in_ranges?(@qc_maybe, cp) -> :maybe + true -> :yes + end + end + + @doc "The canonical combining class." + @spec ccc(non_neg_integer()) :: 0..254 + def ccc(cp) do + case find(@ccc, 3, cp) do + nil -> 0 + i -> elem(@ccc, i * 3 + 2) + end + end + + defp in_ranges?(table, cp), do: find(table, 2, cp) != nil + + # The index of the range holding `cp` in a flat tuple of `width`-wide records sorted by their + # first element, or nil. + defp find(table, width, cp), do: search(table, width, cp, 0, div(tuple_size(table), width) - 1) + + defp search(_table, _width, _cp, lo, hi) when lo > hi, do: nil + + defp search(table, width, cp, lo, hi) do + mid = div(lo + hi, 2) + first = elem(table, mid * width) + last = elem(table, mid * width + 1) + + cond do + cp < first -> search(table, width, cp, lo, mid - 1) + cp > last -> search(table, width, cp, mid + 1, hi) + true -> mid + end + end + end + """ + |> Code.format_string!() + |> IO.iodata_to_binary() + |> Kernel.<>("\n") + end +end + +Unicode15Tables.main(System.argv()) diff --git a/test/fixtures/n6/README.md b/test/fixtures/n6/README.md index a16f86a..ef63655 100644 --- a/test/fixtures/n6/README.md +++ b/test/fixtures/n6/README.md @@ -44,7 +44,7 @@ profile's own vectors. These are candidate input, not the published set. | `tag_bignum_small` | `tag_forbidden` | | `tag_1_datetime` | `tag_forbidden` | | `tag_cose_sign1` | `tag_forbidden` | -| `text_nfd` | `not_nfc` | +| `text_nfd` | `nfc_maybe` | | `text_bad_utf8` | `invalid_utf8` | | `text_overlong_utf8` | `invalid_utf8` | | `text_surrogate` | `invalid_utf8` | @@ -72,8 +72,8 @@ profile's own vectors. These are candidate input, not the published set. | `test_schema_outside_test_mode` | `test_schema_outside_test_mode` | | `unknown_schema` | `unknown_schema` | | `unknown_key` | `unknown_key` | -| `required_key_missing` | `required_key_missing` | -| `wrong_type` | `wrong_type` | +| `field_missing` | `field_missing` | +| `field_type_mismatch` | `field_type_mismatch` | | `set_unsorted` | `set_not_canonical` | | `set_duplicate` | `set_not_canonical` | | `not_a_map` | `body_not_map` | @@ -89,13 +89,13 @@ profile's own vectors. These are candidate input, not the published set. | `label_5` | `header_label_forbidden` | | `label_negative` | `header_label_forbidden` | | `label_hash_envelope` | `header_label_forbidden` | -| `alg_missing` | `header_not_protected` | -| `kid_missing` | `header_not_protected` | -| `typ_missing` | `header_not_protected` | +| `alg_missing` | `header_missing` | +| `kid_missing` | `header_missing` | +| `typ_missing` | `header_missing` | | `typ_uint` | `typ_unexpected` | | `typ_not_in_table` | `typ_unexpected` | | `cwt_without_scitt_typ` | `header_label_forbidden` | -| `kid_wrong_size` | `kid_not_thumbprint` | +| `kid_wrong_size` | `header_value_invalid` | | `header_noncanonical` | `header_noncanonical` | ## Digest defects @@ -104,7 +104,7 @@ profile's own vectors. These are candidate input, not the published set. | -- | -- | | `digest_alg_not_permitted` | `digest_alg_not_permitted` | | `digest_length_mismatch` | `digest_length_mismatch` | -| `digest_not_array` | `digest_malformed` | +| `digest_not_array` | `field_type_mismatch` | ## Single-signer carriers (`cose/sign1/`) diff --git a/test/fixtures/n6/SHA384SUMS b/test/fixtures/n6/SHA384SUMS index baad0d4..12a2753 100644 --- a/test/fixtures/n6/SHA384SUMS +++ b/test/fixtures/n6/SHA384SUMS @@ -1,4 +1,4 @@ -06e6122290266259b43172c29fcc1a705408502be80b24161c19996c764fe5f4eba59b195f3e488a09df1bb11d2253fc README.md +209663b006912666cb71b51c3964d091a2ca6815846887153fcaa77203c59426c5568b5a463f8f450f2e38f0267a5289 README.md 12541e60a919c5350898baade01540aa3ded55897b594afc54501031c55571b9172cae3bff8eb40f5d20e7b2f6c47e9b VERSION e80ceee3df61c92398d9f44ea69efc5ca52602afd424d63ec799735a3466f836ff7208cc45a21918b4ea314e8beb77a8 body/negative/bool_schema_version/true.cbor c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d1380539e54ba616ca2 body/negative/bool_schema_version/true.expect @@ -6,14 +6,16 @@ c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d13 ab82f7cf2e7c5ef2a3e5a4a55dc7576a10178226ce45ea015c90abf19630fda2fa3e154fb17b7925a685cd2ee9d698c4 body/negative/carrier_schema_key0/envelope_schema.expect 86d7f0a9995f461034806461e96f1038289a5375e38c55477afcea8305108a2fc412861a86daf11a5cd810b4a42a8442 body/negative/carrier_schema_key0/top_of_range.cbor ab82f7cf2e7c5ef2a3e5a4a55dc7576a10178226ce45ea015c90abf19630fda2fa3e154fb17b7925a685cd2ee9d698c4 body/negative/carrier_schema_key0/top_of_range.expect +3c0b3142d28945e9a2c5221e2da05b4b5441f8b008bafeae9472e0ab7e2fc3a2bbf989497c7fa76a039c89229880fb8d body/negative/field_missing/no_name.cbor +e777886317795d036c52a0abcacfafeaca69e0d0aad699ec54263d4520877a67e11b5927b5257609d9b889f58993f8a7 body/negative/field_missing/no_name.expect +bd260698c7910af7c315ba9d70987b143cd0da2988199d636f4640374a9af29bc5f5bf1a423d0dc32106d0b45e1bc9a7 body/negative/field_type_mismatch/name_as_uint.cbor +9e14cfaea3cd656bb2fb87eba96afe72378af9c4be6c537b69ba24dbcf2aa28c7e8908aa9a4ef924aa4b91926d3f6339 body/negative/field_type_mismatch/name_as_uint.expect 27250788571c3c27118470ac1ba1def915373792795ca992afbf21552a707648c0f6445d86cc408a51ef2e39f9356874 body/negative/negative_schema_version/minus_1.cbor c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d1380539e54ba616ca2 body/negative/negative_schema_version/minus_1.expect d289746bc7007ed6ce6010aead36ed88fe3e62da386cf4208474682a58960334c39194545b18cf6f47fb35fad086786c body/negative/no_schema_version/no_key_0.cbor 9e40ecb3c6dc2b3808ea3787fd07a31890fa41b2d336bdeb922b0b6c79543e5dedeba148a9671ba0ebb99e1f104b92a0 body/negative/no_schema_version/no_key_0.expect 9e4ad6b6d84d873013c076e0dcbe7de5f369f5cbb1c865100a5d776d1efcc2fe76c07a0e098490166b3621e057d70533 body/negative/not_a_map/array.cbor 1fd35304f4c0c1b8dc89a0454b5fffaa2c7b1333eac63c57db4d0d16050e4214a6d1083c96028a106148ec0a367268df body/negative/not_a_map/array.expect -3c0b3142d28945e9a2c5221e2da05b4b5441f8b008bafeae9472e0ab7e2fc3a2bbf989497c7fa76a039c89229880fb8d body/negative/required_key_missing/no_name.cbor -36fb156c57b913cd756338cd0f83e1a833ce3099c51e63df15be066ccfa2bd268fa75fcc04c47f86e2c52d027a818728 body/negative/required_key_missing/no_name.expect 7b5fcee12059ded8fa1afa22e204d3acb580c62aff8fe979dda2ae3bcba6316e0149bc60efec65444af40582143b4cab body/negative/set_duplicate/1_twice.cbor c5a6ab7e084812fa66f542f8d500f87b0d0493993c8bca83fd6332d2009016989e8cb83a93c4fae1d8b08a8eb616510d body/negative/set_duplicate/1_twice.expect b3cee3d09d5ababe25e142a385186d1a63c8b5d92ff7968845d3df5a7ba1f5e4f1ea311dfc6b269504d8e9e211642abc body/negative/set_unsorted/2_then_1.cbor @@ -30,8 +32,6 @@ e70a719269e3320781b92916a091ba22e4a6c2cb14627c2d400881bb04d55064a7c535e161263107 5fb66206c74a2ebffd03a7de0955f691fac9c364d38f2458ac327f0ad28704f4ca91ca726bc01d8793f5e455cdf8e93d body/negative/unknown_schema/trinity_block_unissued.expect 9184dbb9a6f907d6db5b91f417c74b539b7405b8d87e7d3fdd27f2b199b2aa5f99c3613b65df5c95f953debc727f8eaf body/negative/unknown_schema/version_2.cbor 5fb66206c74a2ebffd03a7de0955f691fac9c364d38f2458ac327f0ad28704f4ca91ca726bc01d8793f5e455cdf8e93d body/negative/unknown_schema/version_2.expect -bd260698c7910af7c315ba9d70987b143cd0da2988199d636f4640374a9af29bc5f5bf1a423d0dc32106d0b45e1bc9a7 body/negative/wrong_type/name_as_uint.cbor -a39dfa282986952f79d8226d3bf2f0bab2ffab497314e7fc2b765867eb123d30786b3593393989c6a34f999aa2fe6992 body/negative/wrong_type/name_as_uint.expect aee22769da6148114745f47be7e4042a761b84b0dd2e6f949b8f65e647a49d9e1bc85a52d591b55ec30c9a85aa451ddd body/positive/all_fields.cbor edef20d0f9adf95e7243d7f5b73741e21351e4be6f973a073724e609944a1fad8e47469d8a88090d502e166b00b63eeb body/positive/all_fields.desc.json 732fbb76961dfb6f65750004a59429857dea30894dcc9e4074e167aa127c6dd0dd92623140fdc237ede8369f3af68662 body/positive/all_fields.diag @@ -129,7 +129,7 @@ cbf9e2506dd0df652dd1e0789be6f5874cf495cfa80a2aa76529656bb651f4c8462dcb5cfb38a55b 4ed1f50a7398b4e70e7a9256f94a57a37a26cb7a536468c74c33d271294e6d36b6219f2e041e109b594342ac025576f8 digest/negative/digest_length_mismatch/sha384_32_bytes.cbor 69d71f30a77f6bc8c1adbee6bcfa3e87b34e7b9af23656c1fd56f7097775386cbd042a9d3a985840ec40155557b66320 digest/negative/digest_length_mismatch/sha384_32_bytes.expect 51f0dc1bfe44461f3d3c9aa38add80af8f504500249aecd59703d1cab03d743a6c798a1062ef6e4185041f83ac12884e digest/negative/digest_not_array/bstr.cbor -031fe6e767a613dac793cedd3092b6bbedf4cba029429b780680a13077667ac2c762080b6c16433a421bc2d5865b098f digest/negative/digest_not_array/bstr.expect +9e14cfaea3cd656bb2fb87eba96afe72378af9c4be6c537b69ba24dbcf2aa28c7e8908aa9a4ef924aa4b91926d3f6339 digest/negative/digest_not_array/bstr.expect 8180df2c9ca43ea9cfee65367c45a73365a5f4b1f3fd4f4af610cad9e9f9c00e6d6d25a5cb245ed710d0ed2a2b5446bc encoding/negative/bstr_key/h01.cbor c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d1380539e54ba616ca2 encoding/negative/bstr_key/h01.expect 877a1d9f501e49ed84faebba15c4db73e2f416e17444cdc1bae655fddd840bcf47fc2d4909375641d36e8d7f7b19b0a2 encoding/negative/duplicate_key/1_twice.cbor @@ -223,9 +223,9 @@ c4d31c9d4cb2589592da0ffa786e05164208c29412066593ebdc430a29b5ce131d977ffb0e559d05 5c7397cafd3d2fb3dc3a6799ea312f5da48a7a4e29032fe2e0f335affafd5df38813ee30419515a2e020ba5566117abd encoding/negative/text_key/top.cbor c4dc77dbb660d75895e69c0e6f666e941369265a051896b15bff30b763d73cb1ce7586a45c732d1380539e54ba616ca2 encoding/negative/text_key/top.expect 99e6b8bde6e3b5f69601a2b0221f070c863eac551712d53789e3481acb04d51b334935fa909cc12cec45a10a63b50edb encoding/negative/text_nfd/e_combining_acute.cbor -aed855ba540e02c0f0a9eb84e6213d57adf12994395f2bb6e7f80140d64c1b30d4791a7150168a7a95759462349f35ff encoding/negative/text_nfd/e_combining_acute.expect +b846be6c19457b69555571513eab1b0cc68096521fa908d22b4f99da67186c645c538ec6002c706dbbffd5256d7c9b1b encoding/negative/text_nfd/e_combining_acute.expect 4d911fc0d387268a5045d312a9fbed80bf9bb96593d3316b7c08d3befadbf3ef78646f7cb4bbdd3cd956d2918f402bed encoding/negative/text_nfd/nested_in_map_value.cbor -0310b08c808ce5bc9a53945e47a358542c55b4e06cd47443a246ecaad562db15a24ec8b77f93a12c36683078200b8c76 encoding/negative/text_nfd/nested_in_map_value.expect +ea33b1d7b4b16b5c069d720b5a3f937688c63d84270f253596b59a70b6723fa9b3e577eb2e3085653c30c2bb64d54f09 encoding/negative/text_nfd/nested_in_map_value.expect dcb09970c39ef25dce96726fbd0f74a3f24c8efb0952a8107b40be5c88cc6ddfe1fc5ce0a1dccd9e9d92f18b34880727 encoding/negative/text_overlong_utf8/slash.cbor b18f706318882bc7e16b912f629c8e81339ae33b3e7e929a9165bdaed4af0578df58f6773526c3ddc6a13f2d620f96c4 encoding/negative/text_overlong_utf8/slash.expect 0b3ceebc7e4e5f26eb52c04b3200156c121c5f4425695b2deec2935f4808f5a5f1376fbba2d890533216be589be85cde encoding/negative/text_surrogate/d800.cbor @@ -379,7 +379,7 @@ b35433a1e93042b7800388a8cdaacc0c4d687eaf2c732e97833d2fbfcf727e4842dcf11c79d12387 27017d84a5a12945ff935829a478dcde1386db298b93c1c490e84f62714d7c1b3c146639359e56c56f0d8bbbfd982262 encoding/positive/uint_max_u64.desc.json f116bf4ce82cb0cd46d91e5ff2442b3f527eae8affefcef0678d2e37980babed66a01689a4d2bc0fa36195601a83f02d encoding/positive/uint_max_u64.diag 38999b3fb39414528cb2bf618aa60979755e666a2a2358feb1796694ea50c50319c90b6cdc5d2e5085d7984e0e9153c6 header/negative/alg_missing/no_label_1.cbor -c9e09f8bf67b2857043cd7029d06f50b97a0f28946f1e8d7c4022d27b43262f5a389c1c07be090a5528051b3cdd0f879 header/negative/alg_missing/no_label_1.expect +2b65626e672465f67e17624f9d93d7cefd111b39bd2c630e4dd1f1dcd04fde80c3095659879374e89eb19b08569664bb header/negative/alg_missing/no_label_1.expect 78dffd69b0ef23c3c594da067e7de10004866506d28f3ff8059883f777e03deef713f1a0ebc9b1a51ea3daf8fea972ca header/negative/alg_not_mldsa87/eddsa.cbor 2cae37aab4a8bdaa238c2f9042125963bc7041226923d3bdc6448e90a421b6893269729c154024f8cc4bb9b28cd9b2ff header/negative/alg_not_mldsa87/eddsa.expect c92b7cc4a2efdc798e2f59f151791548544770be50c8049813d918bc217eb7acc9f11ce42cb873c8fab161bcf82819ab header/negative/alg_not_mldsa87/es384.cbor @@ -399,9 +399,9 @@ b3da2ed7fe90d8e0aaaf139e44bdc0eb2f73a17e6ca9e3bc6195eb00f367de530e4350787e13168c bedee35e886d43100940c40445fd69e0723deae6eaabad1a290978679a9dea928581f8e5ff97f4b6e4235eeeb1fb8794 header/negative/header_noncanonical/typ_before_kid.cbor b3da2ed7fe90d8e0aaaf139e44bdc0eb2f73a17e6ca9e3bc6195eb00f367de530e4350787e13168ca9476f57cbc74c21 header/negative/header_noncanonical/typ_before_kid.expect baa5c58aef7d99eef595d893549700b6996af49a0924a9b7742973f923f979acc5c24a6e4ad9ddf0b181bd8ad1997cbc header/negative/kid_missing/no_label_4.cbor -c9e09f8bf67b2857043cd7029d06f50b97a0f28946f1e8d7c4022d27b43262f5a389c1c07be090a5528051b3cdd0f879 header/negative/kid_missing/no_label_4.expect +2b65626e672465f67e17624f9d93d7cefd111b39bd2c630e4dd1f1dcd04fde80c3095659879374e89eb19b08569664bb header/negative/kid_missing/no_label_4.expect 9994e77c0c4ea9a251f19734bf72e8635b603ee5b3c622497384b67f48f6e559a9a26cb7225a66b8e087ed4cbbc5a869 header/negative/kid_wrong_size/kid_32.cbor -c0e55c7e23b1f3a1e5a57f2d31554a4d2974b97b62526b78b52515cec531269f84f3a740aa21dc30bc19a9039903ac98 header/negative/kid_wrong_size/kid_32.expect +5bf7697646ea7504c2c054c735850e29fe01ab8a9ba211f81db6cf6d391b603f53218bd7743e7958145cc3bbd602fcfd header/negative/kid_wrong_size/kid_32.expect 3a2638d24852775a96fb797693da94828c9650370723b568875321d65e0a9ea94d29f80148e016811aba8358fe7246fe header/negative/label_0/label_0.cbor de8993fcf43d057b9fb9c9e8228988221b3b1b278a9446bf6a8cd248cd91b1a1edcf2ff6dcd27620a7639260bb89b3e3 header/negative/label_0/label_0.expect c2c07dae5d498d19f870cd18232ce1b42bff11b0cb42aea7e5b8f8dbcce5f246693b83589322eac202e10dece1cea9cf header/negative/label_5/iv.cbor @@ -411,7 +411,7 @@ de8993fcf43d057b9fb9c9e8228988221b3b1b278a9446bf6a8cd248cd91b1a1edcf2ff6dcd27620 e9df93fb9e0bb188e5905912af1d29913465d8d07b67824b0d777cc48fb237ae910e20e5fba704a10ddba259953d2f3c header/negative/label_negative/minus_1.cbor de8993fcf43d057b9fb9c9e8228988221b3b1b278a9446bf6a8cd248cd91b1a1edcf2ff6dcd27620a7639260bb89b3e3 header/negative/label_negative/minus_1.expect 33542ee98e2606188f3fde1f8bb5b2bd82ba4f98704401500807456b3e2dbd5feddab6dd102c72b1e9f1cc87fa38df08 header/negative/typ_missing/no_label_16.cbor -c9e09f8bf67b2857043cd7029d06f50b97a0f28946f1e8d7c4022d27b43262f5a389c1c07be090a5528051b3cdd0f879 header/negative/typ_missing/no_label_16.expect +2b65626e672465f67e17624f9d93d7cefd111b39bd2c630e4dd1f1dcd04fde80c3095659879374e89eb19b08569664bb header/negative/typ_missing/no_label_16.expect f793ff73ad9315e95f55067c334dac8c0fe72463c58953108aaa18cb59aec15f751209b73031e5843ab69c73254a589a header/negative/typ_not_in_table/other.cbor c38c1bb721e5f1c4f62dd9b2a6db895af7350fbd905c5609ef068bc54b87f9453e80c26cb9574f86fe32c34dc5f4f88c header/negative/typ_not_in_table/other.expect 7c51407ae8602664a3dafd9ff25dab31045c4a517beed56e5711c18bf6ad024bf378f92cbad9bb89ec95d8a56b9868d1 header/negative/typ_uint/typ_1.cbor diff --git a/test/fixtures/n6/body/negative/required_key_missing/no_name.cbor b/test/fixtures/n6/body/negative/field_missing/no_name.cbor similarity index 100% rename from test/fixtures/n6/body/negative/required_key_missing/no_name.cbor rename to test/fixtures/n6/body/negative/field_missing/no_name.cbor diff --git a/test/fixtures/n6/body/negative/field_missing/no_name.expect b/test/fixtures/n6/body/negative/field_missing/no_name.expect new file mode 100644 index 0000000..7e90572 --- /dev/null +++ b/test/fixtures/n6/body/negative/field_missing/no_name.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"field_missing"} diff --git a/test/fixtures/n6/body/negative/wrong_type/name_as_uint.cbor b/test/fixtures/n6/body/negative/field_type_mismatch/name_as_uint.cbor similarity index 100% rename from test/fixtures/n6/body/negative/wrong_type/name_as_uint.cbor rename to test/fixtures/n6/body/negative/field_type_mismatch/name_as_uint.cbor diff --git a/test/fixtures/n6/body/negative/field_type_mismatch/name_as_uint.expect b/test/fixtures/n6/body/negative/field_type_mismatch/name_as_uint.expect new file mode 100644 index 0000000..58f1dac --- /dev/null +++ b/test/fixtures/n6/body/negative/field_type_mismatch/name_as_uint.expect @@ -0,0 +1 @@ +{"exit":1,"source":"hand","verdict_code":"field_type_mismatch"} diff --git a/test/fixtures/n6/body/negative/required_key_missing/no_name.expect b/test/fixtures/n6/body/negative/required_key_missing/no_name.expect deleted file mode 100644 index f57b350..0000000 --- a/test/fixtures/n6/body/negative/required_key_missing/no_name.expect +++ /dev/null @@ -1 +0,0 @@ -{"exit":1,"source":"hand","verdict_code":"required_key_missing"} diff --git a/test/fixtures/n6/body/negative/wrong_type/name_as_uint.expect b/test/fixtures/n6/body/negative/wrong_type/name_as_uint.expect deleted file mode 100644 index f5d138c..0000000 --- a/test/fixtures/n6/body/negative/wrong_type/name_as_uint.expect +++ /dev/null @@ -1 +0,0 @@ -{"exit":1,"source":"hand","verdict_code":"wrong_type"} diff --git a/test/fixtures/n6/digest/negative/digest_not_array/bstr.expect b/test/fixtures/n6/digest/negative/digest_not_array/bstr.expect index 415c13f..58f1dac 100644 --- a/test/fixtures/n6/digest/negative/digest_not_array/bstr.expect +++ b/test/fixtures/n6/digest/negative/digest_not_array/bstr.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"digest_malformed"} +{"exit":1,"source":"hand","verdict_code":"field_type_mismatch"} diff --git a/test/fixtures/n6/encoding/negative/text_nfd/e_combining_acute.expect b/test/fixtures/n6/encoding/negative/text_nfd/e_combining_acute.expect index aef4e85..2efab97 100644 --- a/test/fixtures/n6/encoding/negative/text_nfd/e_combining_acute.expect +++ b/test/fixtures/n6/encoding/negative/text_nfd/e_combining_acute.expect @@ -1 +1 @@ -{"exit":1,"source":"hand: U+0065 U+0301","verdict_code":"not_nfc"} +{"exit":1,"source":"hand: U+0065 U+0301","verdict_code":"nfc_maybe"} diff --git a/test/fixtures/n6/encoding/negative/text_nfd/nested_in_map_value.expect b/test/fixtures/n6/encoding/negative/text_nfd/nested_in_map_value.expect index 8a28774..926fa88 100644 --- a/test/fixtures/n6/encoding/negative/text_nfd/nested_in_map_value.expect +++ b/test/fixtures/n6/encoding/negative/text_nfd/nested_in_map_value.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"not_nfc"} +{"exit":1,"source":"hand","verdict_code":"nfc_maybe"} diff --git a/test/fixtures/n6/header/negative/alg_missing/no_label_1.expect b/test/fixtures/n6/header/negative/alg_missing/no_label_1.expect index 3822c49..ed4a2eb 100644 --- a/test/fixtures/n6/header/negative/alg_missing/no_label_1.expect +++ b/test/fixtures/n6/header/negative/alg_missing/no_label_1.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"header_not_protected"} +{"exit":1,"source":"hand","verdict_code":"header_missing"} diff --git a/test/fixtures/n6/header/negative/kid_missing/no_label_4.expect b/test/fixtures/n6/header/negative/kid_missing/no_label_4.expect index 3822c49..ed4a2eb 100644 --- a/test/fixtures/n6/header/negative/kid_missing/no_label_4.expect +++ b/test/fixtures/n6/header/negative/kid_missing/no_label_4.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"header_not_protected"} +{"exit":1,"source":"hand","verdict_code":"header_missing"} diff --git a/test/fixtures/n6/header/negative/kid_wrong_size/kid_32.expect b/test/fixtures/n6/header/negative/kid_wrong_size/kid_32.expect index df2e1a7..5c62b68 100644 --- a/test/fixtures/n6/header/negative/kid_wrong_size/kid_32.expect +++ b/test/fixtures/n6/header/negative/kid_wrong_size/kid_32.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"kid_not_thumbprint"} +{"exit":1,"source":"hand","verdict_code":"header_value_invalid"} diff --git a/test/fixtures/n6/header/negative/typ_missing/no_label_16.expect b/test/fixtures/n6/header/negative/typ_missing/no_label_16.expect index 3822c49..ed4a2eb 100644 --- a/test/fixtures/n6/header/negative/typ_missing/no_label_16.expect +++ b/test/fixtures/n6/header/negative/typ_missing/no_label_16.expect @@ -1 +1 @@ -{"exit":1,"source":"hand","verdict_code":"header_not_protected"} +{"exit":1,"source":"hand","verdict_code":"header_missing"} diff --git a/test/support/n6_r7_vectors.ex b/test/support/n6_r7_vectors.ex new file mode 100644 index 0000000..59fe0bf --- /dev/null +++ b/test/support/n6_r7_vectors.ex @@ -0,0 +1,248 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestSupport.N6R7Vectors do + @moduledoc """ + Reads a vector set in the layout of N6 draft v0.1-r7 section 10 (slice 142), and holds the test + profile of its section 10.3: the test schemas in block 0x80, their `typ` values and the three + test entry points. Every judgment goes through `Trinity.N6`, so a run is the codec's answer, not + this module's. + + `run/1` judges every vector of the five cut directories (encoding, body, header, thumbprint, + digest) and answers, per directory, the vectors that agree and the ones that do not. + """ + + alias Trinity.N6.{Body, CBOR, COSE, Digest} + + @issuer {:one_of, [{:tstr, 1, 255, :printable_ascii}, {:bstr, 1, 64}]} + + @test_body_fields %{ + 1 => {:issuer, @issuer, :required}, + 6 => {:id, {:bstr, 16, 32}, :optional}, + 7 => {:prev, :digest, :optional}, + 16 => {:count, {:int, 0, 1000}, :optional}, + 17 => {:level, {:enum, 3, true}, :optional}, + 18 => {:delta, {:int, -100, 100}, :optional}, + 19 => {:note, {:tstr, 0, 256, :any}, :optional}, + 20 => {:tags, {:set, {:int, 0, 0xFFFF_FFFF}}, :optional}, + 21 => {:blob, :bstr, :optional}, + 22 => {:cleared, {:nullable, :uint}, :optional}, + 23 => {:ref, :foreign_ref, :optional}, + 24 => + {:pair, + {:map, + %{1 => {:a, :uint, :required}, 2 => {:b, {:tstr, 1, 64, :printable_ascii}, :optional}}}, + :optional}, + 25 => {:flag, :bool, :optional} + } + + @test_pair_fields %{ + 1 => {:issuer, @issuer, :required}, + 9 => {:signer_ctr, {:tuple, [{:int, 0, 0xFFFF_FFFF}, :uint, :uint]}, :required}, + 10 => {:key_policy, :digest, :required} + } + + @typ_body "application/vnd.example.b80.test_body.v0+cose" + @typ_pair "application/vnd.example.b80.test_pair.v0+cose" + @typ_hash "application/vnd.example.b80.test_hash.v0+cose" + @typ_scitt "application/vnd.example.b80.test_scitt.v0+cose" + + @doc "The test schemas of r7 section 10.3." + def schemas do + [ + %{kind: "test_body", version: 0x8000_0101, typ: @typ_body, fields: @test_body_fields}, + %{kind: "test_pair", version: 0x8000_0201, typ: @typ_pair, fields: @test_pair_fields}, + %{kind: "test_scitt", version: 0x8000_0401, typ: @typ_scitt, fields: @test_body_fields} + ] + end + + @doc "The test profile table: every `typ` of r7 section 10.3." + def typ_table do + %{ + @typ_body => %{scitt: false, carrier: :single, signers: :single, schema: 0x8000_0101}, + @typ_pair => %{scitt: false, carrier: :envelope, signers: :pair, schema: 0x8000_0201}, + @typ_hash => %{scitt: false, carrier: :single, signers: :single, form: :hash}, + @typ_scitt => %{scitt: true, carrier: :single, signers: :single, schema: 0x8000_0401} + } + end + + @doc "The three test entry points." + def entry_points do + %{ + "test.nss.single" => %{algs: [-50], typs: [@typ_body, @typ_scitt, @typ_hash]}, + "test.commercial.ed25519.single" => %{algs: [-19], typs: [@typ_body]}, + "test.commercial.es384.single" => %{algs: [-51], typs: [@typ_body]} + } + end + + @doc "`VERSION`'s first line, and the SHA-384 of `SHA384SUMS` (r7 section 11)." + def pin(root) do + [version | _] = root |> Path.join("VERSION") |> File.read!() |> String.split("\n") + sums = root |> Path.join("SHA384SUMS") |> File.read!() + {version, :crypto.hash(:sha384, sums) |> Base.encode16(case: :lower)} + end + + @doc "Every file `SHA384SUMS` lists whose digest does not match, and every file it does not list." + def sums_defects(root) do + listed = + root + |> Path.join("SHA384SUMS") + |> File.read!() + |> String.split("\n", trim: true) + |> Map.new(fn line -> + [hex, path] = String.split(line, " ", parts: 2) + {path, hex} + end) + + on_disk = + for path <- Path.wildcard(Path.join(root, "**/*")), + File.regular?(path), + rel = Path.relative_to(path, root), + rel != "SHA384SUMS", + do: rel + + wrong = + for {rel, hex} <- listed, + :crypto.hash(:sha384, File.read!(Path.join(root, rel))) |> Base.encode16(case: :lower) != + hex, + do: {:digest, rel} + + wrong ++ for(rel <- on_disk, not is_map_key(listed, rel), do: {:unlisted, rel}) + end + + @doc "Judges every vector of the cut directories: `%{dir => {agree, [disagreement]}}`." + def run(root) do + Map.new(~w(encoding body header thumbprint digest), fn dir -> + results = judge(dir, Path.join(root, dir)) + wrong = for {id, want, got} <- results, want != got, do: {id, want, got} + {dir, {length(results) - length(wrong), wrong}} + end) + end + + defp judge("encoding", dir) do + positives(dir, fn base, ctx -> + bytes = File.read!(base <> ".cbor") + value = base |> Kernel.<>(".desc.json") |> File.read!() |> JSON.decode!() |> value() + ok?(CBOR.encode(value) == {:ok, bytes} and CBOR.decode(bytes) == {:ok, value}, ctx) + end) ++ negatives(dir, &code(CBOR.decode(File.read!(&1 <> ".cbor")))) + end + + defp judge("body", dir) do + body = fn base -> + ctx = ctx(base) + opts = [test_mode: Map.get(ctx, "test_mode", false), typ: Map.get(ctx, "typ")] + code(Body.decode(File.read!(base <> ".cbor"), :any, schemas(), opts)) + end + + positives(dir, fn base, _ctx -> body.(base) end) ++ negatives(dir, body) + end + + defp judge("header", dir) do + header = fn base -> + entry = Map.fetch!(entry_points(), Map.fetch!(ctx(base), "entry")) + code(COSE.decode_protected(File.read!(base <> ".cbor"), typ_table(), entry)) + end + + positives(dir, fn base, _ctx -> header.(base) end) ++ negatives(dir, header) + end + + defp judge("thumbprint", dir) do + positives = + for meta <- Path.wildcard(Path.join(dir, "*.meta.json")) |> Enum.sort() do + base = String.replace_suffix(meta, ".meta.json", "") + {kty, crv, alg, pub} = key(base) + + got = + with :ok <- COSE.check_key(kty, crv, alg, pub) do + kid = File.read!(base <> ".kid") + input = File.read!(base <> ".input") + + ok?(COSE.thumbprint(alg, pub) == kid and :crypto.hash(:sha384, input) == kid, nil) + end + + {Path.basename(base), :ok, got} + end + + positives ++ + negatives(dir, fn base -> + {kty, crv, alg, pub} = key(base) + COSE.check_key(kty, crv, alg, pub) + end) + end + + defp judge("digest", dir) do + positives = + for meta <- Path.wildcard(Path.join(dir, "*.meta.json")) |> Enum.sort() do + base = String.replace_suffix(meta, ".meta.json", "") + m = meta |> File.read!() |> JSON.decode!() + ref = Digest.foreign_ref(File.read!(base <> ".in"), m["media_type"], m["locator"]) + {Path.basename(base), :ok, ok?(CBOR.encode!(ref) == File.read!(base <> ".ref.cbor"), nil)} + end + + positives ++ + negatives(dir, fn base -> + attached = if File.exists?(base <> ".in"), do: File.read!(base <> ".in") + + with {:ok, ref} <- CBOR.decode(File.read!(base <> ".ref.cbor")) |> code(), + :ok <- Body.check_value(ref, :foreign_ref) do + Digest.attachment(ref, attached) + end + end) + end + + defp positives(dir, fun) do + for desc <- Path.wildcard(Path.join([dir, "positive", "*.cbor"])) |> Enum.sort() do + base = String.replace_suffix(desc, ".cbor", "") + {"positive/" <> Path.basename(base), :ok, fun.(base, nil) |> ok_only()} + end + end + + defp negatives(dir, fun) do + for expect <- Path.wildcard(Path.join([dir, "negative", "*", "*.expect"])) |> Enum.sort() do + base = String.replace_suffix(expect, ".expect", "") + id = Path.relative_to(base, Path.join(dir, "negative")) + {id, expected(expect), fun.(base) |> ok_only()} + end + end + + defp expected(path) do + lines = path |> File.read!() |> String.split("\n", trim: true) + ["exit " <> exit, "verdict_code: " <> code | _] = lines + {:error, String.to_integer(exit), String.to_atom(code)} + end + + # A judgment as `{:error, exit, code}` against the expectation, or `:ok`. + defp ok_only({:ok, _}), do: :ok + defp ok_only({:ok, _, _}), do: :ok + defp ok_only(:ok), do: :ok + defp ok_only({:error, code}), do: {:error, Trinity.N6.Profile.exit_of(code), code} + defp ok_only({:error, code, _offset}), do: {:error, Trinity.N6.Profile.exit_of(code), code} + defp ok_only(other), do: other + + defp code({:error, code, _offset}), do: {:error, code} + defp code(other), do: other + + defp ok?(true, _ctx), do: :ok + defp ok?(false, _ctx), do: {:error, :mismatch} + + defp ctx(base) do + path = base <> ".ctx.json" + if File.exists?(path), do: path |> File.read!() |> JSON.decode!(), else: %{} + end + + defp key(base) do + m = (base <> ".meta.json") |> File.read!() |> JSON.decode!() + kty = %{"AKP" => :akp, "EC2" => :ec2, "OKP" => :okp} |> Map.fetch!(m["kty"]) + crv = if m["crv"], do: String.to_integer(m["crv"]) + {kty, crv, String.to_integer(m["alg"]), File.read!(base <> ".pub")} + end + + @doc "The value a `desc.json` node stands for (r7 section 10.2)." + def value(%{"i" => n}), do: String.to_integer(n) + def value(%{"b" => hex}), do: {:bytes, Base.decode16!(hex, case: :lower)} + def value(%{"t" => hex}), do: {:text, Base.decode16!(hex, case: :lower)} + def value(%{"s" => "true"}), do: true + def value(%{"s" => "false"}), do: false + def value(%{"s" => "null"}), do: nil + def value(%{"a" => items}), do: Enum.map(items, &value/1) + def value(%{"m" => pairs}), do: Map.new(pairs, fn [k, v] -> {value(k), value(v)} end) +end diff --git a/test/trinity/n6/body_digest_test.exs b/test/trinity/n6/body_digest_test.exs index ac1efa5..2c8064a 100644 --- a/test/trinity/n6/body_digest_test.exs +++ b/test/trinity/n6/body_digest_test.exs @@ -63,7 +63,7 @@ defmodule Trinity.N6.BodyDigestTest do assert Body.check(%{0 => 1, 16 => %{0 => 0, 1 => 5}}, "k", schemas) == {:error, :unknown_key} - assert Body.check(%{0 => 1, 16 => %{}}, "k", schemas) == {:error, :required_key_missing} + assert Body.check(%{0 => 1, 16 => %{}}, "k", schemas) == {:error, :field_missing} end test "a schema version in the reserved carrier range is refused even when a schema claims it" do diff --git a/test/trinity/n6/carrier_test.exs b/test/trinity/n6/carrier_test.exs index ffc8d49..9b482d9 100644 --- a/test/trinity/n6/carrier_test.exs +++ b/test/trinity/n6/carrier_test.exs @@ -58,7 +58,7 @@ defmodule Trinity.N6.CarrierTest do assert {:ok, %{0 => 0xFFFF0003, 1 => {:bytes, payload}}} = CBOR.decode(bytes) assert payload == parts.payload - assert Profile.schemas().single == {0xFFFF0003, :publisher} + assert Profile.schemas().single == {0xFFFF0003, :draft} end test "AC4: the twin envelope's schema, and a body's schema, are not a single-signer carrier" do diff --git a/test/trinity/n6/cbor_test.exs b/test/trinity/n6/cbor_test.exs index 7bf7e7c..1e13023 100644 --- a/test/trinity/n6/cbor_test.exs +++ b/test/trinity/n6/cbor_test.exs @@ -10,7 +10,7 @@ defmodule Trinity.N6.CBORTest do use ExUnit.Case, async: true use ExUnitProperties - alias Trinity.N6.{CBOR, Profile} + alias Trinity.N6.{CBOR, Profile, Unicode15} alias Trinity.TestSupport.N6Vectors @limits Profile.limits() @@ -99,7 +99,7 @@ defmodule Trinity.N6.CBORTest do assert CBOR.encode(%{-1 => 0}) == {:error, :key_not_uint} assert CBOR.encode(%{0x1_0000_0000 => 0}) == {:error, :key_not_uint} assert CBOR.encode(%{"a" => 0}) == {:error, :key_not_uint} - assert CBOR.encode({:text, "é"}) == {:error, :not_nfc} + assert CBOR.encode({:text, "é"}) == {:error, :nfc_maybe} assert CBOR.encode({:text, <<0xFF>>}) == {:error, :invalid_utf8} assert CBOR.encode("bare") == {:error, :value_not_n6} assert CBOR.encode(:atom) == {:error, :value_not_n6} @@ -160,18 +160,16 @@ defmodule Trinity.N6.CBORTest do end end - # Values in the subset. Text is printable strings normalised to NFC, which is what an encoder - # must be given; the checked-in vectors, not this generator, keep to older code points. + # Values in the subset. Text is printable strings kept to the code points the text rule passes + # on their own (assigned in Unicode 15.0.0, not Cf, class 0, NFC_QC Yes), so every string is one + # an encoder may be given. No normaliser is involved. defp n6_value do leaf = StreamData.one_of([ StreamData.integer(-0x8000_0000_0000_0000..0xFFFF_FFFF_FFFF_FFFF), StreamData.integer(-30..30), StreamData.map(StreamData.binary(max_length: 40), &{:bytes, &1}), - StreamData.map( - StreamData.string(:printable, max_length: 20), - &{:text, :unicode.characters_to_nfc_binary(&1)} - ), + StreamData.map(StreamData.string(:printable, max_length: 20), &{:text, passing(&1)}), StreamData.member_of([true, false, nil]) ]) @@ -186,6 +184,16 @@ defmodule Trinity.N6.CBORTest do |> StreamData.filter(&(depth(&1) <= @limits.depth)) end + defp passing(s) do + for <>, + not Unicode15.unassigned?(cp), + not Unicode15.format_control?(cp), + Unicode15.ccc(cp) == 0, + Unicode15.nfc_qc(cp) == :yes, + into: "", + do: <> + end + defp depth(v) when is_list(v), do: 1 + Enum.reduce(v, 0, &max(depth(&1), &2)) defp depth(v) when is_map(v), do: 1 + Enum.reduce(Map.values(v), 0, &max(depth(&1), &2)) defp depth(_v), do: 0 diff --git a/test/trinity/n6/corpus_test.exs b/test/trinity/n6/corpus_test.exs index 6af813f..1373e17 100644 --- a/test/trinity/n6/corpus_test.exs +++ b/test/trinity/n6/corpus_test.exs @@ -34,7 +34,7 @@ defmodule Trinity.N6.CorpusTest do duplicate_key: :duplicate_key, trailing_bytes: :trailing_bytes, truncated: :truncated, - not_nfc: :not_nfc + nfc_maybe: :nfc_maybe } test "generated bodies round-trip and every mutant is refused with its operator's code" do @@ -122,7 +122,7 @@ defmodule Trinity.N6.CorpusTest do defp mutate(:trailing_bytes, _pairs, bytes), do: bytes <> <<0x00>> defp mutate(:truncated, _pairs, bytes), do: binary_part(bytes, 0, byte_size(bytes) - 1) - defp mutate(:not_nfc, pairs, _bytes), + defp mutate(:nfc_maybe, pairs, _bytes), do: map(pairs ++ [{<<0x1A, 0xFFFF_FFFE::32>>, <<0x63, 0x65, 0xCC, 0x81>>}]) defp map(pairs), do: <<0xA0 + length(pairs)>> <> join(pairs) diff --git a/test/trinity/n6/cose_test.exs b/test/trinity/n6/cose_test.exs index 41f1f68..df803d0 100644 --- a/test/trinity/n6/cose_test.exs +++ b/test/trinity/n6/cose_test.exs @@ -206,7 +206,7 @@ defmodule Trinity.N6.COSETest do {:error, 1, :kid_not_thumbprint} end - test "a signature of the wrong size is a malformed carrier", %{ + test "a signature of the wrong size is signature_encoding (draft v0.1-r7 section 5.3)", %{ key: key, kid: kid, table: table @@ -214,7 +214,7 @@ defmodule Trinity.N6.COSETest do parts = %{protected: COSE.protected_header(kid, @typ), payload: "x", signature: <<0>>} assert COSE.verify(parts, keys: mldsa(key), typ: table) == - {:error, 1, :carrier_malformed} + {:error, 1, :signature_encoding} end test "a runtime without ML-DSA-87 answers no trust, never a crash; one with it judges the signature", diff --git a/test/trinity/n6/publisher_vectors_test.exs b/test/trinity/n6/publisher_vectors_test.exs new file mode 100644 index 0000000..466cba0 --- /dev/null +++ b/test/trinity/n6/publisher_vectors_test.exs @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.PublisherVectorsTest do + # Slice 142 AC5: agreement with the profile publisher's cut vector set (N6 draft v0.1-r7 section + # 10), opt-in by path, because the set is not posted yet and this tree does not vendor it. + # + # TRINITY_N6_PUBLISHER_VECTORS= mix test --only n6_publisher + use ExUnit.Case, async: true + + alias Trinity.TestSupport.N6R7Vectors + + @root System.get_env("TRINITY_N6_PUBLISHER_VECTORS") + + @moduletag :n6_publisher + + if @root do + test "every vector of the cut directories agrees, and the set is the one its SHA384SUMS names" do + assert N6R7Vectors.sums_defects(@root) == [] + + {version, set_digest} = N6R7Vectors.pin(@root) + results = N6R7Vectors.run(@root) + + IO.puts("\nN6 publisher set: #{version}, SHA-384 of SHA384SUMS #{set_digest}") + + for {dir, {agree, wrong}} <- Enum.sort(results), + do: IO.puts(" #{dir}: #{agree} of #{agree + length(wrong)} agree") + + wrong = + for {dir, {_agree, wrong}} <- results, + {id, want, got} <- wrong, + do: "#{dir}/#{id}: want #{inspect(want)}, got #{inspect(got)}" + + assert wrong == [], Enum.join(Enum.sort(wrong), "\n") + end + else + @tag skip: "set TRINITY_N6_PUBLISHER_VECTORS to the publisher's vectors/n6 to run this" + test "every vector of the cut directories agrees, and the set is the one its SHA384SUMS names" do + end + end +end diff --git a/test/trinity/n6/r7_alignment_test.exs b/test/trinity/n6/r7_alignment_test.exs new file mode 100644 index 0000000..7087945 --- /dev/null +++ b/test/trinity/n6/r7_alignment_test.exs @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.N6.R7AlignmentTest do + # Slice 142: the codec and the code registry against N6 draft v0.1-r7. + use ExUnit.Case, async: true + + alias Trinity.N6.{CBOR, Profile, Unicode15} + + defp text(s), do: <<0x60 + byte_size(s)>> <> s + + describe "AC1: the text rule" do + test "format controls are refused, the neighbouring code points are not" do + for cp <- [0x200B, 0x202E, 0xFEFF, 0x00AD, 0x200D, 0x2066, 0xE0041] do + assert {:error, :text_format_control, 0} = CBOR.decode(text(<>)), + "U+#{Integer.to_string(cp, 16)}" + + assert CBOR.encode({:text, <>}) == {:error, :text_format_control} + end + + for cp <- [0x200A, 0x2010, 0x00AC, 0x2056] do + assert {:ok, {:text, _}} = CBOR.decode(text(<>)), + "U+#{Integer.to_string(cp, 16)}" + end + end + + test "a code point assigned only from Unicode 16.0 is unassigned, whatever the runtime says" do + # U+1C89, CYRILLIC CAPITAL LETTER TJE, is new in 16.0: the runtime's own data calls it a + # letter, the pinned 15.0.0 tables do not, and the tables decide. + assert String.match?("Ᲊ", ~r/\p{L}/u) + assert {:error, :text_unassigned, 0} = CBOR.decode(text("Ᲊ")) + assert {:error, :text_unassigned, 0} = CBOR.decode(text(<<0xFFFE::utf8>>)) + end + + test "the quick check: Maybe is nfc_maybe, a combining class out of order is not_nfc" do + assert {:error, :nfc_maybe, 0} = CBOR.decode(text("é")) + assert {:error, :nfc_maybe, 0} = CBOR.decode(text("क़")) + + # U+0591 (class 220) then U+05B0 (class 10): both NFC_QC Yes, so only the order refuses it. + assert {Unicode15.nfc_qc(0x0591), Unicode15.nfc_qc(0x05B0)} == {:yes, :yes} + assert {:error, :not_nfc, 0} = CBOR.decode(text("ְ֑")) + assert {:ok, _} = CBOR.decode(text("ְ֑")) + end + + test "UTF-8 is checked over the whole string before the text rule starts" do + assert {:error, :invalid_utf8, 0} = CBOR.decode(text(<<"​", 0xFF>>)) + end + end + + describe "AC2: the tables" do + test "the counts draft v0.1-r7 states" do + assert Unicode15.version() == "15.0.0" + + assert %{cn: 825_345, cf: 170, qc_no: 1120, qc_maybe: 111} = + Unicode15.counts() + end + + test "no Unicode normaliser is called anywhere in the N6 code" do + offenders = + for path <- Path.wildcard("lib/trinity/n6/**/*.ex"), + source = File.read!(path), + Regex.match?(~r/characters_to_nf[ck]|String\.normalize|:unicode_util/, source), + do: path + + assert offenders == [] + end + + @ucd System.get_env("TRINITY_UCD_DIR") + @tag :tmp_dir + if @ucd do + test "the generator writes the checked-in module byte for byte", %{tmp_dir: tmp} do + out = Path.join(tmp, "unicode15.ex") + {_, 0} = System.cmd("elixir", ["scripts/unicode15_tables.exs", @ucd, out]) + assert File.read!(out) == File.read!("lib/trinity/n6/unicode15.ex") + end + else + @tag skip: "set TRINITY_UCD_DIR to the Unicode 15.0.0 data files to run this" + test "the generator writes the checked-in module byte for byte" do + end + end + end + + describe "AC3: a key's head is checked before its type" do + test "indefinite, not well-formed and truncated key heads keep their own codes" do + assert {:error, :indefinite_length, 1} = CBOR.decode(<<0xA1, 0x7F, 0xFF, 0x00>>) + assert {:error, :not_well_formed, 1} = CBOR.decode(<<0xA1, 0x1C, 0x00>>) + assert {:error, :truncated, 1} = CBOR.decode(<<0xA1, 0x19, 0x00>>) + assert {:error, :key_not_uint, 1} = CBOR.decode(<<0xA1, 0x61, ?a, 0x00>>) + end + end + + describe "AC4: the registry" do + # Draft v0.1-r7 section 9.4, every code a conforming implementation may emit. + @r7 ~w(claim verified_nonclaimed too_large truncated not_well_formed indefinite_length + key_not_uint tag_forbidden float_forbidden simple_forbidden noncanonical duplicate_key + int_out_of_range too_deep too_many_items text_too_long invalid_utf8 text_unassigned + not_nfc text_format_control nfc_maybe trailing_bytes body_not_map schema_version_missing + schema_reserved test_schema_outside_test_mode unknown_schema typ_schema_mismatch + issuer_missing field_missing unknown_key field_type_mismatch value_out_of_range + text_grammar_mismatch set_not_canonical digest_alg_not_permitted digest_length_mismatch + header_noncanonical header_label_forbidden header_missing typ_unexpected + wrong_carrier_for_typ content_type_forbidden header_value_invalid alg_not_permitted + key_invalid kid_not_thumbprint carrier_malformed note_malformed header_not_protected + unprotected_forbidden countersignature_forbidden signature_encoding signature_invalid + signature_invalid_kv signature_invalid_kh no_trusted_signature duplicate_line + duplicate_signer signer_order pair_mismatch twin_header_mismatch class_mismatch + partial_refused key_retired window_reopened foreign_digest_mismatch usage + policy_stale_or_unknown_key unknown_origin descriptor_unverified foreign_bytes_missing + degraded_kv_only degraded_kh_only fork_kv fork_kh fork_pair epoch_regression + key_revoked_compromised_kv key_revoked_compromised_kh issuer_mismatch_kh + accuse_kv_issuer_mismatch accuse_foreign_origin_signed_kv accuse_logged_nonconforming)a + + test "every draft code is in r7's registry, with r7's exit class; only two are this tree's" do + for {code, {exit, :draft, _steps}} <- Profile.codes() do + assert code in @r7, "#{code} is marked :draft but is not in r7 section 9.4" + assert exit in [0, 1, 2, 5], "#{code}" + end + + ours = for {code, {_exit, :trinity, _steps}} <- Profile.codes(), do: code + assert Enum.sort(ours) == [:alg_unavailable, :value_not_n6] + end + + test "the pin names v0.1-r7" do + assert %{version: "n6/v0.1", draft: "v0.1-r7"} = Profile.pin() + end + end +end diff --git a/test/trinity/receipts/v4_test.exs b/test/trinity/receipts/v4_test.exs index 1ab52ef..af0d42a 100644 --- a/test/trinity/receipts/v4_test.exs +++ b/test/trinity/receipts/v4_test.exs @@ -97,8 +97,8 @@ defmodule Trinity.Receipts.V4Test do for {mutant, code} <- [ {Map.put(v, 30, 1), :unknown_key}, - {Map.delete(v, 16), :required_key_missing}, - {Map.put(v, 16, {:text, "two"}), :wrong_type}, + {Map.delete(v, 16), :field_missing}, + {Map.put(v, 16, {:text, "two"}), :field_type_mismatch}, {Map.put(v, 0, V4.schema_number("effect")), :unknown_schema} ] do assert {:error, ^code, _} = @@ -111,7 +111,7 @@ defmodule Trinity.Receipts.V4Test do end test "the encoder refuses a body the decoder would refuse" do - assert {:error, :wrong_type} = V4.row_body("decision", fields(%{seq: -1})) + assert {:error, :value_out_of_range} = V4.row_body("decision", fields(%{seq: -1})) end test "the typ table has one single-signer entry per signed kind and none for query" do