diff --git a/DEPENDENCIES b/DEPENDENCIES index ca31f307..65d9ca5c 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -1,4 +1,4 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 6bbc7a8850da0ad72cd2b2badfc33033a4e48be1 -blaze https://github.com/sourcemeta/blaze 49ff4cd2893d52970154a7eea5a729671a9f630a +core https://github.com/sourcemeta/core 42d50f3c1e2d63c456fbdf37d18854edab39b5a0 +blaze https://github.com/sourcemeta/blaze ca1949507ea5f4215f9a55ca796cd074602ff705 bootstrap https://github.com/twbs/bootstrap 1a6fdfae6be09b09eaced8f0e442ca6f7680a61e diff --git a/cmake/FindCore.cmake b/cmake/FindCore.cmake index edd0efe9..88a98d04 100644 --- a/cmake/FindCore.cmake +++ b/cmake/FindCore.cmake @@ -14,6 +14,7 @@ if(NOT Core_FOUND) set(SOURCEMETA_CORE_MCP OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_HTTP OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_OAUTH OFF CACHE BOOL "disable") + set(SOURCEMETA_CORE_OIDC OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_SEMVER OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_MARKDOWN OFF CACHE BOOL "disable") set(SOURCEMETA_CORE_CONTRIB_GOOGLEBENCHMARK OFF CACHE BOOL "GoogleBenchmark") diff --git a/vendor/blaze/DEPENDENCIES b/vendor/blaze/DEPENDENCIES index a89169bc..fbb14c12 100644 --- a/vendor/blaze/DEPENDENCIES +++ b/vendor/blaze/DEPENDENCIES @@ -1,5 +1,5 @@ vendorpull https://github.com/sourcemeta/vendorpull 1dcbac42809cf87cb5b045106b863e17ad84ba02 -core https://github.com/sourcemeta/core 7581b8c977c794de48e7c2ae5fcf56a7410f8fe4 +core https://github.com/sourcemeta/core 42d50f3c1e2d63c456fbdf37d18854edab39b5a0 jsonschema-test-suite https://github.com/json-schema-org/JSON-Schema-Test-Suite 1acd90e53554fa24d2529b49fd7d50bab18f8b7e jsonschema-2020-12 https://github.com/json-schema-org/json-schema-spec 769daad75a9553562333a8937a187741cb708c72 jsonschema-2019-09 https://github.com/json-schema-org/json-schema-spec 41014ea723120ce70b314d72f863c6929d9f3cfd diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json index 60e0aad5..4b470c9e 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2019-09/vocabulary.json @@ -15,6 +15,10 @@ "x-jsonld-type": "https://schema.org/Product", "x-jsonld-graph": true }, + { + "x-jsonld-datatype": null, + "x-jsonld-override": true + }, { "format": "uri", "x-format-assertion": true @@ -26,43 +30,84 @@ "type": "boolean" }, "x-jsonld-container": { - "enum": [ "@list", "@set", "@language", "@index" ] + "enum": [ "@list", "@set", "@language", "@index", null ] }, "x-jsonld-datatype": { - "x-format-assertion": true, - "type": "string", - "format": "iri" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + ] }, "x-jsonld-direction": { - "enum": [ "ltr", "rtl" ] + "enum": [ "ltr", "rtl", null ] }, "x-jsonld-graph": { - "type": "boolean" + "type": [ "boolean", "null" ] }, "x-jsonld-id": { - "x-format-assertion": true, - "type": "string", - "format": "iri" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + ] }, "x-jsonld-json": { - "type": "boolean" + "type": [ "boolean", "null" ] }, "x-jsonld-language": { - "type": "string", - "pattern": "^(?:[Ee][Nn]-[Gg][Bb]-[Oo][Ee][Dd]|[Ii]-(?:[Aa][Mm][Ii]|[Bb][Nn][Nn]|[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ee][Nn][Oo][Cc][Hh][Ii][Aa][Nn]|[Hh][Aa][Kk]|[Kk][Ll][Ii][Nn][Gg][Oo][Nn]|[Ll][Uu][Xx]|[Mm][Ii][Nn][Gg][Oo]|[Nn][Aa][Vv][Aa][Jj][Oo]|[Pp][Ww][Nn]|[Tt][Aa][Oo]|[Tt][Aa][Yy]|[Tt][Ss][Uu])|[Ss][Gg][Nn]-(?:[Bb][Ee]-[Ff][Rr]|[Bb][Ee]-[Nn][Ll]|[Cc][Hh]-[Dd][Ee])|(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[Xx](?:-[A-Za-z0-9]{1,8})+)?)|[Xx](?:-[A-Za-z0-9]{1,8})+)$" + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + } + ] + }, + "x-jsonld-override": { + "type": "boolean" }, "x-jsonld-reverse": { - "x-format-assertion": true, - "type": "string", - "format": "iri" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + ] }, "x-jsonld-self": { - "x-format-assertion": true, - "type": "string", - "format": "uri-template" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "uri-template" + } + ] }, "x-jsonld-type": { "anyOf": [ + { + "type": "null" + }, { "x-format-assertion": true, "type": "string", @@ -70,6 +115,7 @@ }, { "type": "array", + "uniqueItems": true, "items": { "x-format-assertion": true, "type": "string", diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json index 607faeb4..bb7b41ea 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/2020-12/vocabulary.json @@ -15,6 +15,10 @@ "x-jsonld-type": "https://schema.org/Product", "x-jsonld-graph": true }, + { + "x-jsonld-datatype": null, + "x-jsonld-override": true + }, { "format": "uri", "x-format-assertion": true @@ -26,43 +30,84 @@ "type": "boolean" }, "x-jsonld-container": { - "enum": [ "@list", "@set", "@language", "@index" ] + "enum": [ "@list", "@set", "@language", "@index", null ] }, "x-jsonld-datatype": { - "x-format-assertion": true, - "type": "string", - "format": "iri" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + ] }, "x-jsonld-direction": { - "enum": [ "ltr", "rtl" ] + "enum": [ "ltr", "rtl", null ] }, "x-jsonld-graph": { - "type": "boolean" + "type": [ "boolean", "null" ] }, "x-jsonld-id": { - "x-format-assertion": true, - "type": "string", - "format": "iri" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + ] }, "x-jsonld-json": { - "type": "boolean" + "type": [ "boolean", "null" ] }, "x-jsonld-language": { - "type": "string", - "pattern": "^(?:[Ee][Nn]-[Gg][Bb]-[Oo][Ee][Dd]|[Ii]-(?:[Aa][Mm][Ii]|[Bb][Nn][Nn]|[Dd][Ee][Ff][Aa][Uu][Ll][Tt]|[Ee][Nn][Oo][Cc][Hh][Ii][Aa][Nn]|[Hh][Aa][Kk]|[Kk][Ll][Ii][Nn][Gg][Oo][Nn]|[Ll][Uu][Xx]|[Mm][Ii][Nn][Gg][Oo]|[Nn][Aa][Vv][Aa][Jj][Oo]|[Pp][Ww][Nn]|[Tt][Aa][Oo]|[Tt][Aa][Yy]|[Tt][Ss][Uu])|[Ss][Gg][Nn]-(?:[Bb][Ee]-[Ff][Rr]|[Bb][Ee]-[Nn][Ll]|[Cc][Hh]-[Dd][Ee])|(?:(?:[A-Za-z]{2,3}(?:-[A-Za-z]{3}){0,3}|[A-Za-z]{4,8})(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*(?:-[Xx](?:-[A-Za-z0-9]{1,8})+)?)|[Xx](?:-[A-Za-z0-9]{1,8})+)$" + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^(?:(?:[a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4,8})(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*(?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*(?:-x(?:-[a-z0-9]{1,8})+)?|x(?:-[a-z0-9]{1,8})+)$" + } + ] + }, + "x-jsonld-override": { + "type": "boolean" }, "x-jsonld-reverse": { - "x-format-assertion": true, - "type": "string", - "format": "iri" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "iri" + } + ] }, "x-jsonld-self": { - "x-format-assertion": true, - "type": "string", - "format": "uri-template" + "anyOf": [ + { + "type": "null" + }, + { + "x-format-assertion": true, + "type": "string", + "format": "uri-template" + } + ] }, "x-jsonld-type": { "anyOf": [ + { + "type": "null" + }, { "x-format-assertion": true, "type": "string", @@ -70,6 +115,7 @@ }, { "type": "array", + "uniqueItems": true, "items": { "x-format-assertion": true, "type": "string", diff --git a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown index f35654e0..70d366ee 100644 --- a/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown +++ b/vendor/blaze/schemas/sourcemeta-extension/v1/README.markdown @@ -36,6 +36,7 @@ new vocabulary published under a new major version with a new URI. | [`x-jsonld-graph`](#328-x-jsonld-graph) | A boolean | Object subschemas | | [`x-jsonld-container`](#329-x-jsonld-container) | `@list`, `@set`, `@language`, or `@index` | Array or object property subschemas | | [`x-jsonld-self`](#3210-x-jsonld-self) | A URI Template | Scalar or object subschemas | +| [`x-jsonld-override`](#3211-x-jsonld-override) | A boolean | Any subschema | ### 3.1. `x-format-assertion` @@ -56,7 +57,8 @@ meet at the same instance location. When they do, their values are merged, and identical values always collapse into one, so a schema and the schemas it references may safely declare the same annotation. A keyword that takes a single value MUST NOT be assigned two different values for the same location, -unless its own section defines how distinct values merge. +unless its own section defines how distinct values merge, or +[`x-jsonld-override`](#3211-x-jsonld-override) discards all but one of them. #### 3.2.1. `x-jsonld-id` @@ -73,7 +75,7 @@ asserting the location under each declared predicate. #### 3.2.2. `x-jsonld-type` The value of this keyword MUST be a string representing an absolute IRI -[RFC3987], or an array of such strings. +[RFC3987], or an array of unique such strings. This keyword declares the `@type` of the node that the annotated instance location materializes as. A single IRI is equivalent to an array containing @@ -107,14 +109,14 @@ or `x-jsonld-direction` at the same location. #### 3.2.5. `x-jsonld-language` -The value of this keyword MUST be a string representing a well-formed language -tag [RFC5646]. +The value of this keyword MUST be a string representing a well-formed +language tag in the canonical formatting of [RFC5646], with script subtags in +titlecase, region subtags in uppercase, and every other subtag in lowercase. This keyword declares the language of the language-tagged literal that the annotated instance location materializes as. It MUST be applied to a location -whose value is a string. Tags compare case-insensitively, so spellings that -differ only in case are the same value, and an implementation MAY keep any of -the declared spellings. +whose value is a string. Tags compare exactly as written, which the canonical +formatting makes safe, as one language can no longer spell two values. #### 3.2.6. `x-jsonld-direction` @@ -152,8 +154,9 @@ This keyword declares the container semantics of the annotated instance location. The `@list` and `@set` containers range over an array value, asserting an ordered RDF list and an unordered set respectively. The `@language` container ranges over an object value, asserting language-tagged -literals. Its keys MUST be well-formed language tags [RFC5646], except for the -reserved key `@none` which asserts a literal with no language, and its members +literals. Its keys MUST be well-formed language tags in the canonical +formatting of [RFC5646], except for the reserved key `@none` which asserts a +literal with no language, and its members MUST be strings, arrays of strings, or `null`, where `null` entries are ignored. The `@index` container ranges over an object value whose keys carry no RDF meaning. This keyword MUST NOT be combined with any keyword of this @@ -169,12 +172,41 @@ This keyword mints the identifier of the node that the annotated instance location materializes as, giving an object its `@id` or promoting a scalar to an identified node. An object binds each template variable to the member of that name, and a scalar binds the reserved variable `this` to its own value. -Every binding MUST be a non-empty string, a number, or a boolean, and the -expanded template MUST be an absolute IRI [RFC3987]. The location value MUST +Every binding MUST be a non-empty string, and the expanded template MUST be +an absolute IRI [RFC3987]. The location value MUST NOT be an array, and the keyword MUST NOT be combined with `x-jsonld-datatype`, `x-jsonld-language`, or `x-jsonld-direction` at the same location. +#### 3.2.11. `x-jsonld-override` + +The value of this keyword MUST be a boolean. + +When the value is `true`, the keywords of this vocabulary declared in the +same schema object override the different values that reach the same instance +location from subschemas evaluated beneath that object, such as through a +sibling reference or any other applicator the object composes. Overridden +values are discarded before the single-value requirement of this section is +enforced, and values reaching the location from anywhere else are unaffected, +so an annotation can never override the schema objects that compose it. When +the value is `false` or the keyword is absent, the schema object declares no +overrides. + +Within an override-marked schema object, every keyword of this vocabulary +also accepts `null`, which removes instead of declaring: + +- A `null` for `x-jsonld-datatype`, `x-jsonld-language`, `x-jsonld-direction`, + `x-jsonld-container`, `x-jsonld-self`, `x-jsonld-json`, or `x-jsonld-graph` + overrides like any other value and restores the behavior the location has + when the keyword is absent. +- A `null` for `x-jsonld-id`, `x-jsonld-reverse`, or `x-jsonld-type` removes + every value of that keyword reaching the location from beneath the object, + while values from anywhere else are kept. These keywords otherwise + accumulate, and their non-null values are unaffected by overriding. + +Outside an override-marked schema object, a `null` value for any keyword of +this vocabulary declares nothing, and resolves as if the keyword were absent. + 4. References ------------- diff --git a/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h b/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h index 0340abc2..855f58fc 100644 --- a/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h +++ b/vendor/blaze/src/output/include/sourcemeta/blaze/output_jsonld.h @@ -22,11 +22,12 @@ namespace sourcemeta::blaze { /// @ingroup output /// The x-jsonld-* keywords that jsonld() resolves. Use these as the annotation /// whitelist when compiling a schema so that its annotations are collected. -inline constexpr std::array +inline constexpr std::array JSONLD_KEYWORDS{{"x-jsonld-id", "x-jsonld-type", "x-jsonld-reverse", "x-jsonld-datatype", "x-jsonld-language", "x-jsonld-direction", "x-jsonld-json", "x-jsonld-graph", - "x-jsonld-container", "x-jsonld-self"}}; + "x-jsonld-container", "x-jsonld-self", + "x-jsonld-override"}}; /// @ingroup output /// The descriptor facet that a JSON-LD resolution error is about @@ -39,7 +40,8 @@ enum class JSONLDFacet : std::uint8_t { Graph, JSON, Container, - Self + Self, + Override }; /// @ingroup output diff --git a/vendor/blaze/src/output/output_jsonld.cc b/vendor/blaze/src/output/output_jsonld.cc index 33f20f00..b9209704 100644 --- a/vendor/blaze/src/output/output_jsonld.cc +++ b/vendor/blaze/src/output/output_jsonld.cc @@ -3,19 +3,18 @@ #include #include #include -#include #include #include #include // std::ranges::sort, std::ranges::any_of -#include // std::deque +#include // assert #include // std::ref, std::reference_wrapper #include // std::optional -#include // std::ostringstream #include // std::string #include // std::string_view #include // std::tie, std::make_tuple #include // std::unordered_map +#include // std::unordered_set #include // std::move #include // std::variant, std::get, std::holds_alternative #include // std::vector @@ -67,6 +66,19 @@ auto is_iri_value(const sourcemeta::core::JSON &value) -> bool { return value.is_string() && sourcemeta::core::URI::is_iri(value.to_string()); } +using DirtyLocations = + std::unordered_set; + +// Forget the streamed facts of a location whose annotations cannot be +// resolved by simple agreement, handing it over to the override-aware slow +// path +auto demote(Accumulator &accumulator, DirtyLocations &dirty, + const sourcemeta::core::WeakPointer &location) -> void { + accumulator.erase(location); + dirty.insert(location); +} + auto type_iri_error(const sourcemeta::core::WeakPointer &instance_location) -> sourcemeta::blaze::JSONLDResolutionError { return {.instance_location = sourcemeta::core::to_pointer(instance_location), @@ -153,10 +165,10 @@ auto container_placement_error( if (container == sourcemeta::core::JSONLDContainer::Language) { for (const auto &entry : value.as_object()) { if (entry.first != "@none" && - !sourcemeta::core::is_langtag(entry.first)) { - return facet_error( - pointer, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD language container requires BCP 47 language tag keys"); + !sourcemeta::core::is_canonical_langtag(entry.first)) { + return facet_error(pointer, sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD language container requires canonical " + "BCP 47 language tag keys"); } // A null member, or a null item in an array member, is treated as absent @@ -255,20 +267,18 @@ auto literal_error(const sourcemeta::core::WeakPointer &pointer, // Expand an x-jsonld-self URI Template into a concrete identifier. An object // binds each variable to the member of that name, and a scalar binds the -// reserved variable this to its own value. A string binds directly and any -// other scalar binds through its JSON stringification, whereas an object, -// array, absent, or null value cannot bind. A binding that is not usable, or a -// result that is not an absolute IRI, is a fail-loud resolution error +// reserved variable this to its own value. Only a non-empty string can bind, +// and a binding that is not usable, or a result that is not an absolute IRI, +// is a fail-loud resolution error auto expand_self(const sourcemeta::core::WeakPointer &pointer, const sourcemeta::core::JSON::String &pattern, const sourcemeta::core::JSON &value) -> std::variant { std::optional failure; - std::deque stringified; const sourcemeta::core::URITemplate uri_template{pattern}; auto expanded{uri_template.expand( - [&value, &pointer, &failure, &stringified]( + [&value, &pointer, &failure]( const std::string_view name) -> sourcemeta::core::URITemplateValue { const sourcemeta::core::JSON *bound{nullptr}; if (value.is_object()) { @@ -277,41 +287,30 @@ auto expand_self(const sourcemeta::core::WeakPointer &pointer, bound = &value; } - if (bound == nullptr || bound->is_object() || bound->is_array() || - bound->is_null()) { + if (bound == nullptr || !bound->is_string()) { if (!failure.has_value()) { failure = facet_error( pointer, sourcemeta::blaze::JSONLDFacet::Self, "A JSON-LD self identity template variable must bind to a " - "string, number, or boolean"); + "string"); } return std::nullopt; } - if (bound->is_string()) { - const auto &text{bound->to_string()}; - if (text.empty()) { - if (!failure.has_value()) { - failure = facet_error( - pointer, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity template variable cannot bind to an " - "empty string"); - } - - return std::nullopt; + const auto &text{bound->to_string()}; + if (text.empty()) { + if (!failure.has_value()) { + failure = facet_error( + pointer, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity template variable cannot bind to an " + "empty string"); } - return std::make_tuple(std::string_view{text}, std::nullopt, false); + return std::nullopt; } - // A number or boolean has no direct string, so it binds through its - // compact JSON form, kept alive for the duration of the expansion - std::ostringstream stream; - sourcemeta::core::stringify(*bound, stream); - stringified.push_back(stream.str()); - return std::make_tuple(std::string_view{stringified.back()}, - std::nullopt, false); + return std::make_tuple(std::string_view{text}, std::nullopt, false); })}; if (failure.has_value()) { @@ -350,6 +349,225 @@ auto array_of_nodes(const Accumulator &accumulator, return true; } +// Whether the first annotation's schema object encloses the second's, which +// is the outer-to-inner relation that x-jsonld-override shadows along. Both +// arguments are full evaluate paths whose trailing token is the keyword, so +// the relation holds when the first path's parent strictly prefixes the +// second's +auto encloses(const sourcemeta::core::WeakPointer &outer, + const sourcemeta::core::WeakPointer &inner) -> bool { + return outer.size() < inner.size() && inner.starts_with_initial(outer); +} + +// A collected keyword value pending resolution, paired with the evaluate +// path of the annotation that carried it +struct Candidate { + const sourcemeta::core::JSON *value; + const sourcemeta::core::WeakPointer *path; + bool marked{false}; +}; + +// The candidates gathered at one instance location before resolution, plus +// the evaluate paths of the x-jsonld-override annotations that evaluated to +// true at that location +struct Pending { + std::vector ids; + std::vector reverses; + std::vector types; + std::vector datatypes; + std::vector languages; + std::vector directions; + std::vector jsons; + std::vector graphs; + std::vector containers; + std::vector selves; + std::vector marks; + + [[nodiscard]] auto annotated() const -> bool { + return !this->ids.empty() || !this->reverses.empty() || + !this->types.empty() || !this->datatypes.empty() || + !this->languages.empty() || !this->directions.empty() || + !this->jsons.empty() || !this->graphs.empty() || + !this->containers.empty() || !this->selves.empty(); + } +}; + +using PendingMap = std::unordered_map; + +// Whether the annotation at the given evaluate path shares its schema object +// with one of its location's x-jsonld-override marks +auto marked_by(const std::vector &marks, + const sourcemeta::core::WeakPointer &path) -> bool { + for (const auto *mark : marks) { + if (mark->size() == path.size() && path.starts_with_initial(*mark)) { + return true; + } + } + + return false; +} + +// A null that no override mark licenses declares nothing, as if the keyword +// were absent +auto unmarked_null(const Candidate &candidate) -> bool { + return candidate.value->is_null() && !candidate.marked; +} + +// Attach the location's override marks to a facet's candidates and drop the +// nulls that no mark licenses. This cannot happen at collection time, as the +// sibling mark may be collected in any order relative to the null +auto prepare(std::vector &candidates, + const std::vector &marks) + -> void { + for (auto &candidate : candidates) { + candidate.marked = marked_by(marks, *candidate.path); + } + + std::erase_if(candidates, unmarked_null); +} + +// The comparable form of a single-valued candidate, where an absent result +// stands for a tombstone +using NormalizedKey = std::optional; + +auto key_exact(const sourcemeta::core::JSON &value) -> NormalizedKey { + if (value.is_null()) { + return std::nullopt; + } + + return value.to_string(); +} + +// Only true booleans survive collection, so a tombstone normalizes to the +// absent state exactly like every other facet +auto key_boolean(const sourcemeta::core::JSON &value) -> NormalizedKey { + if (value.is_null()) { + return std::nullopt; + } + + return sourcemeta::core::JSON::String{"true"}; +} + +// The resolution of a single-valued facet at one instance location. An +// absent winner without a conflict means the facet resolves to its absent +// state, either because no candidate exists or because a tombstone won +struct Election { + const Candidate *winner{nullptr}; + bool conflict{false}; +}; + +// Resolve a single-valued facet: shadowed values are discarded, identical +// survivors deduplicate, and more than one distinct surviving value is a +// conflict. The evaluate path comparisons only happen when the candidates +// genuinely diverge +auto elect(const std::vector &candidates, + NormalizedKey (*const key)(const sourcemeta::core::JSON &)) + -> Election { + if (candidates.empty()) { + return {}; + } + + std::vector keys; + keys.reserve(candidates.size()); + bool divergent{false}; + for (const auto &candidate : candidates) { + keys.push_back(key(*candidate.value)); + divergent = divergent || keys.back() != keys.front(); + } + + std::vector survivors; + survivors.reserve(candidates.size()); + if (divergent) { + for (std::size_t index = 0; index < candidates.size(); index += 1) { + bool shadowed{false}; + for (std::size_t other = 0; other < candidates.size(); other += 1) { + if (candidates[other].marked && keys[other] != keys[index] && + encloses(*candidates[other].path, *candidates[index].path)) { + shadowed = true; + break; + } + } + + if (!shadowed) { + survivors.push_back(index); + } + } + } else { + for (std::size_t index = 0; index < candidates.size(); index += 1) { + survivors.push_back(index); + } + } + + // Enclosure is a strict relation, so the candidate with the shortest + // evaluate path can never be shadowed + assert(!survivors.empty()); + const auto &elected{keys[survivors.front()]}; + for (const auto index : survivors) { + if (keys[index] != elected) { + return {.winner = nullptr, .conflict = true}; + } + } + + if (!elected.has_value()) { + return {}; + } + + // Every survivor carries the elected key, and equal keys mean equal + // values, so any survivor serves as the winner + return {.winner = &candidates[survivors.front()], .conflict = false}; +} + +// A false boolean never enters resolution, as the absent state it restates +// already is false, so removal only ever spells null +auto false_boolean(const Candidate &candidate) -> bool { + return candidate.value->is_boolean() && !candidate.value->to_boolean(); +} + +// Whether an additive candidate is removed by a sibling tombstone, which +// happens when the tombstone's schema object encloses the candidate's +auto removed_by_tombstone(const std::vector &candidates, + const Candidate &candidate) -> bool { + for (const auto &tombstone : candidates) { + if (tombstone.value->is_null() && + encloses(*tombstone.path, *candidate.path)) { + return true; + } + } + + return false; +} + +auto resolve_edges(const std::vector &candidates, const bool reverse, + std::vector &edges) -> void { + for (const auto &candidate : candidates) { + if (candidate.value->is_null() || + removed_by_tombstone(candidates, candidate)) { + continue; + } + + add_edge(edges, candidate.value->to_string(), reverse); + } +} + +auto resolve_types(const std::vector &candidates, + std::vector &types) -> void { + for (const auto &candidate : candidates) { + if (candidate.value->is_null() || + removed_by_tombstone(candidates, candidate)) { + continue; + } + + if (candidate.value->is_array()) { + for (const auto &element : candidate.value->as_array()) { + add_type(types, element.to_string()); + } + } else { + add_type(types, candidate.value->to_string()); + } + } +} + // The x-jsonld-* keyword names, pre-hashed for the first-pass dispatch using namespace std::string_view_literals; const auto HASH_ID{sourcemeta::core::JSON::Object::hash("x-jsonld-id"sv)}; @@ -367,17 +585,58 @@ const auto HASH_GRAPH{sourcemeta::core::JSON::Object::hash("x-jsonld-graph"sv)}; const auto HASH_CONTAINER{ sourcemeta::core::JSON::Object::hash("x-jsonld-container"sv)}; const auto HASH_SELF{sourcemeta::core::JSON::Object::hash("x-jsonld-self"sv)}; +const auto HASH_OVERRIDE{ + sourcemeta::core::JSON::Object::hash("x-jsonld-override"sv)}; + +// Gather an already-validated annotation into its location's pending +// candidates for the override-aware slow path +auto collect(const sourcemeta::blaze::SimpleOutput::AnnotationEntry &entry, + Pending &pending) -> void { + const auto &keyword{entry.evaluate_path.back()}; + const Candidate candidate{.value = &entry.value, + .path = &entry.evaluate_path}; + + if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE)) { + if (entry.value.to_boolean()) { + pending.marks.push_back(&entry.evaluate_path); + } + } else if (keyword.property_equals("x-jsonld-id", HASH_ID)) { + pending.ids.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)) { + pending.reverses.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-type", HASH_TYPE)) { + pending.types.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-datatype", HASH_DATATYPE)) { + pending.datatypes.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { + pending.languages.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { + pending.directions.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-json", HASH_JSON)) { + pending.jsons.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-graph", HASH_GRAPH)) { + pending.graphs.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-container", HASH_CONTAINER)) { + pending.containers.push_back(candidate); + } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { + pending.selves.push_back(candidate); + } +} // Turn the JSON-LD annotations into a resolved list, or the first error found. -// The first pass groups the facts by location, the second derives each kind -// from the value shape and validates the facts against it +// The first pass validates every annotation and streams agreeing values +// straight into the accumulator, demoting a location to the slow path the +// moment a null or a genuinely diverging value arrives. The slow path +// collects the demoted locations' candidates and resolves each facet, +// shadowing through x-jsonld-override when the candidates genuinely conflict. +// The last pass derives each kind from the value shape and validates the +// facts against it auto resolve(const sourcemeta::core::JSON &instance, const sourcemeta::blaze::SimpleOutput &output) -> std::variant { Accumulator accumulator; - std::vector> - language_containers; + DirtyLocations dirty; for (const auto &entry : output.annotations()) { if (entry.evaluate_path.empty()) { @@ -388,163 +647,299 @@ auto resolve(const sourcemeta::core::JSON &instance, const auto &instance_location{entry.instance_location}; const auto &value{entry.value}; - if (keyword.property_equals("x-jsonld-id", HASH_ID) || - keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)) { + if (keyword.property_equals("x-jsonld-override", HASH_OVERRIDE)) { + if (!value.is_boolean()) { + return facet_error(instance_location, + sourcemeta::blaze::JSONLDFacet::Override, + "The value of x-jsonld-override must be a boolean"); + } + + // A mark only matters when it shadows a diverging value or licenses a + // null or a false, and each of those demotes the location on its own, + // so the slow path recollects the mark whenever it can act. A future + // keyword whose mark changes agreeing resolutions must demote here + } else if (keyword.property_equals("x-jsonld-id", HASH_ID) || + keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)) { const bool reverse{ keyword.property_equals("x-jsonld-reverse", HASH_REVERSE)}; - if (!is_iri_value(value)) { + if (!value.is_null() && !is_iri_value(value)) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Predicate, reverse ? "The value of x-jsonld-reverse must be an absolute IRI" : "The value of x-jsonld-id must be an absolute IRI"); } - add_edge(accumulator[instance_location].edges, value.to_string(), - reverse); + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + add_edge(accumulator[instance_location].edges, value.to_string(), + reverse); + } } else if (keyword.property_equals("x-jsonld-type", HASH_TYPE)) { - auto &types{accumulator[instance_location].types}; if (value.is_array()) { for (const auto &element : value.as_array()) { if (!is_iri_value(element)) { return type_iri_error(instance_location); } - - add_type(types, element.to_string()); - } - } else { - if (!is_iri_value(value)) { - return type_iri_error(instance_location); } + } else if (!value.is_null() && !is_iri_value(value)) { + return type_iri_error(instance_location); + } - add_type(types, value.to_string()); + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &types{accumulator[instance_location].types}; + if (value.is_array()) { + for (const auto &element : value.as_array()) { + add_type(types, element.to_string()); + } + } else { + add_type(types, value.to_string()); + } } } else if (keyword.property_equals("x-jsonld-datatype", HASH_DATATYPE)) { - auto &datatype{accumulator[instance_location].datatype}; - if (!is_iri_value(value)) { + if (!value.is_null() && !is_iri_value(value)) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, "The value of x-jsonld-datatype must be an absolute IRI"); } - const auto &text{value.to_string()}; - if (datatype.has_value() && datatype.value() != text) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Datatype, - "A JSON-LD datatype cannot be assigned more than one value"); + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + const auto &text{value.to_string()}; + if (facts.datatype.has_value() && facts.datatype.value() != text) { + demote(accumulator, dirty, instance_location); + } else { + facts.datatype = text; + } } - - datatype = text; } else if (keyword.property_equals("x-jsonld-language", HASH_LANGUAGE)) { - auto &language{accumulator[instance_location].language}; - if (!value.is_string() || - !sourcemeta::core::is_langtag(value.to_string())) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Language, - "The value of x-jsonld-language must be a BCP 47 language tag"); + if (!value.is_null() && + (!value.is_string() || + !sourcemeta::core::is_canonical_langtag(value.to_string()))) { + return facet_error(instance_location, + sourcemeta::blaze::JSONLDFacet::Language, + "The value of x-jsonld-language must be a " + "canonical BCP 47 language tag"); } - const auto &text{value.to_string()}; - if (language.has_value()) { - // Language tags compare case-insensitively, so the first spelling is - // kept and only a genuinely different tag is a conflict - if (!sourcemeta::core::equals_ignore_case(language.value(), text)) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Language, - "A JSON-LD language cannot be assigned more than one value"); + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + const auto &text{value.to_string()}; + if (facts.language.has_value() && facts.language.value() != text) { + demote(accumulator, dirty, instance_location); + } else { + facts.language = text; } - } else { - language = text; } } else if (keyword.property_equals("x-jsonld-direction", HASH_DIRECTION)) { - auto &direction{accumulator[instance_location].direction}; - const auto parsed{parse_direction(value)}; - if (!parsed.has_value()) { + const auto direction{parse_direction(value)}; + if (!value.is_null() && !direction.has_value()) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Direction, R"(The value of x-jsonld-direction must be "ltr" or "rtl")"); } - if (direction.has_value() && direction.value() != parsed.value()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Direction, - "A JSON-LD direction cannot be assigned more than one value"); - } - - direction = parsed; - } else if (keyword.property_equals("x-jsonld-json", HASH_JSON)) { - if (!value.is_boolean()) { - return facet_error(instance_location, - sourcemeta::blaze::JSONLDFacet::JSON, - "The value of x-jsonld-json must be a boolean"); - } - - // A false value leaves the fact unset, so it must stay a no-op and not - // create an entry - if (value.to_boolean()) { - accumulator[instance_location].json = true; + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + if (facts.direction.has_value() && facts.direction != direction) { + demote(accumulator, dirty, instance_location); + } else { + facts.direction = direction; + } } - } else if (keyword.property_equals("x-jsonld-graph", HASH_GRAPH)) { - if (!value.is_boolean()) { - return facet_error(instance_location, - sourcemeta::blaze::JSONLDFacet::Graph, - "The value of x-jsonld-graph must be a boolean"); + } else if (keyword.property_equals("x-jsonld-json", HASH_JSON) || + keyword.property_equals("x-jsonld-graph", HASH_GRAPH)) { + const bool graph{keyword.property_equals("x-jsonld-graph", HASH_GRAPH)}; + if (!value.is_null() && !value.is_boolean()) { + return facet_error( + instance_location, + graph ? sourcemeta::blaze::JSONLDFacet::Graph + : sourcemeta::blaze::JSONLDFacet::JSON, + graph ? "The value of x-jsonld-graph must be a boolean" + : "The value of x-jsonld-json must be a boolean"); } - if (value.to_boolean()) { - accumulator[instance_location].graph = true; + // A false declares nothing anywhere, as the absent state it restates + // already is false, so removal only ever spells null + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (value.to_boolean() && !dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + if (graph) { + facts.graph = true; + } else { + facts.json = true; + } } } else if (keyword.property_equals("x-jsonld-container", HASH_CONTAINER)) { - auto &container{accumulator[instance_location].container}; - const auto parsed{parse_container(value)}; - if (!parsed.has_value()) { + const auto container{parse_container(value)}; + if (!value.is_null() && !container.has_value()) { return facet_error( instance_location, sourcemeta::blaze::JSONLDFacet::Container, R"(The value of x-jsonld-container must be "@list", "@set", "@language", or "@index")"); } - if (container.has_value() && container.value() != parsed.value()) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Container, - "A JSON-LD container cannot be assigned more than one value"); - } - - container = parsed; - - if (container == sourcemeta::core::JSONLDContainer::Language) { - language_containers.push_back(std::cref(instance_location)); + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + if (facts.container.has_value() && facts.container != container) { + demote(accumulator, dirty, instance_location); + } else { + facts.container = container; + } } } else if (keyword.property_equals("x-jsonld-self", HASH_SELF)) { - auto &self{accumulator[instance_location].self}; - if (!value.is_string() || - !sourcemeta::core::URITemplate::is_uritemplate(value.to_string())) { + if (!value.is_null() && + (!value.is_string() || + !sourcemeta::core::URITemplate::is_uritemplate(value.to_string()))) { return facet_error(instance_location, sourcemeta::blaze::JSONLDFacet::Self, "The value of x-jsonld-self must be a URI Template"); } - const auto &text{value.to_string()}; - if (self.has_value() && self.value() != text) { - return facet_error( - instance_location, sourcemeta::blaze::JSONLDFacet::Self, - "A JSON-LD self identity cannot be assigned more than one value"); + if (value.is_null()) { + demote(accumulator, dirty, instance_location); + } else if (!dirty.contains(instance_location)) { + auto &facts{accumulator[instance_location]}; + const auto &text{value.to_string()}; + if (facts.self.has_value() && facts.self.value() != text) { + demote(accumulator, dirty, instance_location); + } else { + facts.self = text; + } + } + } + } + + PendingMap pending; + if (!dirty.empty()) { + pending.reserve(dirty.size()); + for (const auto &entry : output.annotations()) { + if (entry.evaluate_path.empty() || + !dirty.contains(entry.instance_location)) { + continue; } - self = text; + collect(entry, pending[entry.instance_location]); } } + for (auto &[location, entry] : pending) { + prepare(entry.ids, entry.marks); + prepare(entry.reverses, entry.marks); + prepare(entry.types, entry.marks); + prepare(entry.datatypes, entry.marks); + prepare(entry.languages, entry.marks); + prepare(entry.directions, entry.marks); + prepare(entry.jsons, entry.marks); + prepare(entry.graphs, entry.marks); + prepare(entry.containers, entry.marks); + prepare(entry.selves, entry.marks); + + std::erase_if(entry.jsons, false_boolean); + std::erase_if(entry.graphs, false_boolean); + + if (!entry.annotated()) { + continue; + } + + Facts facts; + resolve_edges(entry.ids, false, facts.edges); + resolve_edges(entry.reverses, true, facts.edges); + resolve_types(entry.types, facts.types); + + const auto datatype{elect(entry.datatypes, key_exact)}; + if (datatype.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::Datatype, + "A JSON-LD datatype cannot be assigned more than one value"); + } else if (datatype.winner != nullptr) { + facts.datatype = datatype.winner->value->to_string(); + } + + const auto language{elect(entry.languages, key_exact)}; + if (language.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::Language, + "A JSON-LD language cannot be assigned more than one value"); + } else if (language.winner != nullptr) { + facts.language = language.winner->value->to_string(); + } + + const auto direction{elect(entry.directions, key_exact)}; + if (direction.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::Direction, + "A JSON-LD direction cannot be assigned more than one value"); + } else if (direction.winner != nullptr) { + facts.direction = parse_direction(*direction.winner->value); + } + + const auto json{elect(entry.jsons, key_boolean)}; + if (json.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::JSON, + "A JSON-LD JSON literal flag cannot be assigned more than one " + "value"); + } else if (json.winner != nullptr) { + facts.json = true; + } + + const auto graph{elect(entry.graphs, key_boolean)}; + if (graph.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::Graph, + "A JSON-LD graph flag cannot be assigned more than one value"); + } else if (graph.winner != nullptr) { + facts.graph = true; + } + + const auto container{elect(entry.containers, key_exact)}; + if (container.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::Container, + "A JSON-LD container cannot be assigned more than one value"); + } else if (container.winner != nullptr) { + facts.container = parse_container(*container.winner->value); + } + + const auto self{elect(entry.selves, key_exact)}; + if (self.conflict) { + return facet_error( + location, sourcemeta::blaze::JSONLDFacet::Self, + "A JSON-LD self identity cannot be assigned more than one value"); + } else if (self.winner != nullptr) { + facts.self = self.winner->value->to_string(); + } + + accumulator.emplace(location, std::move(facts)); + } + // A language container materializes its members directly as language-tagged // strings, never consulting their descriptors, so a member cannot carry a // JSON-LD annotation of its own - for (const auto container_location : language_containers) { - const auto &members{ - sourcemeta::core::get(instance, container_location.get())}; + for (const auto &[container_location, container_facts] : accumulator) { + if (container_facts.container != + sourcemeta::core::JSONLDContainer::Language) { + continue; + } + + const auto &members{sourcemeta::core::get(instance, container_location)}; if (!members.is_object()) { continue; } for (const auto &entry : members.as_object()) { - auto member{container_location.get()}; + auto member{container_location}; member.push_back(std::cref(entry.first)); if (accumulator.contains(member)) { return facet_error(member, sourcemeta::blaze::JSONLDFacet::Container, diff --git a/vendor/core/CMakeLists.txt b/vendor/core/CMakeLists.txt index 1be12a1f..b74544ac 100644 --- a/vendor/core/CMakeLists.txt +++ b/vendor/core/CMakeLists.txt @@ -38,6 +38,7 @@ option(SOURCEMETA_CORE_HTTP "Build the Sourcemeta Core HTTP library" ON) option(SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL "Use system cURL for the Sourcemeta Core HTTP library" OFF) option(SOURCEMETA_CORE_JOSE "Build the Sourcemeta Core JOSE library" ON) option(SOURCEMETA_CORE_OAUTH "Build the Sourcemeta Core OAuth library" ON) +option(SOURCEMETA_CORE_OIDC "Build the Sourcemeta Core OIDC library" ON) option(SOURCEMETA_CORE_SEMVER "Build the Sourcemeta Core SemVer library" ON) option(SOURCEMETA_CORE_GZIP "Build the Sourcemeta Core GZIP library" ON) option(SOURCEMETA_CORE_HTML "Build the Sourcemeta Core HTML library" ON) @@ -226,6 +227,10 @@ if(SOURCEMETA_CORE_OAUTH) add_subdirectory(src/core/oauth) endif() +if(SOURCEMETA_CORE_OIDC) + add_subdirectory(src/core/oidc) +endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(src/core/semver) endif() @@ -398,6 +403,10 @@ if(SOURCEMETA_CORE_TESTS) add_subdirectory(test/oauth) endif() + if(SOURCEMETA_CORE_OIDC) + add_subdirectory(test/oidc) + endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(test/semver) endif() diff --git a/vendor/core/config.cmake.in b/vendor/core/config.cmake.in index fc7845b1..973d86cc 100644 --- a/vendor/core/config.cmake.in +++ b/vendor/core/config.cmake.in @@ -32,6 +32,7 @@ if(NOT SOURCEMETA_CORE_COMPONENTS) list(APPEND SOURCEMETA_CORE_COMPONENTS http) list(APPEND SOURCEMETA_CORE_COMPONENTS jose) list(APPEND SOURCEMETA_CORE_COMPONENTS oauth) + list(APPEND SOURCEMETA_CORE_COMPONENTS oidc) list(APPEND SOURCEMETA_CORE_COMPONENTS semver) list(APPEND SOURCEMETA_CORE_COMPONENTS gzip) list(APPEND SOURCEMETA_CORE_COMPONENTS html) @@ -248,7 +249,30 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_http.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_html.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_oauth.cmake") + elseif(component STREQUAL "oidc") + if(@SOURCEMETA_CORE_CRYPTO_USE_SYSTEM_OPENSSL@) + find_dependency(OpenSSL 3.0) + endif() + if(@SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL@) + find_dependency(CURL) + endif() + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_numeric.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_unicode.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_time.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_uri.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_http.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_html.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_oauth.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_oidc.cmake") elseif(component STREQUAL "semver") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") diff --git a/vendor/core/src/core/crypto/CMakeLists.txt b/vendor/core/src/core/crypto/CMakeLists.txt index 074e6510..693765c5 100644 --- a/vendor/core/src/core/crypto/CMakeLists.txt +++ b/vendor/core/src/core/crypto/CMakeLists.txt @@ -2,14 +2,17 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME crypto PRIVATE_HEADERS sha256.h hmac_sha256.h hmac_sha384.h hmac_sha512.h sha384.h sha512.h sha1.h fnv128.h - uuid.h crc32.h base64.h secure.h verify.h sign.h aes_gcm.h + uuid.h crc32.h base64.h secure.h verify.h sign.h aes_gcm.h aes_cbc_hmac.h + aes_kw.h rsa_oaep.h ecdh.h SOURCES crypto_sha256.cc crypto_hmac_sha256.cc crypto_hmac_sha384.cc crypto_hmac_sha512.cc crypto_sha384.cc crypto_sha512.cc crypto_sha1.cc crypto_uuid.cc crypto_fnv128.cc crypto_crc32.cc crypto_base64.cc - crypto_random.cc crypto_sign_rsa.cc crypto_aes_gcm.cc - crypto_sha2_64.h crypto_random.h crypto_helpers.h crypto_der.h crypto_aes.h) + crypto_random.cc crypto_sign_rsa.cc crypto_aes_gcm.cc crypto_aes_cbc_hmac.cc + crypto_aes_kw.cc crypto_ecdh.cc + crypto_sha2_64.h crypto_sha256_other.h crypto_random.h crypto_helpers.h + crypto_der.h crypto_aes.h) target_link_libraries(sourcemeta_core_crypto PRIVATE sourcemeta::core::text sourcemeta::core::numeric) @@ -22,7 +25,8 @@ if(SOURCEMETA_CORE_CRYPTO_USE_SYSTEM_OPENSSL) crypto_sha384_openssl.cc crypto_sha512_openssl.cc crypto_random_openssl.cc crypto_verify_openssl.cc crypto_secure_equals_openssl.cc - crypto_sign_openssl.cc crypto_aes_gcm_openssl.cc + crypto_sign_openssl.cc crypto_aes_gcm_openssl.cc crypto_aes_cbc_openssl.cc + crypto_aes_kw_openssl.cc crypto_rsa_oaep_openssl.cc crypto_ecdh_openssl.cc crypto_openssl.h crypto_pkcs8.h) target_link_libraries(sourcemeta_core_crypto PRIVATE OpenSSL::Crypto) elseif(APPLE) @@ -112,7 +116,8 @@ elseif(APPLE) crypto_sha384_apple.cc crypto_sha512_apple.cc crypto_random_apple.cc crypto_verify_apple.cc crypto_secure_equals_apple.cc - crypto_sign_apple.cc crypto_aes_gcm_apple.cc + crypto_sign_apple.cc crypto_aes_gcm_apple.cc crypto_aes_cbc_apple.cc + crypto_aes_kw_apple.cc crypto_rsa_oaep_apple.cc crypto_ecdh_apple.cc crypto_eddsa_cryptokit.mm "${CRYPTOKIT_OBJECT}" crypto_eddsa.h crypto_eddsa_apple.h crypto_apple.h crypto_aes_apple.h crypto_bignum.h crypto_shake256.h crypto_pkcs8.h) @@ -142,8 +147,10 @@ elseif(WIN32) crypto_sha384_windows.cc crypto_sha512_windows.cc crypto_random_windows.cc crypto_verify_windows.cc crypto_secure_equals_generic.cc - crypto_sign_windows.cc crypto_aes_gcm_windows.cc crypto_eddsa.h - crypto_windows.h + crypto_sign_windows.cc crypto_aes_gcm_windows.cc crypto_aes_cbc_windows.cc + crypto_aes_kw_windows.cc crypto_rsa_oaep_windows.cc crypto_ecdh_windows.cc + crypto_eddsa.h + crypto_windows.h crypto_aes_kw_loop.h crypto_bignum.h crypto_ecc.h crypto_shake256.h crypto_pkcs8.h) target_link_libraries(sourcemeta_core_crypto PRIVATE bcrypt) else() @@ -155,9 +162,11 @@ else() crypto_sha384_other.cc crypto_sha512_other.cc crypto_random_other.cc crypto_verify_other.cc crypto_secure_equals_generic.cc crypto_sign_other.cc - crypto_aes_gcm_other.cc + crypto_aes_gcm_other.cc crypto_aes_cbc_other.cc crypto_aes_kw_other.cc + crypto_rsa_oaep_other.cc crypto_ecdh_other.cc + crypto_aes_block.h crypto_aes_kw_loop.h crypto_sha1_other.h crypto_bignum.h crypto_ecc.h crypto_eddsa.h crypto_shake256.h - crypto_pkcs8.h) + crypto_pkcs8.h crypto_other.h) endif() if(SOURCEMETA_CORE_INSTALL) diff --git a/vendor/core/src/core/crypto/crypto_aes.h b/vendor/core/src/core/crypto/crypto_aes.h index 97dc20ef..41aac6df 100644 --- a/vendor/core/src/core/crypto/crypto_aes.h +++ b/vendor/core/src/core/crypto/crypto_aes.h @@ -1,29 +1,57 @@ #ifndef SOURCEMETA_CORE_CRYPTO_AES_H_ #define SOURCEMETA_CORE_CRYPTO_AES_H_ +#include + #include // std::optional #include // std::string #include // std::string_view namespace sourcemeta::core { -// The raw AES-256 Galois/Counter Mode primitive (NIST SP 800-38D) with a 96-bit -// nonce, no associated data, and a 128-bit tag, defined once per backend. The -// framing that adds the nonce and length checks lives in the shared seal and -// unseal functions, so these assume a 32-byte key and a 12-byte nonce +// The raw AES Galois/Counter Mode primitive (NIST SP 800-38D), defined once per +// backend, over a 128, 192, or 256-bit key, a 96-bit initialization vector, and +// associated data that is authenticated but not encrypted. The length checks +// live in the shared encrypt and decrypt functions, so these assume a 16, 24, +// or 32-byte key, a 12-byte initialization vector, and a 16-byte tag + +// Encrypt the plaintext, returning the ciphertext and its 16-byte tag +auto aes_gcm_seal(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional; + +// Decrypt the ciphertext, verifying the tag before returning the plaintext and +// rejecting a mismatch +auto aes_gcm_open(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, const std::string_view tag) + -> std::optional; + +// The raw AES Cipher Block Chaining primitive (NIST SP 800-38A), defined once +// per backend, over a 128, 192, or 256-bit key and a 128-bit initialization +// vector. The key splitting, padding, and authentication live in the shared +// functions, so these assume a 16, 24, or 32-byte key, a 16-byte initialization +// vector, and input that is a whole number of 16-byte blocks -// Encrypt the plaintext, returning the ciphertext followed by the 16-byte -// authentication tag -auto aes_256_gcm_encrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view plaintext) +auto aes_cbc_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view plaintext) -> std::optional; -// Decrypt the ciphertext, which is followed by its 16-byte tag, verifying the -// tag before returning the plaintext and rejecting a mismatch -auto aes_256_gcm_decrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view ciphertext) +auto aes_cbc_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view ciphertext) + -> std::optional; + +// The raw AES Key Wrap primitive (RFC 3394), defined once per backend, over a +// 128, 192, or 256-bit key-encryption key. The length checks live in the shared +// functions, so these assume a 16, 24, or 32-byte key-encryption key and input +// that is a whole number of 64-bit blocks + +auto aes_wrap(const std::string_view key_encryption_key, + const std::string_view key) -> std::optional; + +auto aes_unwrap(const std::string_view key_encryption_key, + const std::string_view wrapped_key) -> std::optional; } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_apple.h b/vendor/core/src/core/crypto/crypto_aes_apple.h index 3a403bb5..8285924d 100644 --- a/vendor/core/src/core/crypto/crypto_aes_apple.h +++ b/vendor/core/src/core/crypto/crypto_aes_apple.h @@ -3,22 +3,24 @@ #include // std::size_t -// Seal a plaintext with AES-256 in Galois/Counter Mode through CryptoKit, -// writing the ciphertext followed by the 16-byte tag into the output buffer, -// which the caller sizes to the plaintext length plus 16. Returns false when -// the key or nonce is not a valid length -extern "C" auto sourcemeta_core_aes_256_gcm_seal_cryptokit( +// Seal a plaintext with AES in Galois/Counter Mode through CryptoKit, writing +// the ciphertext (the plaintext length) and its 16-byte tag into the two output +// buffers. Returns false when the key, nonce, or a buffer is invalid +extern "C" auto sourcemeta_core_aes_gcm_seal_cryptokit( const unsigned char *key, std::size_t key_size, const unsigned char *nonce, std::size_t nonce_size, const unsigned char *plaintext, - std::size_t plaintext_size, unsigned char *output) -> bool; + std::size_t plaintext_size, const unsigned char *associated_data, + std::size_t associated_data_size, unsigned char *ciphertext_output, + unsigned char *tag_output) -> bool; // Open a message sealed with the function above, writing the plaintext into the // output buffer, which the caller sizes to the ciphertext length. Returns false // when the tag does not verify or the input is malformed -extern "C" auto sourcemeta_core_aes_256_gcm_open_cryptokit( +extern "C" auto sourcemeta_core_aes_gcm_open_cryptokit( const unsigned char *key, std::size_t key_size, const unsigned char *nonce, std::size_t nonce_size, const unsigned char *ciphertext, std::size_t ciphertext_size, const unsigned char *tag, std::size_t tag_size, + const unsigned char *associated_data, std::size_t associated_data_size, unsigned char *output) -> bool; #endif diff --git a/vendor/core/src/core/crypto/crypto_aes_block.h b/vendor/core/src/core/crypto/crypto_aes_block.h new file mode 100644 index 00000000..00248ca1 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_block.h @@ -0,0 +1,245 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_AES_BLOCK_H_ +#define SOURCEMETA_CORE_CRYPTO_AES_BLOCK_H_ + +// The AES block cipher (FIPS 197) for the reference backend, shared by the +// modes of operation built on top of it. This is not constant-time, which is +// acceptable only because this backend is the non-production fallback. Both the +// forward and the inverse cipher over 128, 192, and 256-bit keys are provided, +// sharing the single key schedule + +#include // std::array +#include // assert +#include // std::size_t +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +// The Rijndael substitution box (FIPS 197 Figure 7) +inline constexpr std::array aes_substitution{ + {0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, + 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, + 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, + 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, + 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, + 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, + 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, + 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, + 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, + 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, + 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, + 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, + 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, + 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, + 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, + 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, + 0xb0, 0x54, 0xbb, 0x16}}; + +// The inverse of the Rijndael substitution box (FIPS 197 Figure 14) +inline constexpr std::array aes_inverse_substitution{ + {0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, + 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, + 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, + 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, + 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, + 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, + 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, + 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, + 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, + 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, + 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, + 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, + 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, + 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, + 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, + 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, + 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, + 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, + 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, + 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, + 0x55, 0x21, 0x0c, 0x7d}}; + +using AesBlock = std::array; +// The expanded key material fits the largest schedule, the 256-bit key over 14 +// rounds (FIPS 197 Section 5.2) +using AesRoundKeys = std::array; + +// A key schedule together with its round count, since AES-128, AES-192, and +// AES-256 run 10, 12, and 14 rounds respectively (FIPS 197 Section 5.1) +struct AesKeySchedule { + AesRoundKeys round_keys; + std::size_t rounds; +}; + +// Multiply by two in GF(2^8) with the AES reduction polynomial (FIPS 197 +// Section 4.2) +inline auto aes_xtime(const std::uint8_t value) -> std::uint8_t { + return static_cast((static_cast(value) << 1u) ^ + (((value & 0x80u) != 0) ? 0x1bu : 0x00u)); +} + +inline auto aes_field_multiply(const std::uint8_t left, + const std::uint8_t right) -> std::uint8_t { + std::uint8_t product{0}; + std::uint8_t factor{left}; + for (std::uint8_t bit{right}; bit != 0; bit >>= 1u) { + if ((bit & 1u) != 0) { + product ^= factor; + } + + factor = aes_xtime(factor); + } + + return product; +} + +// AES key expansion (FIPS 197 Section 5.2) over a 128, 192, or 256-bit key +inline auto aes_expand_key(const std::string_view key) -> AesKeySchedule { + assert(key.size() == 16 || key.size() == 24 || key.size() == 32); + // The number of 32-bit words in the key, four, six, or eight (FIPS 197 Table + // 4), and the round count it yields + const auto key_words{key.size() / 4}; + const auto rounds{key_words + 6}; + AesKeySchedule schedule{.round_keys = {}, .rounds = rounds}; + auto &round_keys{schedule.round_keys}; + for (std::size_t index = 0; index < key.size(); ++index) { + round_keys[index] = static_cast(key[index]); + } + + const auto expanded_bytes{16 * (rounds + 1)}; + std::uint8_t round_constant{0x01}; + for (std::size_t index = key.size(); index < expanded_bytes; index += 4) { + std::array word{ + {round_keys[index - 4], round_keys[index - 3], round_keys[index - 2], + round_keys[index - 1]}}; + const auto position{index / 4}; + if (position % key_words == 0) { + const auto first{word[0]}; + word[0] = aes_substitution[word[1]] ^ round_constant; + word[1] = aes_substitution[word[2]]; + word[2] = aes_substitution[word[3]]; + word[3] = aes_substitution[first]; + round_constant = aes_xtime(round_constant); + } else if (key_words > 6 && position % key_words == 4) { + // The extra substitution mid-schedule applies only to the 256-bit key + for (auto &byte : word) { + byte = aes_substitution[byte]; + } + } + + for (std::size_t offset = 0; offset < 4; ++offset) { + round_keys[index + offset] = + round_keys[index - key.size() + offset] ^ word[offset]; + } + } + + return schedule; +} + +inline auto aes_encrypt_block(const AesKeySchedule &schedule, AesBlock state) + -> AesBlock { + const auto &round_keys{schedule.round_keys}; + const auto add_round_key{[&round_keys, &state](const std::size_t round) { + for (std::size_t index = 0; index < 16; ++index) { + state[index] ^= round_keys[(round * 16) + index]; + } + }}; + + add_round_key(0); + for (std::size_t round = 1; round <= schedule.rounds; ++round) { + for (auto &byte : state) { + byte = aes_substitution[byte]; + } + + // ShiftRows over the column-major state (FIPS 197 Section 5.1.2) + const AesBlock shifted{{state[0], state[5], state[10], state[15], state[4], + state[9], state[14], state[3], state[8], state[13], + state[2], state[7], state[12], state[1], state[6], + state[11]}}; + state = shifted; + + if (round != schedule.rounds) { + // MixColumns (FIPS 197 Section 5.1.3) + for (std::size_t column = 0; column < 4; ++column) { + const auto base{column * 4}; + const auto first{state[base]}; + const auto second{state[base + 1]}; + const auto third{state[base + 2]}; + const auto fourth{state[base + 3]}; + state[base] = aes_field_multiply(first, 2) ^ + aes_field_multiply(second, 3) ^ third ^ fourth; + state[base + 1] = first ^ aes_field_multiply(second, 2) ^ + aes_field_multiply(third, 3) ^ fourth; + state[base + 2] = first ^ second ^ aes_field_multiply(third, 2) ^ + aes_field_multiply(fourth, 3); + state[base + 3] = aes_field_multiply(first, 3) ^ second ^ third ^ + aes_field_multiply(fourth, 2); + } + } + + add_round_key(round); + } + + return state; +} + +inline auto aes_decrypt_block(const AesKeySchedule &schedule, AesBlock state) + -> AesBlock { + const auto &round_keys{schedule.round_keys}; + const auto add_round_key{[&round_keys, &state](const std::size_t round) { + for (std::size_t index = 0; index < 16; ++index) { + state[index] ^= round_keys[(round * 16) + index]; + } + }}; + + add_round_key(schedule.rounds); + for (std::size_t round = schedule.rounds; round-- > 0;) { + // InvShiftRows over the column-major state (FIPS 197 Section 5.3.1) + const AesBlock shifted{{state[0], state[13], state[10], state[7], state[4], + state[1], state[14], state[11], state[8], state[5], + state[2], state[15], state[12], state[9], state[6], + state[3]}}; + state = shifted; + + for (auto &byte : state) { + byte = aes_inverse_substitution[byte]; + } + + add_round_key(round); + + if (round != 0) { + // InvMixColumns (FIPS 197 Section 5.3.3) + for (std::size_t column = 0; column < 4; ++column) { + const auto base{column * 4}; + const auto first{state[base]}; + const auto second{state[base + 1]}; + const auto third{state[base + 2]}; + const auto fourth{state[base + 3]}; + state[base] = + aes_field_multiply(first, 0x0e) ^ aes_field_multiply(second, 0x0b) ^ + aes_field_multiply(third, 0x0d) ^ aes_field_multiply(fourth, 0x09); + state[base + 1] = + aes_field_multiply(first, 0x09) ^ aes_field_multiply(second, 0x0e) ^ + aes_field_multiply(third, 0x0b) ^ aes_field_multiply(fourth, 0x0d); + state[base + 2] = + aes_field_multiply(first, 0x0d) ^ aes_field_multiply(second, 0x09) ^ + aes_field_multiply(third, 0x0e) ^ aes_field_multiply(fourth, 0x0b); + state[base + 3] = + aes_field_multiply(first, 0x0b) ^ aes_field_multiply(second, 0x0d) ^ + aes_field_multiply(third, 0x09) ^ aes_field_multiply(fourth, 0x0e); + } + } + } + + return state; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_aes_cbc_apple.cc b/vendor/core/src/core/crypto/crypto_aes_cbc_apple.cc new file mode 100644 index 00000000..c5804ffc --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_cbc_apple.cc @@ -0,0 +1,43 @@ +#include "crypto_aes.h" + +#include // CCCrypt, kCC* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace { +// CommonCrypto runs AES in Cipher Block Chaining mode with no padding when no +// option is set, over whole blocks, with the key length selecting the variant +auto apply_cbc(const CCOperation operation, const std::string_view key, + const std::string_view iv, const std::string_view input) + -> std::optional { + std::string output(input.size(), '\x00'); + std::size_t moved{0}; + const auto status{CCCrypt(operation, kCCAlgorithmAES, 0, key.data(), + key.size(), iv.data(), input.data(), input.size(), + output.data(), output.size(), &moved)}; + if (status != kCCSuccess || moved != input.size()) { + return std::nullopt; + } + + return output; +} +} // namespace + +namespace sourcemeta::core { + +auto aes_cbc_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view plaintext) + -> std::optional { + return apply_cbc(kCCEncrypt, key, iv, plaintext); +} + +auto aes_cbc_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view ciphertext) + -> std::optional { + return apply_cbc(kCCDecrypt, key, iv, ciphertext); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_cbc_hmac.cc b/vendor/core/src/core/crypto/crypto_aes_cbc_hmac.cc new file mode 100644 index 00000000..8821cdfe --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_cbc_hmac.cc @@ -0,0 +1,168 @@ +#include +#include + +#include "crypto_aes.h" + +#include // std::size_t +#include // std::uint8_t, std::uint64_t +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move, std::unreachable + +namespace sourcemeta::core { + +namespace { +// A 128-bit initialization vector and the AES block size (NIST SP 800-38A) +constexpr std::size_t IV_BYTES{16}; +constexpr std::size_t BLOCK_BYTES{16}; +// An upper bound the backends can all process without narrowing their lengths +constexpr std::size_t MAX_INPUT_BYTES{ + static_cast(std::numeric_limits::max())}; + +// A 256, 384, or 512-bit key selects A128CBC-HS256, A192CBC-HS384, or +// A256CBC-HS512, split into equal halves for the MAC and the cipher (RFC 7518 +// Section 5.2.2.1) +auto is_valid_key_size(const std::size_t size) -> bool { + return size == 32 || size == 48 || size == 64; +} + +// The associated data length in bits as a 64-bit big-endian integer, the final +// input to the MAC (RFC 7518 Section 5.2.2.1) +auto associated_data_length_block(const std::size_t associated_data_size) + -> std::string { + std::string block(8, '\x00'); + const auto bits{static_cast(associated_data_size) * 8}; + for (std::size_t index = 0; index < 8; ++index) { + block[7 - index] = static_cast((bits >> (8 * index)) & 0xffu); + } + + return block; +} + +// The HMAC over the associated data, the initialization vector, the ciphertext, +// and the length block, truncated to the tag length that the variant defines +auto authentication_tag(const std::size_t tag_length, + const std::string_view mac_key, + const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext) -> std::string { + const auto length_block{associated_data_length_block(associated_data.size())}; + // The MAC input is the associated data, the initialization vector, the + // ciphertext, and the length block concatenated (RFC 7518 Section 5.2.2.1) + std::string message; + message.reserve(associated_data.size() + iv.size() + ciphertext.size() + + length_block.size()); + message.append(associated_data); + message.append(iv); + message.append(ciphertext); + message.append(length_block); + switch (tag_length) { + case 16: { + const auto digest{hmac_sha256_digest(mac_key, message)}; + return std::string{reinterpret_cast(digest.data()), + tag_length}; + } + case 24: { + const auto digest{hmac_sha384_digest(mac_key, message)}; + return std::string{reinterpret_cast(digest.data()), + tag_length}; + } + case 32: { + const auto digest{hmac_sha512_digest(mac_key, message)}; + return std::string{reinterpret_cast(digest.data()), + tag_length}; + } + default: + std::unreachable(); + } +} +} // namespace + +auto aes_cbc_hmac_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { + // The plaintext bound leaves a whole block of headroom, since the padding + // grows the buffer by up to that much and the backends narrow the padded + // length to an int + if (!is_valid_key_size(key.size()) || iv.size() != IV_BYTES || + plaintext.size() > MAX_INPUT_BYTES - BLOCK_BYTES || + associated_data.size() > MAX_INPUT_BYTES) { + return std::nullopt; + } + + const auto half{key.size() / 2}; + const auto mac_key{key.substr(0, half)}; + const auto encryption_key{key.substr(half)}; + + // PKCS#7 padding always adds between one and a whole block of bytes, each + // equal to the number added (RFC 5652 Section 6.3) + const auto padding{BLOCK_BYTES - (plaintext.size() % BLOCK_BYTES)}; + std::string padded{plaintext}; + padded.append(padding, static_cast(padding)); + + auto ciphertext{aes_cbc_encrypt(encryption_key, iv, padded)}; + if (!ciphertext.has_value()) { + return std::nullopt; + } + + const auto tag{authentication_tag(half, mac_key, iv, associated_data, + ciphertext.value())}; + AESCBCHMACCiphertext result{.data = std::move(ciphertext).value(), + .tag_length = half}; + result.data.append(tag); + return result; +} + +auto aes_cbc_hmac_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, + const std::string_view tag) + -> std::optional { + const auto half{key.size() / 2}; + if (!is_valid_key_size(key.size()) || iv.size() != IV_BYTES || + tag.size() != half || ciphertext.empty() || + ciphertext.size() % BLOCK_BYTES != 0 || + ciphertext.size() > MAX_INPUT_BYTES || + associated_data.size() > MAX_INPUT_BYTES) { + return std::nullopt; + } + + const auto mac_key{key.substr(0, half)}; + const auto encryption_key{key.substr(half)}; + + // Verify the tag before, and independent of, decrypting so a padding oracle + // is never reachable (encrypt-then-MAC) + const auto expected{ + authentication_tag(half, mac_key, iv, associated_data, ciphertext)}; + if (!secure_equals(expected, tag)) { + return std::nullopt; + } + + auto padded{aes_cbc_decrypt(encryption_key, iv, ciphertext)}; + if (!padded.has_value()) { + return std::nullopt; + } + + // Strip and validate the PKCS#7 padding, safe to do in variable time here + // since the authentication already succeeded + const auto &plaintext{padded.value()}; + const auto padding{ + static_cast(static_cast(plaintext.back()))}; + if (padding == 0 || padding > BLOCK_BYTES || padding > plaintext.size()) { + return std::nullopt; + } + + for (std::size_t index = plaintext.size() - padding; index < plaintext.size(); + ++index) { + if (static_cast(plaintext[index]) != padding) { + return std::nullopt; + } + } + + return plaintext.substr(0, plaintext.size() - padding); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_cbc_openssl.cc b/vendor/core/src/core/crypto/crypto_aes_cbc_openssl.cc new file mode 100644 index 00000000..6244cfb7 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_cbc_openssl.cc @@ -0,0 +1,94 @@ +#include "crypto_aes.h" + +#include // EVP_* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move + +namespace { +auto cipher_for_key(const std::size_t key_size) -> const EVP_CIPHER * { + switch (key_size) { + case 16: + return EVP_aes_128_cbc(); + case 24: + return EVP_aes_192_cbc(); + case 32: + return EVP_aes_256_cbc(); + default: + return nullptr; + } +} +} // namespace + +namespace sourcemeta::core { + +auto aes_cbc_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view plaintext) + -> std::optional { + const auto *const cipher{cipher_for_key(key.size())}; + auto *context{EVP_CIPHER_CTX_new()}; + if (cipher == nullptr || context == nullptr) { + EVP_CIPHER_CTX_free(context); + return std::nullopt; + } + + std::optional result; + std::string ciphertext(plaintext.size(), '\x00'); + auto *const output{reinterpret_cast(ciphertext.data())}; + int length{0}; + int final_length{0}; + if (EVP_EncryptInit_ex(context, cipher, nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data())) == + 1 && + // The shared caller applies the padding, so the cipher runs over whole + // blocks with its own padding disabled + EVP_CIPHER_CTX_set_padding(context, 0) == 1 && + EVP_EncryptUpdate( + context, output, &length, + reinterpret_cast(plaintext.data()), + static_cast(plaintext.size())) == 1 && + EVP_EncryptFinal_ex(context, output + length, &final_length) == 1) { + result = std::move(ciphertext); + } + + EVP_CIPHER_CTX_free(context); + return result; +} + +auto aes_cbc_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view ciphertext) + -> std::optional { + const auto *const cipher{cipher_for_key(key.size())}; + auto *context{EVP_CIPHER_CTX_new()}; + if (cipher == nullptr || context == nullptr) { + EVP_CIPHER_CTX_free(context); + return std::nullopt; + } + + std::optional result; + std::string plaintext(ciphertext.size(), '\x00'); + auto *const output{reinterpret_cast(plaintext.data())}; + int length{0}; + int final_length{0}; + if (EVP_DecryptInit_ex(context, cipher, nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data())) == + 1 && + EVP_CIPHER_CTX_set_padding(context, 0) == 1 && + EVP_DecryptUpdate( + context, output, &length, + reinterpret_cast(ciphertext.data()), + static_cast(ciphertext.size())) == 1 && + EVP_DecryptFinal_ex(context, output + length, &final_length) == 1) { + result = std::move(plaintext); + } + + EVP_CIPHER_CTX_free(context); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_cbc_other.cc b/vendor/core/src/core/crypto/crypto_aes_cbc_other.cc new file mode 100644 index 00000000..9779f68d --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_cbc_other.cc @@ -0,0 +1,76 @@ +#include "crypto_aes.h" +#include "crypto_aes_block.h" + +#include // assert +#include // std::size_t +#include // std::uint8_t +#include // std::optional +#include // std::string +#include // std::string_view + +// A from-scratch AES in Cipher Block Chaining mode (NIST SP 800-38A) for the +// reference backend, over the shared AES block cipher. This is not +// constant-time, which is acceptable only because this backend is the +// non-production fallback + +namespace sourcemeta::core { +namespace { +constexpr std::size_t BLOCK_BYTES{16}; + +auto load_block(const std::string_view data, const std::size_t offset) + -> AesBlock { + AesBlock block{}; + for (std::size_t index = 0; index < BLOCK_BYTES; ++index) { + block[index] = static_cast(data[offset + index]); + } + + return block; +} +} // namespace + +auto aes_cbc_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view plaintext) + -> std::optional { + assert(plaintext.size() % BLOCK_BYTES == 0); + const auto schedule{aes_expand_key(key)}; + std::string ciphertext(plaintext.size(), '\x00'); + auto chain{load_block(iv, 0)}; + for (std::size_t offset = 0; offset < plaintext.size(); + offset += BLOCK_BYTES) { + auto block{load_block(plaintext, offset)}; + for (std::size_t index = 0; index < BLOCK_BYTES; ++index) { + block[index] ^= chain[index]; + } + + chain = aes_encrypt_block(schedule, block); + for (std::size_t index = 0; index < BLOCK_BYTES; ++index) { + ciphertext[offset + index] = static_cast(chain[index]); + } + } + + return ciphertext; +} + +auto aes_cbc_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view ciphertext) + -> std::optional { + assert(ciphertext.size() % BLOCK_BYTES == 0); + const auto schedule{aes_expand_key(key)}; + std::string plaintext(ciphertext.size(), '\x00'); + auto chain{load_block(iv, 0)}; + for (std::size_t offset = 0; offset < ciphertext.size(); + offset += BLOCK_BYTES) { + const auto block{load_block(ciphertext, offset)}; + const auto decrypted{aes_decrypt_block(schedule, block)}; + for (std::size_t index = 0; index < BLOCK_BYTES; ++index) { + plaintext[offset + index] = + static_cast(decrypted[index] ^ chain[index]); + } + + chain = block; + } + + return plaintext; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_cbc_windows.cc b/vendor/core/src/core/crypto/crypto_aes_cbc_windows.cc new file mode 100644 index 00000000..f0b52eb7 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_cbc_windows.cc @@ -0,0 +1,91 @@ +#include "crypto_aes.h" + +#include // ULONG, PUCHAR +// clang-format off +#include // BCrypt*, BCRYPT_* +// clang-format on + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move + +namespace { +auto as_buffer(const std::string_view value) -> PUCHAR { + return reinterpret_cast(const_cast( + value.data())); // NOLINT(cppcoreguidelines-pro-type-const-cast) +} + +auto as_buffer(std::string &value) -> PUCHAR { + return reinterpret_cast(value.data()); +} + +// The chaining mode is a wide string constant rather than caller-owned bytes +auto as_buffer(const wchar_t *const value) -> PUCHAR { + return reinterpret_cast(const_cast( + value)); // NOLINT(cppcoreguidelines-pro-type-const-cast) +} + +// Both directions share the setup, differing only in the transform, since +// BCryptEncrypt and BCryptDecrypt take the same arguments over whole blocks +auto apply_cbc(const bool encrypt, const std::string_view key, + const std::string_view iv, const std::string_view input) + -> std::optional { + BCRYPT_ALG_HANDLE algorithm{nullptr}; + if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( + &algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0))) { + return std::nullopt; + } + + std::optional result; + BCRYPT_KEY_HANDLE key_handle{nullptr}; + if (BCRYPT_SUCCESS(BCryptSetProperty(algorithm, BCRYPT_CHAINING_MODE, + as_buffer(BCRYPT_CHAIN_MODE_CBC), + sizeof(BCRYPT_CHAIN_MODE_CBC), 0)) && + BCRYPT_SUCCESS(BCryptGenerateSymmetricKey( + algorithm, &key_handle, nullptr, 0, as_buffer(key), + static_cast(key.size()), 0))) { + // The chaining vector is updated in place, so it is copied from the caller + std::string chaining{iv}; + std::string output(input.size(), '\x00'); + ULONG written{0}; + const auto status{ + encrypt ? BCryptEncrypt(key_handle, as_buffer(input), + static_cast(input.size()), nullptr, + as_buffer(chaining), + static_cast(chaining.size()), + as_buffer(output), + static_cast(output.size()), &written, 0) + : BCryptDecrypt( + key_handle, as_buffer(input), + static_cast(input.size()), nullptr, + as_buffer(chaining), static_cast(chaining.size()), + as_buffer(output), static_cast(output.size()), + &written, 0)}; + if (BCRYPT_SUCCESS(status)) { + result = std::move(output); + } + + BCryptDestroyKey(key_handle); + } + + BCryptCloseAlgorithmProvider(algorithm, 0); + return result; +} +} // namespace + +namespace sourcemeta::core { + +auto aes_cbc_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view plaintext) + -> std::optional { + return apply_cbc(true, key, iv, plaintext); +} + +auto aes_cbc_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view ciphertext) + -> std::optional { + return apply_cbc(false, key, iv, ciphertext); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_gcm.cc b/vendor/core/src/core/crypto/crypto_aes_gcm.cc index 68dd4c48..cf94b19e 100644 --- a/vendor/core/src/core/crypto/crypto_aes_gcm.cc +++ b/vendor/core/src/core/crypto/crypto_aes_gcm.cc @@ -15,52 +15,84 @@ namespace sourcemeta::core { namespace { -// A 256-bit key, a 96-bit nonce, and a 128-bit tag (NIST SP 800-38D) -constexpr std::size_t KEY_BYTES{32}; -constexpr std::size_t NONCE_BYTES{12}; +// A 96-bit initialization vector and a 128-bit tag (NIST SP 800-38D) +constexpr std::size_t IV_BYTES{12}; constexpr std::size_t TAG_BYTES{16}; +// A 256-bit key, the only size the self-contained seal accepts +constexpr std::size_t SEAL_KEY_BYTES{32}; // An upper bound the backends can all process without narrowing their lengths constexpr std::size_t MAX_INPUT_BYTES{ static_cast(std::numeric_limits::max())}; + +auto is_valid_key_size(const std::size_t size) -> bool { + return size == 16 || size == 24 || size == 32; +} } // namespace +auto aes_gcm_encrypt(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { + if (!is_valid_key_size(key.size()) || iv.size() != IV_BYTES || + plaintext.size() > MAX_INPUT_BYTES || + associated_data.size() > MAX_INPUT_BYTES) { + return std::nullopt; + } + + return aes_gcm_seal(key, iv, associated_data, plaintext); +} + +auto aes_gcm_decrypt(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, + const std::string_view tag) -> std::optional { + if (!is_valid_key_size(key.size()) || iv.size() != IV_BYTES || + tag.size() != TAG_BYTES || ciphertext.size() > MAX_INPUT_BYTES || + associated_data.size() > MAX_INPUT_BYTES) { + return std::nullopt; + } + + return aes_gcm_open(key, iv, associated_data, ciphertext, tag); +} + auto aes_256_gcm_seal(const std::string_view key, const std::string_view plaintext) -> std::optional { - if (key.size() != KEY_BYTES || plaintext.size() > MAX_INPUT_BYTES) { + if (key.size() != SEAL_KEY_BYTES) { return std::nullopt; } - std::string nonce(NONCE_BYTES, '\x00'); + std::string nonce(IV_BYTES, '\x00'); try { fill_random_bytes(std::span{ - reinterpret_cast(nonce.data()), NONCE_BYTES}); + reinterpret_cast(nonce.data()), IV_BYTES}); } catch (const std::exception &) { return std::nullopt; } - const auto ciphertext{aes_256_gcm_encrypt(key, nonce, plaintext)}; - if (!ciphertext.has_value()) { + const auto result{aes_gcm_encrypt(key, nonce, "", plaintext)}; + if (!result.has_value()) { return std::nullopt; } // The sealed message is the nonce followed by the ciphertext and its tag std::string sealed{std::move(nonce)}; - sealed.append(ciphertext.value()); + sealed.append(result.value().data); return sealed; } auto aes_256_gcm_unseal(const std::string_view key, const std::string_view sealed) -> std::optional { - if (key.size() != KEY_BYTES || sealed.size() < NONCE_BYTES + TAG_BYTES || - sealed.size() - NONCE_BYTES - TAG_BYTES > MAX_INPUT_BYTES) { + if (key.size() != SEAL_KEY_BYTES || sealed.size() < IV_BYTES + TAG_BYTES) { return std::nullopt; } - const auto nonce{sealed.substr(0, NONCE_BYTES)}; - const auto ciphertext{sealed.substr(NONCE_BYTES)}; - return aes_256_gcm_decrypt(key, nonce, ciphertext); + const auto nonce{sealed.substr(0, IV_BYTES)}; + const auto ciphertext{ + sealed.substr(IV_BYTES, sealed.size() - IV_BYTES - TAG_BYTES)}; + const auto tag{sealed.substr(sealed.size() - TAG_BYTES)}; + return aes_gcm_decrypt(key, nonce, "", ciphertext, tag); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_gcm_apple.cc b/vendor/core/src/core/crypto/crypto_aes_gcm_apple.cc index e4c40acf..b5b326c4 100644 --- a/vendor/core/src/core/crypto/crypto_aes_gcm_apple.cc +++ b/vendor/core/src/core/crypto/crypto_aes_gcm_apple.cc @@ -13,39 +13,42 @@ constexpr std::size_t TAG_BYTES{16}; namespace sourcemeta::core { -auto aes_256_gcm_encrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view plaintext) - -> std::optional { - std::string output(plaintext.size() + TAG_BYTES, '\x00'); - if (!sourcemeta_core_aes_256_gcm_seal_cryptokit( +auto aes_gcm_seal(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { + // A single buffer holds the ciphertext followed by its 16-byte tag, which the + // shim writes into through the two output pointers + AESGCMCiphertext result{ + .data = std::string(plaintext.size() + TAG_BYTES, '\x00')}; + auto *const output{reinterpret_cast(result.data.data())}; + if (!sourcemeta_core_aes_gcm_seal_cryptokit( reinterpret_cast(key.data()), key.size(), - reinterpret_cast(nonce.data()), nonce.size(), + reinterpret_cast(iv.data()), iv.size(), reinterpret_cast(plaintext.data()), - plaintext.size(), reinterpret_cast(output.data()))) { + plaintext.size(), + reinterpret_cast(associated_data.data()), + associated_data.size(), output, output + plaintext.size())) { return std::nullopt; } - return output; + return result; } -auto aes_256_gcm_decrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view ciphertext) +auto aes_gcm_open(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, const std::string_view tag) -> std::optional { - if (ciphertext.size() < TAG_BYTES) { - return std::nullopt; - } - - const auto message{ciphertext.substr(0, ciphertext.size() - TAG_BYTES)}; - const auto tag{ciphertext.substr(ciphertext.size() - TAG_BYTES)}; - std::string output(message.size(), '\x00'); - if (!sourcemeta_core_aes_256_gcm_open_cryptokit( + std::string output(ciphertext.size(), '\x00'); + if (!sourcemeta_core_aes_gcm_open_cryptokit( reinterpret_cast(key.data()), key.size(), - reinterpret_cast(nonce.data()), nonce.size(), - reinterpret_cast(message.data()), - message.size(), reinterpret_cast(tag.data()), - tag.size(), reinterpret_cast(output.data()))) { + reinterpret_cast(iv.data()), iv.size(), + reinterpret_cast(ciphertext.data()), + ciphertext.size(), + reinterpret_cast(tag.data()), tag.size(), + reinterpret_cast(associated_data.data()), + associated_data.size(), + reinterpret_cast(output.data()))) { return std::nullopt; } diff --git a/vendor/core/src/core/crypto/crypto_aes_gcm_openssl.cc b/vendor/core/src/core/crypto/crypto_aes_gcm_openssl.cc index 17e23de5..1e797331 100644 --- a/vendor/core/src/core/crypto/crypto_aes_gcm_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_aes_gcm_openssl.cc @@ -10,84 +10,102 @@ namespace { constexpr std::size_t TAG_BYTES{16}; + +auto cipher_for_key(const std::size_t key_size) -> const EVP_CIPHER * { + switch (key_size) { + case 16: + return EVP_aes_128_gcm(); + case 24: + return EVP_aes_192_gcm(); + case 32: + return EVP_aes_256_gcm(); + default: + return nullptr; + } +} } // namespace namespace sourcemeta::core { -auto aes_256_gcm_encrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view plaintext) - -> std::optional { +auto aes_gcm_seal(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { + const auto *const cipher{cipher_for_key(key.size())}; auto *context{EVP_CIPHER_CTX_new()}; - if (context == nullptr) { + if (cipher == nullptr || context == nullptr) { + EVP_CIPHER_CTX_free(context); return std::nullopt; } - std::optional result; - std::string ciphertext(plaintext.size(), '\x00'); - std::string tag(TAG_BYTES, '\x00'); + std::optional result; + // A single buffer holds the ciphertext followed by its 16-byte tag + std::string data(plaintext.size() + TAG_BYTES, '\x00'); + auto *const output{reinterpret_cast(data.data())}; int length{0}; int final_length{0}; - if (EVP_EncryptInit_ex(context, EVP_aes_256_gcm(), nullptr, nullptr, - nullptr) == 1 && + int associated_length{0}; + if (EVP_EncryptInit_ex(context, cipher, nullptr, nullptr, nullptr) == 1 && EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, - static_cast(nonce.size()), nullptr) == 1 && - EVP_EncryptInit_ex( - context, nullptr, nullptr, - reinterpret_cast(key.data()), - reinterpret_cast(nonce.data())) == 1 && + static_cast(iv.size()), nullptr) == 1 && + EVP_EncryptInit_ex(context, nullptr, nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data())) == + 1 && + (associated_data.empty() || + EVP_EncryptUpdate( + context, nullptr, &associated_length, + reinterpret_cast(associated_data.data()), + static_cast(associated_data.size())) == 1) && (plaintext.empty() || EVP_EncryptUpdate( - context, reinterpret_cast(ciphertext.data()), - &length, reinterpret_cast(plaintext.data()), + context, output, &length, + reinterpret_cast(plaintext.data()), static_cast(plaintext.size())) == 1) && - EVP_EncryptFinal_ex(context, - reinterpret_cast(ciphertext.data()) + - length, - &final_length) == 1 && + EVP_EncryptFinal_ex(context, output + length, &final_length) == 1 && EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_GET_TAG, static_cast(TAG_BYTES), - reinterpret_cast(tag.data())) == 1) { - ciphertext.append(tag); - result = std::move(ciphertext); + output + plaintext.size()) == 1) { + result = AESGCMCiphertext{.data = std::move(data)}; } EVP_CIPHER_CTX_free(context); return result; } -auto aes_256_gcm_decrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view ciphertext) +auto aes_gcm_open(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, const std::string_view tag) -> std::optional { - if (ciphertext.size() < TAG_BYTES) { - return std::nullopt; - } - - const auto message{ciphertext.substr(0, ciphertext.size() - TAG_BYTES)}; - const auto tag{ciphertext.substr(ciphertext.size() - TAG_BYTES)}; + const auto *const cipher{cipher_for_key(key.size())}; auto *context{EVP_CIPHER_CTX_new()}; - if (context == nullptr) { + if (cipher == nullptr || context == nullptr) { + EVP_CIPHER_CTX_free(context); return std::nullopt; } std::optional result; - std::string plaintext(message.size(), '\x00'); + std::string plaintext(ciphertext.size(), '\x00'); int length{0}; int final_length{0}; - if (EVP_DecryptInit_ex(context, EVP_aes_256_gcm(), nullptr, nullptr, - nullptr) == 1 && + int associated_length{0}; + if (EVP_DecryptInit_ex(context, cipher, nullptr, nullptr, nullptr) == 1 && EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, - static_cast(nonce.size()), nullptr) == 1 && - EVP_DecryptInit_ex( - context, nullptr, nullptr, - reinterpret_cast(key.data()), - reinterpret_cast(nonce.data())) == 1 && - (message.empty() || + static_cast(iv.size()), nullptr) == 1 && + EVP_DecryptInit_ex(context, nullptr, nullptr, + reinterpret_cast(key.data()), + reinterpret_cast(iv.data())) == + 1 && + (associated_data.empty() || + EVP_DecryptUpdate( + context, nullptr, &associated_length, + reinterpret_cast(associated_data.data()), + static_cast(associated_data.size())) == 1) && + (ciphertext.empty() || EVP_DecryptUpdate( context, reinterpret_cast(plaintext.data()), - &length, reinterpret_cast(message.data()), - static_cast(message.size())) == 1) && + &length, reinterpret_cast(ciphertext.data()), + static_cast(ciphertext.size())) == 1) && EVP_CIPHER_CTX_ctrl( context, EVP_CTRL_GCM_SET_TAG, static_cast(TAG_BYTES), const_cast( diff --git a/vendor/core/src/core/crypto/crypto_aes_gcm_other.cc b/vendor/core/src/core/crypto/crypto_aes_gcm_other.cc index f37e2f18..8475406b 100644 --- a/vendor/core/src/core/crypto/crypto_aes_gcm_other.cc +++ b/vendor/core/src/core/crypto/crypto_aes_gcm_other.cc @@ -1,153 +1,25 @@ #include "crypto_aes.h" +#include "crypto_aes_block.h" -#include // std::array +#include // assert #include // std::size_t #include // std::uint8_t, std::uint64_t #include // std::optional, std::nullopt #include // std::string #include // std::string_view -// A from-scratch AES-256 in Galois/Counter Mode (FIPS 197 and NIST SP 800-38D) -// for the reference backend. This is not constant-time, which is acceptable -// only because this backend is the non-production fallback +// A from-scratch AES in Galois/Counter Mode (NIST SP 800-38D) for the reference +// backend, over the shared AES block cipher. This is not constant-time, which +// is acceptable only because this backend is the non-production fallback namespace sourcemeta::core { namespace { -// The Rijndael substitution box (FIPS 197 Figure 7) -constexpr std::array SUBSTITUTION{ - {0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, - 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, - 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, - 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, - 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, - 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, - 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, - 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, - 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, - 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, - 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, - 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, - 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, - 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, - 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, - 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, - 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, - 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, - 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, - 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, - 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, - 0xb0, 0x54, 0xbb, 0x16}}; - -using Block = std::array; -using RoundKeys = std::array; - -// Multiply by two in GF(2^8) with the AES reduction polynomial (FIPS 197 -// Section 4.2) -auto xtime(const std::uint8_t value) -> std::uint8_t { - return static_cast((static_cast(value) << 1u) ^ - (((value & 0x80u) != 0) ? 0x1bu : 0x00u)); -} - -auto multiply(const std::uint8_t left, const std::uint8_t right) - -> std::uint8_t { - std::uint8_t product{0}; - std::uint8_t factor{left}; - for (std::uint8_t bit{right}; bit != 0; bit >>= 1u) { - if ((bit & 1u) != 0) { - product ^= factor; - } - - factor = xtime(factor); - } - - return product; -} - -// AES-256 key expansion (FIPS 197 Section 5.2, with the 256-bit key schedule) -auto expand_key(const std::string_view key) -> RoundKeys { - RoundKeys round_keys{}; - for (std::size_t index = 0; index < 32; ++index) { - round_keys[index] = static_cast(key[index]); - } - - std::uint8_t round_constant{0x01}; - for (std::size_t index = 32; index < round_keys.size(); index += 4) { - std::array word{ - {round_keys[index - 4], round_keys[index - 3], round_keys[index - 2], - round_keys[index - 1]}}; - const auto position{index / 4}; - if (position % 8 == 0) { - const auto first{word[0]}; - word[0] = SUBSTITUTION[word[1]] ^ round_constant; - word[1] = SUBSTITUTION[word[2]]; - word[2] = SUBSTITUTION[word[3]]; - word[3] = SUBSTITUTION[first]; - round_constant = xtime(round_constant); - } else if (position % 8 == 4) { - for (auto &byte : word) { - byte = SUBSTITUTION[byte]; - } - } - - for (std::size_t offset = 0; offset < 4; ++offset) { - round_keys[index + offset] = - round_keys[index - 32 + offset] ^ word[offset]; - } - } - - return round_keys; -} - -auto encrypt_block(const RoundKeys &round_keys, Block state) -> Block { - const auto add_round_key{[&round_keys, &state](const std::size_t round) { - for (std::size_t index = 0; index < 16; ++index) { - state[index] ^= round_keys[(round * 16) + index]; - } - }}; - - add_round_key(0); - for (std::size_t round = 1; round <= 14; ++round) { - for (auto &byte : state) { - byte = SUBSTITUTION[byte]; - } - - // ShiftRows over the column-major state (FIPS 197 Section 5.1.2) - const Block shifted{{state[0], state[5], state[10], state[15], state[4], - state[9], state[14], state[3], state[8], state[13], - state[2], state[7], state[12], state[1], state[6], - state[11]}}; - state = shifted; - - if (round != 14) { - // MixColumns (FIPS 197 Section 5.1.3) - for (std::size_t column = 0; column < 4; ++column) { - const auto base{column * 4}; - const auto first{state[base]}; - const auto second{state[base + 1]}; - const auto third{state[base + 2]}; - const auto fourth{state[base + 3]}; - state[base] = multiply(first, 2) ^ multiply(second, 3) ^ third ^ fourth; - state[base + 1] = - first ^ multiply(second, 2) ^ multiply(third, 3) ^ fourth; - state[base + 2] = - first ^ second ^ multiply(third, 2) ^ multiply(fourth, 3); - state[base + 3] = - multiply(first, 3) ^ second ^ third ^ multiply(fourth, 2); - } - } - - add_round_key(round); - } - - return state; -} - // Multiply two blocks in GF(2^128) with the GCM reduction polynomial (NIST SP // 800-38D Section 6.3) -auto gf_multiply(const Block &left, const Block &right) -> Block { - Block product{}; - Block value{left}; +auto gf_multiply(const AesBlock &left, const AesBlock &right) -> AesBlock { + AesBlock product{}; + AesBlock value{left}; for (std::size_t bit = 0; bit < 128; ++bit) { if (((right[bit / 8] >> (7 - (bit % 8))) & 1u) != 0) { for (std::size_t index = 0; index < 16; ++index) { @@ -172,8 +44,8 @@ auto gf_multiply(const Block &left, const Block &right) -> Block { } // GHASH the data padded to whole blocks (NIST SP 800-38D Section 6.4) -auto ghash(const Block &key, const std::string_view data, Block accumulator) - -> Block { +auto ghash(const AesBlock &key, const std::string_view data, + AesBlock accumulator) -> AesBlock { for (std::size_t offset = 0; offset < data.size(); offset += 16) { for (std::size_t index = 0; index < 16 && offset + index < data.size(); ++index) { @@ -187,30 +59,32 @@ auto ghash(const Block &key, const std::string_view data, Block accumulator) } struct Context { - RoundKeys round_keys; - Block hash_key; - Block counter_zero; - Block tag_mask; + AesKeySchedule schedule; + AesBlock hash_key; + AesBlock counter_zero; + AesBlock tag_mask; }; -auto make_context(const std::string_view key, const std::string_view nonce) +auto make_context(const std::string_view key, const std::string_view iv) -> Context { - Context context{.round_keys = expand_key(key), + assert(iv.size() == 12); + Context context{.schedule = aes_expand_key(key), .hash_key = {}, .counter_zero = {}, .tag_mask = {}}; - context.hash_key = encrypt_block(context.round_keys, Block{}); + context.hash_key = aes_encrypt_block(context.schedule, AesBlock{}); for (std::size_t index = 0; index < 12; ++index) { - context.counter_zero[index] = static_cast(nonce[index]); + context.counter_zero[index] = static_cast(iv[index]); } - // The 96-bit nonce yields the pre-counter block nonce || 0x00000001 + // The 96-bit initialization vector yields the pre-counter block iv || + // 0x00000001 context.counter_zero[15] = 1; - context.tag_mask = encrypt_block(context.round_keys, context.counter_zero); + context.tag_mask = aes_encrypt_block(context.schedule, context.counter_zero); return context; } -auto increment_counter(Block &counter) -> void { +auto increment_counter(AesBlock &counter) -> void { for (std::size_t index = 16; index-- > 12;) { if (++counter[index] != 0) { break; @@ -222,10 +96,10 @@ auto increment_counter(Block &counter) -> void { auto counter_mode(const Context &context, const std::string_view input) -> std::string { std::string output(input.size(), '\x00'); - Block counter{context.counter_zero}; + AesBlock counter{context.counter_zero}; for (std::size_t offset = 0; offset < input.size(); offset += 16) { increment_counter(counter); - const auto keystream{encrypt_block(context.round_keys, counter)}; + const auto keystream{aes_encrypt_block(context.schedule, counter)}; for (std::size_t index = 0; index < 16 && offset + index < input.size(); ++index) { output[offset + index] = static_cast( @@ -236,13 +110,22 @@ auto counter_mode(const Context &context, const std::string_view input) return output; } -// The authentication tag over an empty associated data and the ciphertext -auto compute_tag(const Context &context, const std::string_view ciphertext) - -> Block { - auto accumulator{ghash(context.hash_key, ciphertext, Block{})}; - Block lengths{}; +// The authentication tag over the associated data and the ciphertext (NIST SP +// 800-38D Section 7.1) +auto compute_tag(const Context &context, const std::string_view associated_data, + const std::string_view ciphertext) -> AesBlock { + auto accumulator{ghash(context.hash_key, associated_data, AesBlock{})}; + accumulator = ghash(context.hash_key, ciphertext, accumulator); + + // The final GHASH block is the bit lengths of the associated data and the + // ciphertext, each as a 64-bit big-endian integer + AesBlock lengths{}; + const auto associated_bits{ + static_cast(associated_data.size()) * 8}; const auto ciphertext_bits{static_cast(ciphertext.size()) * 8}; for (std::size_t index = 0; index < 8; ++index) { + lengths[7 - index] = + static_cast((associated_bits >> (8 * index)) & 0xffu); lengths[15 - index] = static_cast((ciphertext_bits >> (8 * index)) & 0xffu); } @@ -252,7 +135,7 @@ auto compute_tag(const Context &context, const std::string_view ciphertext) } accumulator = gf_multiply(accumulator, context.hash_key); - Block tag{}; + AesBlock tag{}; for (std::size_t index = 0; index < 16; ++index) { tag[index] = accumulator[index] ^ context.tag_mask[index]; } @@ -262,42 +145,36 @@ auto compute_tag(const Context &context, const std::string_view ciphertext) } // namespace -auto aes_256_gcm_encrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view plaintext) - -> std::optional { - const auto context{make_context(key, nonce)}; - std::string ciphertext{counter_mode(context, plaintext)}; - const auto tag{compute_tag(context, ciphertext)}; - ciphertext.append(reinterpret_cast(tag.data()), tag.size()); - return ciphertext; +auto aes_gcm_seal(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { + const auto context{make_context(key, iv)}; + // The tag is computed over the ciphertext, then appended to it + AESGCMCiphertext result{.data = counter_mode(context, plaintext)}; + const auto tag{compute_tag(context, associated_data, result.data)}; + result.data.append(reinterpret_cast(tag.data()), tag.size()); + return result; } -auto aes_256_gcm_decrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view ciphertext) +auto aes_gcm_open(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, const std::string_view tag) -> std::optional { - if (ciphertext.size() < 16) { - return std::nullopt; - } - - const auto message{ciphertext.substr(0, ciphertext.size() - 16)}; - const auto received_tag{ciphertext.substr(ciphertext.size() - 16)}; - const auto context{make_context(key, nonce)}; - const auto tag{compute_tag(context, message)}; + const auto context{make_context(key, iv)}; + const auto expected{compute_tag(context, associated_data, ciphertext)}; std::uint8_t difference{0}; for (std::size_t index = 0; index < 16; ++index) { difference = static_cast( - difference | - (tag[index] ^ static_cast(received_tag[index]))); + difference | (expected[index] ^ static_cast(tag[index]))); } if (difference != 0) { return std::nullopt; } - return counter_mode(context, message); + return counter_mode(context, ciphertext); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_gcm_windows.cc b/vendor/core/src/core/crypto/crypto_aes_gcm_windows.cc index a8e8bf9c..b5c541e1 100644 --- a/vendor/core/src/core/crypto/crypto_aes_gcm_windows.cc +++ b/vendor/core/src/core/crypto/crypto_aes_gcm_windows.cc @@ -32,17 +32,17 @@ auto as_buffer(const wchar_t *const value) -> PUCHAR { namespace sourcemeta::core { -auto aes_256_gcm_encrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view plaintext) - -> std::optional { +auto aes_gcm_seal(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { BCRYPT_ALG_HANDLE algorithm{nullptr}; if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( &algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0))) { return std::nullopt; } - std::optional result; + std::optional result; BCRYPT_KEY_HANDLE key_handle{nullptr}; if (BCRYPT_SUCCESS(BCryptSetProperty(algorithm, BCRYPT_CHAINING_MODE, as_buffer(BCRYPT_CHAIN_MODE_GCM), @@ -50,23 +50,25 @@ auto aes_256_gcm_encrypt(const std::string_view key, BCRYPT_SUCCESS(BCryptGenerateSymmetricKey( algorithm, &key_handle, nullptr, 0, as_buffer(key), static_cast(key.size()), 0))) { + // A single buffer holds the ciphertext followed by its 16-byte tag, which + // the mode info writes into at the offset past the ciphertext + std::string data(plaintext.size() + TAG_BYTES, '\x00'); BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info; BCRYPT_INIT_AUTH_MODE_INFO(info); - info.pbNonce = as_buffer(nonce); - info.cbNonce = static_cast(nonce.size()); - std::string tag(TAG_BYTES, '\x00'); - info.pbTag = as_buffer(tag); + info.pbNonce = as_buffer(iv); + info.cbNonce = static_cast(iv.size()); + info.pbAuthData = as_buffer(associated_data); + info.cbAuthData = static_cast(associated_data.size()); + info.pbTag = as_buffer(data) + plaintext.size(); info.cbTag = static_cast(TAG_BYTES); - std::string ciphertext(plaintext.size(), '\x00'); ULONG written{0}; if (BCRYPT_SUCCESS(BCryptEncrypt(key_handle, as_buffer(plaintext), static_cast(plaintext.size()), - &info, nullptr, 0, as_buffer(ciphertext), - static_cast(ciphertext.size()), + &info, nullptr, 0, as_buffer(data), + static_cast(plaintext.size()), &written, 0))) { - ciphertext.append(tag); - result = std::move(ciphertext); + result = AESGCMCiphertext{.data = std::move(data)}; } BCryptDestroyKey(key_handle); @@ -76,16 +78,10 @@ auto aes_256_gcm_encrypt(const std::string_view key, return result; } -auto aes_256_gcm_decrypt(const std::string_view key, - const std::string_view nonce, - const std::string_view ciphertext) +auto aes_gcm_open(const std::string_view key, const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, const std::string_view tag) -> std::optional { - if (ciphertext.size() < TAG_BYTES) { - return std::nullopt; - } - - const auto message{ciphertext.substr(0, ciphertext.size() - TAG_BYTES)}; - auto tag{std::string{ciphertext.substr(ciphertext.size() - TAG_BYTES)}}; BCRYPT_ALG_HANDLE algorithm{nullptr}; if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( &algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0))) { @@ -102,18 +98,21 @@ auto aes_256_gcm_decrypt(const std::string_view key, static_cast(key.size()), 0))) { BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info; BCRYPT_INIT_AUTH_MODE_INFO(info); - info.pbNonce = as_buffer(nonce); - info.cbNonce = static_cast(nonce.size()); + info.pbNonce = as_buffer(iv); + info.cbNonce = static_cast(iv.size()); + info.pbAuthData = as_buffer(associated_data); + info.cbAuthData = static_cast(associated_data.size()); info.pbTag = as_buffer(tag); - info.cbTag = static_cast(TAG_BYTES); + info.cbTag = static_cast(tag.size()); - std::string plaintext(message.size(), '\x00'); + std::string plaintext(ciphertext.size(), '\x00'); ULONG written{0}; // Reports a tag mismatch through the status, so a tampered message fails - if (BCRYPT_SUCCESS(BCryptDecrypt( - key_handle, as_buffer(message), static_cast(message.size()), - &info, nullptr, 0, as_buffer(plaintext), - static_cast(plaintext.size()), &written, 0))) { + if (BCRYPT_SUCCESS(BCryptDecrypt(key_handle, as_buffer(ciphertext), + static_cast(ciphertext.size()), + &info, nullptr, 0, as_buffer(plaintext), + static_cast(plaintext.size()), + &written, 0))) { result = std::move(plaintext); } diff --git a/vendor/core/src/core/crypto/crypto_aes_kw.cc b/vendor/core/src/core/crypto/crypto_aes_kw.cc new file mode 100644 index 00000000..cad7eaf5 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_kw.cc @@ -0,0 +1,51 @@ +#include + +#include "crypto_aes.h" + +#include // std::size_t +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::string_view + +namespace sourcemeta::core { + +namespace { +// AES Key Wrap operates on 64-bit semiblocks (RFC 3394 Section 2) +constexpr std::size_t SEMIBLOCK_BYTES{8}; +// An upper bound the backends can all process without narrowing their lengths +constexpr std::size_t MAX_INPUT_BYTES{ + static_cast(std::numeric_limits::max())}; + +auto is_valid_key_size(const std::size_t size) -> bool { + return size == 16 || size == 24 || size == 32; +} +} // namespace + +auto aes_key_wrap(const std::string_view key_encryption_key, + const std::string_view key) -> std::optional { + // The key must be at least two semiblocks (RFC 3394 Section 2), and the bound + // leaves headroom for the extra semiblock the wrap prepends + if (!is_valid_key_size(key_encryption_key.size()) || + key.size() < 2 * SEMIBLOCK_BYTES || key.size() % SEMIBLOCK_BYTES != 0 || + key.size() > MAX_INPUT_BYTES - SEMIBLOCK_BYTES) { + return std::nullopt; + } + + return aes_wrap(key_encryption_key, key); +} + +auto aes_key_unwrap(const std::string_view key_encryption_key, + const std::string_view wrapped_key) + -> std::optional { + // The wrapped key is the integrity semiblock followed by at least two more + if (!is_valid_key_size(key_encryption_key.size()) || + wrapped_key.size() < 3 * SEMIBLOCK_BYTES || + wrapped_key.size() % SEMIBLOCK_BYTES != 0 || + wrapped_key.size() > MAX_INPUT_BYTES) { + return std::nullopt; + } + + return aes_unwrap(key_encryption_key, wrapped_key); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_kw_apple.cc b/vendor/core/src/core/crypto/crypto_aes_kw_apple.cc new file mode 100644 index 00000000..723c7247 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_kw_apple.cc @@ -0,0 +1,55 @@ +#include "crypto_aes.h" + +#include // kCCSuccess +#include // CCSymmetricKey*, CCrfc3394_iv, kCCWRAPAES + +#include // std::size_t +#include // std::uint8_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace { +constexpr std::size_t SEMIBLOCK_BYTES{8}; +} // namespace + +namespace sourcemeta::core { + +auto aes_wrap(const std::string_view key_encryption_key, + const std::string_view key) -> std::optional { + std::string wrapped(key.size() + SEMIBLOCK_BYTES, '\x00'); + std::size_t wrapped_size{wrapped.size()}; + const auto status{CCSymmetricKeyWrap( + kCCWRAPAES, CCrfc3394_iv, CCrfc3394_ivLen, + reinterpret_cast(key_encryption_key.data()), + key_encryption_key.size(), + reinterpret_cast(key.data()), key.size(), + reinterpret_cast(wrapped.data()), &wrapped_size)}; + if (status != kCCSuccess || wrapped_size != wrapped.size()) { + return std::nullopt; + } + + return wrapped; +} + +auto aes_unwrap(const std::string_view key_encryption_key, + const std::string_view wrapped_key) + -> std::optional { + std::string key(wrapped_key.size() - SEMIBLOCK_BYTES, '\x00'); + std::size_t key_size{key.size()}; + // A failed integrity check surfaces as a non-success status + const auto status{CCSymmetricKeyUnwrap( + kCCWRAPAES, CCrfc3394_iv, CCrfc3394_ivLen, + reinterpret_cast(key_encryption_key.data()), + key_encryption_key.size(), + reinterpret_cast(wrapped_key.data()), + wrapped_key.size(), reinterpret_cast(key.data()), + &key_size)}; + if (status != kCCSuccess || key_size != key.size()) { + return std::nullopt; + } + + return key; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_kw_loop.h b/vendor/core/src/core/crypto/crypto_aes_kw_loop.h new file mode 100644 index 00000000..108e5b2f --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_kw_loop.h @@ -0,0 +1,131 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_AES_KW_LOOP_H_ +#define SOURCEMETA_CORE_CRYPTO_AES_KW_LOOP_H_ + +// The AES Key Wrap integrity register loop (RFC 3394), shared by the software +// backends that run it over their own AES block cipher. The single-block +// operation is passed in as a callback, so each backend supplies only the +// cipher and reports its own failures + +#include // std::array +#include // std::size_t +#include // std::uint8_t, std::uint64_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +// AES Key Wrap operates on 64-bit semiblocks over 128-bit AES blocks +inline constexpr std::size_t aes_kw_semiblock_bytes{8}; +using AesKeyWrapBlock = std::array; +using AesKeyWrapRegister = std::array; + +// The default integrity value (RFC 3394 Section 2.2.3.1) +inline constexpr AesKeyWrapRegister aes_kw_default_iv{ + {0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6, 0xa6}}; + +// Fold the round counter into the integrity register as a 64-bit big-endian +// value (RFC 3394 Section 2.2.1) +inline auto aes_kw_exclusive_or_counter(AesKeyWrapRegister &value, + const std::uint64_t counter) -> void { + for (std::size_t index = 0; index < aes_kw_semiblock_bytes; ++index) { + value[aes_kw_semiblock_bytes - 1 - index] ^= + static_cast((counter >> (8 * index)) & 0xffu); + } +} + +// The wrap loop over a block cipher that reports failure by returning no value +// (RFC 3394 Section 2.2.1) +template +auto aes_kw_wrap_loop(const Cipher &encrypt, const std::string_view key) + -> std::optional { + const auto blocks{key.size() / aes_kw_semiblock_bytes}; + AesKeyWrapRegister integrity{aes_kw_default_iv}; + std::string registers{key}; + for (std::size_t round = 0; round < 6; ++round) { + for (std::size_t index = 1; index <= blocks; ++index) { + AesKeyWrapBlock input{}; + const auto offset{(index - 1) * aes_kw_semiblock_bytes}; + for (std::size_t byte = 0; byte < aes_kw_semiblock_bytes; ++byte) { + input[byte] = integrity[byte]; + input[aes_kw_semiblock_bytes + byte] = + static_cast(registers[offset + byte]); + } + + const auto output{encrypt(input)}; + if (!output.has_value()) { + return std::nullopt; + } + + for (std::size_t byte = 0; byte < aes_kw_semiblock_bytes; ++byte) { + integrity[byte] = output.value()[byte]; + registers[offset + byte] = + static_cast(output.value()[aes_kw_semiblock_bytes + byte]); + } + + aes_kw_exclusive_or_counter(integrity, (blocks * round) + index); + } + } + + std::string wrapped; + wrapped.reserve(key.size() + aes_kw_semiblock_bytes); + wrapped.append(reinterpret_cast(integrity.data()), + aes_kw_semiblock_bytes); + wrapped.append(registers); + return wrapped; +} + +// The unwrap loop, verifying the integrity register returns to the default +// value without an early exit so the comparison does not leak where a mismatch +// occurred (RFC 3394 Section 2.2.2) +template +auto aes_kw_unwrap_loop(const Cipher &decrypt, + const std::string_view wrapped_key) + -> std::optional { + const auto blocks{(wrapped_key.size() / aes_kw_semiblock_bytes) - 1}; + AesKeyWrapRegister integrity{}; + for (std::size_t byte = 0; byte < aes_kw_semiblock_bytes; ++byte) { + integrity[byte] = static_cast(wrapped_key[byte]); + } + + std::string registers{wrapped_key.substr(aes_kw_semiblock_bytes)}; + for (std::size_t round = 6; round-- > 0;) { + for (std::size_t index = blocks; index >= 1; --index) { + aes_kw_exclusive_or_counter(integrity, (blocks * round) + index); + AesKeyWrapBlock input{}; + const auto offset{(index - 1) * aes_kw_semiblock_bytes}; + for (std::size_t byte = 0; byte < aes_kw_semiblock_bytes; ++byte) { + input[byte] = integrity[byte]; + input[aes_kw_semiblock_bytes + byte] = + static_cast(registers[offset + byte]); + } + + const auto output{decrypt(input)}; + if (!output.has_value()) { + return std::nullopt; + } + + for (std::size_t byte = 0; byte < aes_kw_semiblock_bytes; ++byte) { + integrity[byte] = output.value()[byte]; + registers[offset + byte] = + static_cast(output.value()[aes_kw_semiblock_bytes + byte]); + } + } + } + + std::uint8_t difference{0}; + for (std::size_t byte = 0; byte < aes_kw_semiblock_bytes; ++byte) { + difference = static_cast( + difference | (integrity[byte] ^ aes_kw_default_iv[byte])); + } + + if (difference != 0) { + return std::nullopt; + } + + return registers; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_aes_kw_openssl.cc b/vendor/core/src/core/crypto/crypto_aes_kw_openssl.cc new file mode 100644 index 00000000..96e695e5 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_kw_openssl.cc @@ -0,0 +1,101 @@ +#include "crypto_aes.h" + +#include // EVP_* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move + +namespace { +constexpr std::size_t SEMIBLOCK_BYTES{8}; + +auto wrap_cipher_for_key(const std::size_t key_size) -> const EVP_CIPHER * { + switch (key_size) { + case 16: + return EVP_aes_128_wrap(); + case 24: + return EVP_aes_192_wrap(); + case 32: + return EVP_aes_256_wrap(); + default: + return nullptr; + } +} +} // namespace + +namespace sourcemeta::core { + +auto aes_wrap(const std::string_view key_encryption_key, + const std::string_view key) -> std::optional { + const auto *const cipher{wrap_cipher_for_key(key_encryption_key.size())}; + auto *context{EVP_CIPHER_CTX_new()}; + if (cipher == nullptr || context == nullptr) { + EVP_CIPHER_CTX_free(context); + return std::nullopt; + } + + // The wrap ciphers are disabled unless the context opts in + EVP_CIPHER_CTX_set_flags(context, EVP_CIPHER_CTX_FLAG_WRAP_ALLOW); + std::optional result; + std::string wrapped(key.size() + SEMIBLOCK_BYTES, '\x00'); + auto *const output{reinterpret_cast(wrapped.data())}; + int length{0}; + int final_length{0}; + // A null initialization vector selects the RFC 3394 default integrity value + if (EVP_EncryptInit_ex( + context, cipher, nullptr, + reinterpret_cast(key_encryption_key.data()), + nullptr) == 1 && + EVP_EncryptUpdate(context, output, &length, + reinterpret_cast(key.data()), + static_cast(key.size())) == 1 && + EVP_EncryptFinal_ex(context, output + length, &final_length) == 1 && + static_cast(length) + + static_cast(final_length) == + wrapped.size()) { + result = std::move(wrapped); + } + + EVP_CIPHER_CTX_free(context); + return result; +} + +auto aes_unwrap(const std::string_view key_encryption_key, + const std::string_view wrapped_key) + -> std::optional { + const auto *const cipher{wrap_cipher_for_key(key_encryption_key.size())}; + auto *context{EVP_CIPHER_CTX_new()}; + if (cipher == nullptr || context == nullptr) { + EVP_CIPHER_CTX_free(context); + return std::nullopt; + } + + EVP_CIPHER_CTX_set_flags(context, EVP_CIPHER_CTX_FLAG_WRAP_ALLOW); + std::optional result; + std::string key(wrapped_key.size() - SEMIBLOCK_BYTES, '\x00'); + auto *const output{reinterpret_cast(key.data())}; + int length{0}; + int final_length{0}; + // The integrity check runs during decryption, so a failure surfaces here + if (EVP_DecryptInit_ex( + context, cipher, nullptr, + reinterpret_cast(key_encryption_key.data()), + nullptr) == 1 && + EVP_DecryptUpdate( + context, output, &length, + reinterpret_cast(wrapped_key.data()), + static_cast(wrapped_key.size())) == 1 && + EVP_DecryptFinal_ex(context, output + length, &final_length) == 1 && + static_cast(length) + + static_cast(final_length) == + key.size()) { + result = std::move(key); + } + + EVP_CIPHER_CTX_free(context); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_kw_other.cc b/vendor/core/src/core/crypto/crypto_aes_kw_other.cc new file mode 100644 index 00000000..349f8f13 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_kw_other.cc @@ -0,0 +1,38 @@ +#include "crypto_aes.h" +#include "crypto_aes_block.h" +#include "crypto_aes_kw_loop.h" + +#include // std::optional +#include // std::string +#include // std::string_view + +// A from-scratch AES Key Wrap (RFC 3394) for the reference backend, running the +// shared loop over the shared AES block cipher. This is not constant-time, +// which is acceptable only because this backend is the non-production fallback + +namespace sourcemeta::core { + +auto aes_wrap(const std::string_view key_encryption_key, + const std::string_view key) -> std::optional { + const auto schedule{aes_expand_key(key_encryption_key)}; + return aes_kw_wrap_loop( + [&schedule]( + const AesKeyWrapBlock &block) -> std::optional { + return aes_encrypt_block(schedule, block); + }, + key); +} + +auto aes_unwrap(const std::string_view key_encryption_key, + const std::string_view wrapped_key) + -> std::optional { + const auto schedule{aes_expand_key(key_encryption_key)}; + return aes_kw_unwrap_loop( + [&schedule]( + const AesKeyWrapBlock &block) -> std::optional { + return aes_decrypt_block(schedule, block); + }, + wrapped_key); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_aes_kw_windows.cc b/vendor/core/src/core/crypto/crypto_aes_kw_windows.cc new file mode 100644 index 00000000..20a5c973 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_aes_kw_windows.cc @@ -0,0 +1,114 @@ +#include "crypto_aes.h" +#include "crypto_aes_kw_loop.h" + +#include // ULONG, PUCHAR +// clang-format off +#include // BCrypt*, BCRYPT_* +// clang-format on + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +// Windows CNG has no RFC 3394 primitive, so AES Key Wrap runs the shared loop +// over the AES block cipher in Electronic Codebook mode + +namespace { +auto as_buffer(const std::string_view value) -> PUCHAR { + return reinterpret_cast(const_cast( + value.data())); // NOLINT(cppcoreguidelines-pro-type-const-cast) +} + +auto as_buffer(const wchar_t *const value) -> PUCHAR { + return reinterpret_cast(const_cast( + value)); // NOLINT(cppcoreguidelines-pro-type-const-cast) +} + +// One Electronic Codebook block, reporting a CNG failure or a short transform +// as no value so the shared loop stops rather than wrapping corrupt output +auto cipher_block(BCRYPT_KEY_HANDLE key, const bool encrypt, + const sourcemeta::core::AesKeyWrapBlock &input) + -> std::optional { + sourcemeta::core::AesKeyWrapBlock output{}; + auto *const input_buffer{reinterpret_cast( + const_cast< + std::uint8_t *>( // NOLINT(cppcoreguidelines-pro-type-const-cast) + input.data()))}; + auto *const output_buffer{reinterpret_cast(output.data())}; + ULONG written{0}; + const auto status{ + encrypt + ? BCryptEncrypt(key, input_buffer, static_cast(input.size()), + nullptr, nullptr, 0, output_buffer, + static_cast(output.size()), &written, 0) + : BCryptDecrypt(key, input_buffer, static_cast(input.size()), + nullptr, nullptr, 0, output_buffer, + static_cast(output.size()), &written, 0)}; + if (!BCRYPT_SUCCESS(status) || written != output.size()) { + return std::nullopt; + } + + return output; +} +} // namespace + +namespace sourcemeta::core { + +auto aes_wrap(const std::string_view key_encryption_key, + const std::string_view key) -> std::optional { + BCRYPT_ALG_HANDLE algorithm{nullptr}; + if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( + &algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0))) { + return std::nullopt; + } + + std::optional result; + BCRYPT_KEY_HANDLE key_handle{nullptr}; + if (BCRYPT_SUCCESS(BCryptSetProperty(algorithm, BCRYPT_CHAINING_MODE, + as_buffer(BCRYPT_CHAIN_MODE_ECB), + sizeof(BCRYPT_CHAIN_MODE_ECB), 0)) && + BCRYPT_SUCCESS(BCryptGenerateSymmetricKey( + algorithm, &key_handle, nullptr, 0, as_buffer(key_encryption_key), + static_cast(key_encryption_key.size()), 0))) { + result = aes_kw_wrap_loop( + [key_handle](const AesKeyWrapBlock &block) { + return cipher_block(key_handle, true, block); + }, + key); + BCryptDestroyKey(key_handle); + } + + BCryptCloseAlgorithmProvider(algorithm, 0); + return result; +} + +auto aes_unwrap(const std::string_view key_encryption_key, + const std::string_view wrapped_key) + -> std::optional { + BCRYPT_ALG_HANDLE algorithm{nullptr}; + if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( + &algorithm, BCRYPT_AES_ALGORITHM, nullptr, 0))) { + return std::nullopt; + } + + std::optional result; + BCRYPT_KEY_HANDLE key_handle{nullptr}; + if (BCRYPT_SUCCESS(BCryptSetProperty(algorithm, BCRYPT_CHAINING_MODE, + as_buffer(BCRYPT_CHAIN_MODE_ECB), + sizeof(BCRYPT_CHAIN_MODE_ECB), 0)) && + BCRYPT_SUCCESS(BCryptGenerateSymmetricKey( + algorithm, &key_handle, nullptr, 0, as_buffer(key_encryption_key), + static_cast(key_encryption_key.size()), 0))) { + result = aes_kw_unwrap_loop( + [key_handle](const AesKeyWrapBlock &block) { + return cipher_block(key_handle, false, block); + }, + wrapped_key); + BCryptDestroyKey(key_handle); + } + + BCryptCloseAlgorithmProvider(algorithm, 0); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_apple.h b/vendor/core/src/core/crypto/crypto_apple.h index 6b54644c..16a8ed08 100644 --- a/vendor/core/src/core/crypto/crypto_apple.h +++ b/vendor/core/src/core/crypto/crypto_apple.h @@ -1,9 +1,12 @@ #ifndef SOURCEMETA_CORE_CRYPTO_APPLE_H_ #define SOURCEMETA_CORE_CRYPTO_APPLE_H_ -// Security-framework key export helpers shared by the Apple signing and -// verification backends, so the external representation handling stays -// single-sourced +// The Apple key internals and the Security-framework export helper shared +// across the Apple backends, so the parsed key layout and the external +// representation handling stay single-sourced + +#include +#include #include // CF*, kCF* #include // Sec*, kSec* @@ -14,6 +17,27 @@ namespace sourcemeta::core { +// The parsed key keeps the platform key object alive for reuse. The Edwards +// curves have no Security framework primitive, so they keep the raw seed or +// point and operate through CryptoKit or the reference implementation +struct PublicKey::Internal { + PublicKey::Type kind; + SecKeyRef key; + std::string modulus; + std::size_t field_bytes; + std::string edwards_point; + EdwardsCurve edwards_curve; +}; + +struct PrivateKey::Internal { + PrivateKey::Type kind; + SecKeyRef key; + std::size_t field_bytes; + std::string edwards_seed; + EdwardsCurve edwards_curve; + bool rsa_pss_restricted{false}; +}; + // Copy the platform's external representation of a key, the PKCS#1 structure // for RSA and the X9.63 uncompressed point for elliptic curve keys inline auto copy_external_representation(SecKeyRef key) diff --git a/vendor/core/src/core/crypto/crypto_base64.cc b/vendor/core/src/core/crypto/crypto_base64.cc index 89c97cb0..7ebff8d6 100644 --- a/vendor/core/src/core/crypto/crypto_base64.cc +++ b/vendor/core/src/core/crypto/crypto_base64.cc @@ -5,6 +5,7 @@ #include // std::uint8_t, std::uint32_t #include // std::optional, std::nullopt #include // std::ostream +#include // std::span #include // std::string #include // std::string_view @@ -215,6 +216,18 @@ auto base64url_encode(const std::string_view input) -> std::string { return result; } +auto base64url_encode(const std::span input) + -> std::string { + // An empty span may carry a null pointer, which some standard libraries + // reject when constructing a string view even for a zero length + if (input.empty()) { + return {}; + } + + return base64url_encode(std::string_view{ + reinterpret_cast(input.data()), input.size()}); +} + auto base64url_encode(const std::string_view input, SecureString &output) -> void { // The input is copied first so that growing the output cannot invalidate it diff --git a/vendor/core/src/core/crypto/crypto_ecdh.cc b/vendor/core/src/core/crypto/crypto_ecdh.cc new file mode 100644 index 00000000..8f0e9162 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_ecdh.cc @@ -0,0 +1,74 @@ +#include +#include + +#include "crypto_helpers.h" + +#include // std::min +#include // std::array +#include // std::size_t +#include // std::uint32_t +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { +auto append_length_prefixed(std::string &target, const std::string_view data) + -> void { + append_big_endian_uint32(target, static_cast(data.size())); + target.append(data); +} +} // namespace + +auto kdf_concat(const std::string_view shared_secret, + const std::string_view algorithm_id, + const std::string_view party_u_info, + const std::string_view party_v_info, + const std::size_t derived_key_bytes) + -> std::optional { + // The construction encodes each length and the key length in bits as a 32-bit + // field, so an input that would not fit cannot be represented + constexpr std::size_t maximum_field{ + std::numeric_limits::max()}; + if (algorithm_id.size() > maximum_field || + party_u_info.size() > maximum_field || + party_v_info.size() > maximum_field || + derived_key_bytes > maximum_field / 8u) { + return std::nullopt; + } + + // OtherInfo (RFC 7518 Section 4.6.2) concatenates the algorithm identifier + // and the two party information strings, each as a 32-bit big-endian length + // followed by the data, then the key length in bits as SuppPubInfo and an + // empty SuppPrivInfo + std::string other_info; + append_length_prefixed(other_info, algorithm_id); + append_length_prefixed(other_info, party_u_info); + append_length_prefixed(other_info, party_v_info); + append_big_endian_uint32(other_info, + static_cast(derived_key_bytes * 8u)); + + // Each round hashes the big-endian round counter, the shared secret, and + // OtherInfo, taking blocks until the requested length is reached + std::string derived; + derived.reserve(derived_key_bytes); + std::uint32_t counter{1}; + while (derived.size() < derived_key_bytes) { + std::string counter_bytes; + append_big_endian_uint32(counter_bytes, counter); + const std::array parts{ + {counter_bytes, shared_secret, other_info}}; + const auto block{sha256_digest(std::span{parts})}; + const auto available{ + std::min(derived_key_bytes - derived.size(), block.size())}; + derived.append(reinterpret_cast(block.data()), available); + counter += 1; + } + + return derived; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_ecdh_apple.cc b/vendor/core/src/core/crypto/crypto_ecdh_apple.cc new file mode 100644 index 00000000..484dbbc9 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_ecdh_apple.cc @@ -0,0 +1,57 @@ +#include + +#include "crypto_apple.h" + +#include // CF*, kCF* +#include // Sec*, kSec* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string + +namespace sourcemeta::core { + +auto ecdh_derive(const PrivateKey &private_key, const PublicKey &public_key) + -> std::optional { + const auto *const private_internal{private_key.internal()}; + const auto *const public_internal{public_key.internal()}; + if (private_internal == nullptr || public_internal == nullptr || + private_internal->kind != PrivateKey::Type::EllipticCurve || + public_internal->kind != PublicKey::Type::EllipticCurve || + private_internal->field_bytes != public_internal->field_bytes) { + return std::nullopt; + } + + auto parameters{CFDictionaryCreate(kCFAllocatorDefault, nullptr, nullptr, 0, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks)}; + if (parameters == nullptr) { + return std::nullopt; + } + + // The standard exchange returns the agreed point x coordinate, and the peer + // key was validated on the curve when it was parsed + auto shared{SecKeyCopyKeyExchangeResult( + private_internal->key, kSecKeyAlgorithmECDHKeyExchangeStandard, + public_internal->key, parameters, nullptr)}; + CFRelease(parameters); + if (shared == nullptr) { + return std::nullopt; + } + + const auto length{static_cast(CFDataGetLength(shared))}; + if (length > private_internal->field_bytes) { + CFRelease(shared); + return std::nullopt; + } + + // Left-pad to the fixed-length x coordinate the contract promises, since a + // leading zero could otherwise shorten the platform output + std::string result(private_internal->field_bytes - length, '\x00'); + result.append(reinterpret_cast(CFDataGetBytePtr(shared)), + length); + CFRelease(shared); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_ecdh_openssl.cc b/vendor/core/src/core/crypto/crypto_ecdh_openssl.cc new file mode 100644 index 00000000..bbf26df5 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_ecdh_openssl.cc @@ -0,0 +1,55 @@ +#include + +#include "crypto_openssl.h" + +#include // EVP_* + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::move + +namespace sourcemeta::core { + +auto ecdh_derive(const PrivateKey &private_key, const PublicKey &public_key) + -> std::optional { + const auto *const private_internal{private_key.internal()}; + const auto *const public_internal{public_key.internal()}; + if (private_internal == nullptr || public_internal == nullptr || + private_internal->kind != PrivateKey::Type::EllipticCurve || + public_internal->kind != PublicKey::Type::EllipticCurve || + private_internal->field_bytes != public_internal->field_bytes) { + return std::nullopt; + } + + auto *context{EVP_PKEY_CTX_new(private_internal->key, nullptr)}; + if (context == nullptr) { + return std::nullopt; + } + + std::optional result; + std::size_t length{0}; + // The peer key was validated on the curve when it was parsed, and setting it + // as the derivation peer checks it again + if (EVP_PKEY_derive_init(context) == 1 && + EVP_PKEY_derive_set_peer(context, public_internal->key) == 1 && + EVP_PKEY_derive(context, nullptr, &length) == 1) { + std::string secret(length, '\x00'); + if (EVP_PKEY_derive(context, + reinterpret_cast(secret.data()), + &length) == 1 && + length <= private_internal->field_bytes) { + secret.resize(length); + // Guarantee the fixed-length x coordinate the contract promises, since a + // leading zero could otherwise shorten the platform output + secret.insert(secret.begin(), + private_internal->field_bytes - secret.size(), '\x00'); + result = std::move(secret); + } + } + + EVP_PKEY_CTX_free(context); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_ecdh_other.cc b/vendor/core/src/core/crypto/crypto_ecdh_other.cc new file mode 100644 index 00000000..34a153c0 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_ecdh_other.cc @@ -0,0 +1,65 @@ +#include + +#include "crypto_bignum.h" +#include "crypto_ecc.h" +#include "crypto_other.h" + +#include // std::optional, std::nullopt +#include // std::string +#include // std::unreachable + +// A from-scratch ECDH primitive for the reference backend, over the shared +// constant-time elliptic curve arithmetic. The shared secret is the affine x +// coordinate of the peer point multiplied by the own scalar (SEC1, the form +// RFC 7518 Section 4.6 consumes) + +namespace sourcemeta::core { + +namespace { +auto to_curve_parameters(const EllipticCurve curve) -> EllipticCurveParameters { + switch (curve) { + case EllipticCurve::P256: + return curve_p256(); + case EllipticCurve::P384: + return curve_p384(); + case EllipticCurve::P521: + return curve_p521(); + } + + std::unreachable(); +} +} // namespace + +auto ecdh_derive(const PrivateKey &private_key, const PublicKey &public_key) + -> std::optional { + const auto *const private_internal{private_key.internal()}; + const auto *const public_internal{public_key.internal()}; + if (private_internal == nullptr || public_internal == nullptr || + private_internal->kind != PrivateKey::Type::EllipticCurve || + public_internal->kind != PublicKey::Type::EllipticCurve || + private_internal->elliptic_curve != public_internal->elliptic_curve) { + return std::nullopt; + } + + const auto parameters{to_curve_parameters(private_internal->elliptic_curve)}; + const auto peer_x{bignum_from_bytes(public_internal->coordinate_x)}; + const auto peer_y{bignum_from_bytes(public_internal->coordinate_y)}; + // Invalid-curve defense: the peer coordinates must be below the field prime + // and satisfy the curve equation + if (bignum_compare(peer_x, parameters.prime) >= 0 || + bignum_compare(peer_y, parameters.prime) >= 0 || + !point_on_curve(peer_x, peer_y, parameters)) { + return std::nullopt; + } + + const JacobianPoint peer_point{ + .x = peer_x, .y = peer_y, .z = bignum_from_u64(1)}; + auto scalar{bignum_from_bytes(private_internal->scalar)}; + const SecureBignumScope scalar_scope{scalar}; + const auto product{ + point_scalar_multiply_constant_time(scalar, peer_point, parameters)}; + const auto shared_x{point_affine_x_constant_time(product, parameters)}; + return bignum_to_bytes(shared_x, parameters.field_bytes); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_ecdh_windows.cc b/vendor/core/src/core/crypto/crypto_ecdh_windows.cc new file mode 100644 index 00000000..ed48b489 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_ecdh_windows.cc @@ -0,0 +1,179 @@ +#include + +#include "crypto_windows.h" + +#include // ULONG, PUCHAR, LPCWSTR +// clang-format off +#include // BCrypt*, BCRYPT_* +// clang-format on + +#include // std::reverse +#include // std::size_t +#include // std::memcpy +#include // std::optional, std::nullopt +#include // std::string +#include // std::move + +namespace sourcemeta::core { + +namespace { +// The elliptic curve is recovered from the field width, since the sign backend +// stores that rather than the curve identifier +auto to_ecdh_algorithm(const std::size_t field_bytes) noexcept -> LPCWSTR { + switch (field_bytes) { + case 32: + return BCRYPT_ECDH_P256_ALGORITHM; + case 48: + return BCRYPT_ECDH_P384_ALGORITHM; + case 66: + return BCRYPT_ECDH_P521_ALGORITHM; + default: + return nullptr; + } +} + +auto to_ecdh_private_magic(const std::size_t field_bytes) noexcept -> ULONG { + switch (field_bytes) { + case 32: + return BCRYPT_ECDH_PRIVATE_P256_MAGIC; + case 48: + return BCRYPT_ECDH_PRIVATE_P384_MAGIC; + default: + return BCRYPT_ECDH_PRIVATE_P521_MAGIC; + } +} + +auto to_ecdh_public_magic(const std::size_t field_bytes) noexcept -> ULONG { + switch (field_bytes) { + case 32: + return BCRYPT_ECDH_PUBLIC_P256_MAGIC; + case 48: + return BCRYPT_ECDH_PUBLIC_P384_MAGIC; + default: + return BCRYPT_ECDH_PUBLIC_P521_MAGIC; + } +} + +struct EcdhKey { + BCRYPT_ALG_HANDLE algorithm; + BCRYPT_KEY_HANDLE key; +}; + +auto destroy(const EcdhKey &key) -> void { + if (key.key != nullptr) { + BCryptDestroyKey(key.key); + } + + if (key.algorithm != nullptr) { + BCryptCloseAlgorithmProvider(key.algorithm, 0); + } +} + +// The sign backend imports elliptic curve keys under the ECDSA provider, which +// cannot perform key agreement, so the key is exported and re-imported under +// the ECDH provider, rewriting the blob magic that identifies the algorithm +auto reimport_under_ecdh(const BCRYPT_KEY_HANDLE source, + const LPCWSTR blob_type, const LPCWSTR algorithm_id, + const ULONG magic) -> EcdhKey { + ULONG length{0}; + if (!BCRYPT_SUCCESS(BCryptExportKey(source, nullptr, blob_type, nullptr, 0, + &length, 0)) || + length < sizeof(magic)) { + return {.algorithm = nullptr, .key = nullptr}; + } + + std::string blob(length, '\x00'); + if (!BCRYPT_SUCCESS(BCryptExportKey( + source, nullptr, blob_type, reinterpret_cast(blob.data()), + static_cast(blob.size()), &length, 0))) { + return {.algorithm = nullptr, .key = nullptr}; + } + + std::memcpy(blob.data(), &magic, sizeof(magic)); + + BCRYPT_ALG_HANDLE algorithm{nullptr}; + if (!BCRYPT_SUCCESS( + BCryptOpenAlgorithmProvider(&algorithm, algorithm_id, nullptr, 0))) { + return {.algorithm = nullptr, .key = nullptr}; + } + + BCRYPT_KEY_HANDLE key{nullptr}; + if (!BCRYPT_SUCCESS(BCryptImportKeyPair(algorithm, nullptr, blob_type, &key, + reinterpret_cast(blob.data()), + static_cast(blob.size()), + 0))) { + BCryptCloseAlgorithmProvider(algorithm, 0); + return {.algorithm = nullptr, .key = nullptr}; + } + + return {.algorithm = algorithm, .key = key}; +} +} // namespace + +auto ecdh_derive(const PrivateKey &private_key, const PublicKey &public_key) + -> std::optional { + const auto *const private_internal{private_key.internal()}; + const auto *const public_internal{public_key.internal()}; + if (private_internal == nullptr || public_internal == nullptr || + private_internal->kind != PrivateKey::Type::EllipticCurve || + public_internal->kind != PublicKey::Type::EllipticCurve || + private_internal->field_bytes != public_internal->field_bytes) { + return std::nullopt; + } + + const auto field_bytes{private_internal->field_bytes}; + const auto algorithm_id{to_ecdh_algorithm(field_bytes)}; + if (algorithm_id == nullptr) { + return std::nullopt; + } + + const auto private_ecdh{ + reimport_under_ecdh(private_internal->key, BCRYPT_ECCPRIVATE_BLOB, + algorithm_id, to_ecdh_private_magic(field_bytes))}; + if (private_ecdh.key == nullptr) { + return std::nullopt; + } + + const auto public_ecdh{ + reimport_under_ecdh(public_internal->key, BCRYPT_ECCPUBLIC_BLOB, + algorithm_id, to_ecdh_public_magic(field_bytes))}; + if (public_ecdh.key == nullptr) { + destroy(private_ecdh); + return std::nullopt; + } + + std::optional result; + BCRYPT_SECRET_HANDLE secret{nullptr}; + if (BCRYPT_SUCCESS(BCryptSecretAgreement(private_ecdh.key, public_ecdh.key, + &secret, 0))) { + ULONG length{0}; + if (BCRYPT_SUCCESS(BCryptDeriveKey(secret, BCRYPT_KDF_RAW_SECRET, nullptr, + nullptr, 0, &length, 0))) { + std::string secret_bytes(length, '\x00'); + ULONG written{0}; + if (BCRYPT_SUCCESS(BCryptDeriveKey( + secret, BCRYPT_KDF_RAW_SECRET, nullptr, + reinterpret_cast(secret_bytes.data()), + static_cast(secret_bytes.size()), &written, 0)) && + written <= field_bytes) { + secret_bytes.resize(written); + // The raw secret is little-endian, so it is reversed into the + // big-endian form the other backends return + std::reverse(secret_bytes.begin(), secret_bytes.end()); + // Guarantee the fixed-length x coordinate the contract promises, since + // a leading zero could otherwise shorten the platform output + secret_bytes.insert(secret_bytes.begin(), + field_bytes - secret_bytes.size(), '\x00'); + result = std::move(secret_bytes); + } + } + + BCryptDestroySecret(secret); + } + + destroy(public_ecdh); + destroy(private_ecdh); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.mm b/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.mm index 33bf6ffd..065258d0 100644 --- a/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.mm +++ b/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.mm @@ -40,43 +40,62 @@ } } -extern "C" auto sourcemeta_core_aes_256_gcm_seal_cryptokit( +extern "C" auto sourcemeta_core_aes_gcm_seal_cryptokit( const unsigned char *key, std::size_t key_size, const unsigned char *nonce, std::size_t nonce_size, const unsigned char *plaintext, - std::size_t plaintext_size, unsigned char *output) -> bool { + std::size_t plaintext_size, const unsigned char *associated_data, + std::size_t associated_data_size, unsigned char *ciphertext_output, + unsigned char *tag_output) -> bool { @autoreleasepool { NSData *const key_data{[NSData dataWithBytes:key length:key_size]}; NSData *const nonce_data{[NSData dataWithBytes:nonce length:nonce_size]}; NSData *const plaintext_data{[NSData dataWithBytes:plaintext length:plaintext_size]}; + NSData *const associated{[NSData dataWithBytes:associated_data + length:associated_data_size]}; NSData *const result{[SourcemetaCoreAESGCM sealWithKey:key_data nonce:nonce_data - plaintext:plaintext_data]}; - if (output == nullptr || result == nil || - result.length != plaintext_size + 16) { + plaintext:plaintext_data + associatedData:associated]}; + // CryptoKit returns the ciphertext followed by its 16-byte tag + if (result == nil || result.length != plaintext_size + 16 || + tag_output == nullptr) { return false; } - std::memcpy(output, result.bytes, result.length); + const auto *const bytes{static_cast(result.bytes)}; + if (plaintext_size > 0) { + if (ciphertext_output == nullptr) { + return false; + } + + std::memcpy(ciphertext_output, bytes, plaintext_size); + } + + std::memcpy(tag_output, bytes + plaintext_size, 16); return true; } } -extern "C" auto sourcemeta_core_aes_256_gcm_open_cryptokit( +extern "C" auto sourcemeta_core_aes_gcm_open_cryptokit( const unsigned char *key, std::size_t key_size, const unsigned char *nonce, std::size_t nonce_size, const unsigned char *ciphertext, std::size_t ciphertext_size, const unsigned char *tag, - std::size_t tag_size, unsigned char *output) -> bool { + std::size_t tag_size, const unsigned char *associated_data, + std::size_t associated_data_size, unsigned char *output) -> bool { @autoreleasepool { NSData *const key_data{[NSData dataWithBytes:key length:key_size]}; NSData *const nonce_data{[NSData dataWithBytes:nonce length:nonce_size]}; NSData *const ciphertext_data{[NSData dataWithBytes:ciphertext length:ciphertext_size]}; NSData *const tag_data{[NSData dataWithBytes:tag length:tag_size]}; + NSData *const associated{[NSData dataWithBytes:associated_data + length:associated_data_size]}; NSData *const result{[SourcemetaCoreAESGCM openWithKey:key_data nonce:nonce_data ciphertext:ciphertext_data - tag:tag_data]}; + tag:tag_data + associatedData:associated]}; if (result == nil) { return false; } diff --git a/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.swift b/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.swift index 4a9dd6ed..d2e82fc6 100644 --- a/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.swift +++ b/vendor/core/src/core/crypto/crypto_eddsa_cryptokit.swift @@ -27,13 +27,13 @@ public final class SourcemetaCoreEd25519: NSObject { } } -// AES-256 in Galois/Counter Mode, which the Apple Security framework does not +// AES in Galois/Counter Mode, which the Apple Security framework does not // expose through its C API either. CryptoKit provides it since macOS 10.15 @objc(SourcemetaCoreAESGCM) public final class SourcemetaCoreAESGCM: NSObject { - @objc public static func seal(key: Data, nonce: Data, - plaintext: Data) -> Data? { - guard key.count == 32 else { + @objc public static func seal(key: Data, nonce: Data, plaintext: Data, + associatedData: Data) -> Data? { + guard key.count == 16 || key.count == 24 || key.count == 32 else { return nil } @@ -43,7 +43,7 @@ public final class SourcemetaCoreAESGCM: NSObject { guard let box = try? AES.GCM.seal( plaintext, using: SymmetricKey(data: key), - nonce: sealingNonce) else { + nonce: sealingNonce, authenticating: associatedData) else { return nil } @@ -51,8 +51,8 @@ public final class SourcemetaCoreAESGCM: NSObject { } @objc public static func open(key: Data, nonce: Data, ciphertext: Data, - tag: Data) -> Data? { - guard key.count == 32 else { + tag: Data, associatedData: Data) -> Data? { + guard key.count == 16 || key.count == 24 || key.count == 32 else { return nil } @@ -62,6 +62,7 @@ public final class SourcemetaCoreAESGCM: NSObject { return nil } - return try? AES.GCM.open(box, using: SymmetricKey(data: key)) + return try? AES.GCM.open( + box, using: SymmetricKey(data: key), authenticating: associatedData) } } diff --git a/vendor/core/src/core/crypto/crypto_helpers.h b/vendor/core/src/core/crypto/crypto_helpers.h index b5f99c06..96e66a94 100644 --- a/vendor/core/src/core/crypto/crypto_helpers.h +++ b/vendor/core/src/core/crypto/crypto_helpers.h @@ -21,6 +21,17 @@ namespace sourcemeta::core { // the range of valid key sizes inline constexpr std::size_t MAXIMUM_KEY_BYTES{512}; +// Append a 32-bit integer in big-endian (network) order, the encoding that the +// mask generation function of RFC 8017 and the Concat KDF of RFC 7518 use for +// their counters and length fields +inline auto append_big_endian_uint32(std::string &target, + const std::uint32_t value) -> void { + target.push_back(static_cast((value >> 24u) & 0xffu)); + target.push_back(static_cast((value >> 16u) & 0xffu)); + target.push_back(static_cast((value >> 8u) & 0xffu)); + target.push_back(static_cast(value & 0xffu)); +} + // The same guard for a raw buffer, so a secret that a fixed-size digest array // holds is wiped when leaving the current scope struct SecureBufferScope { @@ -113,6 +124,23 @@ inline auto curve_field_bytes(const EllipticCurve curve) noexcept std::unreachable(); } +// The order bit length of each curve, which the platform key generators take as +// the requested key size. It is not the field width in bits, since P-521 has a +// 521-bit order that does not fill its 66 octets +inline auto curve_bit_length(const EllipticCurve curve) noexcept + -> std::size_t { + switch (curve) { + case EllipticCurve::P256: + return 256; + case EllipticCurve::P384: + return 384; + case EllipticCurve::P521: + return 521; + } + + std::unreachable(); +} + // The inverse mapping, identifying the curve from its field width, so a backend // that reports a key only by coordinate size resolves it the same way inline auto ec_curve_from_field_bytes(const std::size_t field_bytes) noexcept diff --git a/vendor/core/src/core/crypto/crypto_hmac_sha256_apple.cc b/vendor/core/src/core/crypto/crypto_hmac_sha256_apple.cc index 4c8e3a3b..7d15b3fd 100644 --- a/vendor/core/src/core/crypto/crypto_hmac_sha256_apple.cc +++ b/vendor/core/src/core/crypto/crypto_hmac_sha256_apple.cc @@ -1,9 +1,13 @@ #include -#include // CCHmac, kCCHmacAlgSHA256 +// CCHmac, CCHmacContext, CCHmacInit, CCHmacUpdate, CCHmacFinal, +// kCCHmacAlgSHA256 +#include -#include // std::array -#include // std::uint8_t +#include // std::array +#include // std::uint8_t +#include // std::span +#include // std::string_view namespace sourcemeta::core { @@ -16,4 +20,19 @@ auto hmac_sha256_digest(const std::string_view key, return digest; } +auto hmac_sha256_digest(const std::string_view key, + const std::span message) + -> std::array { + CCHmacContext context; + CCHmacInit(&context, kCCHmacAlgSHA256, key.data(), key.size()); + + for (const auto part : message) { + CCHmacUpdate(&context, part.data(), part.size()); + } + + std::array digest{}; + CCHmacFinal(&context, digest.data()); + return digest; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_hmac_sha256_openssl.cc b/vendor/core/src/core/crypto/crypto_hmac_sha256_openssl.cc index 6e1deb16..3591ce1c 100644 --- a/vendor/core/src/core/crypto/crypto_hmac_sha256_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_hmac_sha256_openssl.cc @@ -1,41 +1,89 @@ #include -#include -#include // EVP_sha256 -#include // HMAC +#include // OSSL_MAC_PARAM_DIGEST +#include // EVP_MAC_* +#include // OSSL_PARAM, OSSL_PARAM_construct_* -#include // std::array -#include // std::size_t -#include // std::uint8_t -#include // std::runtime_error +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::span +#include // std::runtime_error +#include // std::string_view + +namespace { + +// Fetching the algorithm on every digest would repeat a provider lookup, so +// the handle is fetched once and reused, as OpenSSL recommends for frequently +// used algorithms. A failed fetch throws so that initialization is retried on +// a later call, as a provider may be loaded after the first attempt +struct HmacAlgorithm { + HmacAlgorithm() : handle{EVP_MAC_fetch(nullptr, "HMAC", nullptr)} { + if (this->handle == nullptr) { + throw std::runtime_error("Could not fetch the OpenSSL HMAC algorithm"); + } + } + ~HmacAlgorithm() { EVP_MAC_free(this->handle); } + HmacAlgorithm(const HmacAlgorithm &) = delete; + HmacAlgorithm(HmacAlgorithm &&) = delete; + auto operator=(const HmacAlgorithm &) -> HmacAlgorithm & = delete; + auto operator=(HmacAlgorithm &&) -> HmacAlgorithm & = delete; + EVP_MAC *handle; +}; + +} // namespace namespace sourcemeta::core { auto hmac_sha256_digest(const std::string_view key, - const std::string_view message) + const std::span message) -> std::array { - // A key longer than the block size is hashed first (RFC 2104 Section 2), - // which also keeps the key length within the OpenSSL length parameter - constexpr std::size_t block_size{64}; - std::array key_digest{}; - const unsigned char *key_data{ - reinterpret_cast(key.data())}; - auto key_size{key.size()}; - if (key_size > block_size) { - key_digest = sha256_digest(key); - key_data = key_digest.data(); - key_size = key_digest.size(); + static const HmacAlgorithm algorithm; + auto *context = EVP_MAC_CTX_new(algorithm.handle); + if (context == nullptr) { + throw std::runtime_error("Could not allocate OpenSSL MAC context"); + } + + // The parameter interface is not const-qualified but never writes through + // the pointer + std::array parameters{ + {OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST, + const_cast("SHA256"), 0), + OSSL_PARAM_construct_end()}}; + + // An empty view may carry a null pointer, which the MAC interface would + // interpret as a request to reuse a previously set key + static constexpr unsigned char empty_key{0x00}; + const auto *key_data{ + key.empty() ? &empty_key + : reinterpret_cast(key.data())}; + + auto success{EVP_MAC_init(context, key_data, key.size(), parameters.data()) == + 1}; + for (const auto part : message) { + success = success && + EVP_MAC_update( + context, reinterpret_cast(part.data()), + part.size()) == 1; } std::array digest{}; - unsigned int length{0}; - if (HMAC(EVP_sha256(), key_data, static_cast(key_size), - reinterpret_cast(message.data()), - message.size(), digest.data(), &length) == nullptr) { + std::size_t length{0}; + success = success && + EVP_MAC_final(context, digest.data(), &length, digest.size()) == 1; + EVP_MAC_CTX_free(context); + if (!success) { throw std::runtime_error("Could not compute HMAC-SHA256 digest"); } return digest; } +auto hmac_sha256_digest(const std::string_view key, + const std::string_view message) + -> std::array { + return hmac_sha256_digest(key, + std::span{&message, 1}); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_hmac_sha256_other.cc b/vendor/core/src/core/crypto/crypto_hmac_sha256_other.cc index 93432d42..99eb6e50 100644 --- a/vendor/core/src/core/crypto/crypto_hmac_sha256_other.cc +++ b/vendor/core/src/core/crypto/crypto_hmac_sha256_other.cc @@ -1,16 +1,19 @@ #include #include -#include // std::array -#include // std::size_t -#include // std::uint8_t -#include // std::memcpy -#include // std::string +#include "crypto_sha256_other.h" + +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::memcpy +#include // std::span +#include // std::string_view namespace sourcemeta::core { auto hmac_sha256_digest(const std::string_view key, - const std::string_view message) + const std::span message) -> std::array { // SHA-256 operates on 64-byte blocks (FIPS 180-4 Section 5.1.1), which is the // block size the HMAC construction pads the key to (RFC 2104 Section 2) @@ -28,18 +31,38 @@ auto hmac_sha256_digest(const std::string_view key, // H((K XOR opad) || H((K XOR ipad) || message)) with ipad = 0x36 and // opad = 0x5c repeated to the block size (RFC 2104 Section 2) - std::string inner_input(block_size, '\x00'); - std::string outer_input(block_size, '\x00'); + std::array inner_key{}; + std::array outer_key{}; for (std::size_t index = 0; index < block_size; ++index) { - inner_input[index] = static_cast(padded_key[index] ^ 0x36); - outer_input[index] = static_cast(padded_key[index] ^ 0x5c); + inner_key[index] = static_cast(padded_key[index] ^ 0x36); + outer_key[index] = static_cast(padded_key[index] ^ 0x5c); } - inner_input.append(message); - const auto inner_digest{sha256_digest(inner_input)}; - outer_input.append(reinterpret_cast(inner_digest.data()), - inner_digest.size()); - return sha256_digest(outer_input); + Sha256Context inner_context; + sha256_update( + inner_context, + {reinterpret_cast(inner_key.data()), inner_key.size()}); + for (const auto part : message) { + sha256_update(inner_context, part); + } + + const auto inner_digest{sha256_finalize(inner_context)}; + + Sha256Context outer_context; + sha256_update( + outer_context, + {reinterpret_cast(outer_key.data()), outer_key.size()}); + sha256_update(outer_context, + {reinterpret_cast(inner_digest.data()), + inner_digest.size()}); + return sha256_finalize(outer_context); +} + +auto hmac_sha256_digest(const std::string_view key, + const std::string_view message) + -> std::array { + return hmac_sha256_digest(key, + std::span{&message, 1}); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_hmac_sha256_windows.cc b/vendor/core/src/core/crypto/crypto_hmac_sha256_windows.cc index f12d6994..c502c1c0 100644 --- a/vendor/core/src/core/crypto/crypto_hmac_sha256_windows.cc +++ b/vendor/core/src/core/crypto/crypto_hmac_sha256_windows.cc @@ -7,16 +7,19 @@ #include // BCrypt*, BCRYPT_* -#include // std::array -#include // std::size_t -#include // std::uint8_t -#include // std::numeric_limits -#include // std::runtime_error +#include "crypto_windows.h" + +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::span +#include // std::runtime_error +#include // std::string_view namespace sourcemeta::core { auto hmac_sha256_digest(const std::string_view key, - const std::string_view message) + const std::span message) -> std::array { // A key longer than the block size is hashed first (RFC 2104 Section 2), // which also keeps the key length within the CNG length parameter. The @@ -48,21 +51,9 @@ auto hmac_sha256_digest(const std::string_view key, throw std::runtime_error("Could not create the CNG HMAC-SHA256 hash"); } - // The data interface is not const-qualified but never writes through - // the pointer, and it takes a 32-bit length, so larger inputs must be - // fed in chunks - auto *remaining_data{ - reinterpret_cast(const_cast(message.data()))}; - auto remaining_size{message.size()}; - constexpr std::size_t maximum_chunk{std::numeric_limits::max()}; auto success{true}; - while (remaining_size > 0 && success) { - const auto chunk_size{remaining_size > maximum_chunk ? maximum_chunk - : remaining_size}; - success = BCRYPT_SUCCESS(BCryptHashData(hash, remaining_data, - static_cast(chunk_size), 0)); - remaining_data += chunk_size; - remaining_size -= chunk_size; + for (const auto part : message) { + success = success && hash_data_chunked(hash, part); } std::array digest{}; @@ -80,4 +71,11 @@ auto hmac_sha256_digest(const std::string_view key, return digest; } +auto hmac_sha256_digest(const std::string_view key, + const std::string_view message) + -> std::array { + return hmac_sha256_digest(key, + std::span{&message, 1}); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_openssl.h b/vendor/core/src/core/crypto/crypto_openssl.h index a292d764..bd881bd9 100644 --- a/vendor/core/src/core/crypto/crypto_openssl.h +++ b/vendor/core/src/core/crypto/crypto_openssl.h @@ -1,9 +1,11 @@ #ifndef SOURCEMETA_CORE_CRYPTO_OPENSSL_H_ #define SOURCEMETA_CORE_CRYPTO_OPENSSL_H_ -// OpenSSL key export helpers shared by the signing and verification backends, -// so the provider parameter handling and the curve mapping stay single-sourced +// The OpenSSL key internals and the export helpers shared across the OpenSSL +// backends, so the parsed key layout, the provider parameter handling, and the +// curve mapping stay single-sourced +#include #include #include // BIGNUM, BN_* @@ -18,6 +20,27 @@ namespace sourcemeta::core { +// The parsed key keeps the native handle alive so that many operations reuse it +struct PublicKey::Internal { + PublicKey::Type kind; + EVP_PKEY *key; + // The stripped modulus, kept for the RSA signature range check + std::string modulus; + // The field width for the elliptic curve signature size check + std::size_t field_bytes; + // The expected signature length for the Edwards curve + std::size_t signature_bytes; +}; + +struct PrivateKey::Internal { + PrivateKey::Type kind; + EVP_PKEY *key; + // The field width for the elliptic curve raw signature encoding + std::size_t field_bytes; + // Set for an id-RSASSA-PSS key, which is refused for PKCS1v15 signing + bool rsa_pss_restricted{false}; +}; + // The platform reports the curve under its canonical name rather than the JWK // alias the key was built with, so both are accepted inline auto ec_curve_from_group_name(const std::string_view name) noexcept diff --git a/vendor/core/src/core/crypto/crypto_other.h b/vendor/core/src/core/crypto/crypto_other.h new file mode 100644 index 00000000..66c1b705 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_other.h @@ -0,0 +1,44 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_OTHER_H_ +#define SOURCEMETA_CORE_CRYPTO_OTHER_H_ + +// The reference backend key internals, so the parsed key layout stays +// single-sourced across the reference backends + +#include +#include + +#include // std::string + +namespace sourcemeta::core { + +struct PublicKey::Internal { + PublicKey::Type kind; + std::string modulus; + std::string exponent; + std::string coordinate_x; + std::string coordinate_y; + EllipticCurve elliptic_curve; + EdwardsCurve edwards_curve; +}; + +struct PrivateKey::Internal { + PrivateKey::Type kind; + std::string modulus; + std::string public_exponent; + std::string private_exponent; + std::string scalar; + EllipticCurve elliptic_curve; + std::string edwards_seed; + EdwardsCurve edwards_curve; + bool rsa_pss_restricted{false}; + // The public coordinates, kept so the public key can be recovered without + // recomputing the point from the scalar. A key parsed from a PEM document, + // which carries only the scalar on this backend, recomputes them at parse + // time, so they are populated for every elliptic curve private key + std::string coordinate_x; + std::string coordinate_y; +}; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc b/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc new file mode 100644 index 00000000..888ef531 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_rsa_oaep_apple.cc @@ -0,0 +1,91 @@ +#include + +#include "crypto_apple.h" + +#include // CF*, kCF* +#include // Sec*, kSec* + +#include // std::size_t +#include // std::uint8_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +namespace { +auto make_data(const std::string_view value) -> CFDataRef { + return CFDataCreate(kCFAllocatorDefault, + reinterpret_cast(value.data()), + static_cast(value.size())); +} + +auto to_oaep_algorithm(const RSAOAEPHash hash) -> SecKeyAlgorithm { + switch (hash) { + case RSAOAEPHash::SHA1: + return kSecKeyAlgorithmRSAEncryptionOAEPSHA1; + case RSAOAEPHash::SHA256: + return kSecKeyAlgorithmRSAEncryptionOAEPSHA256; + } + + std::unreachable(); +} +} // namespace + +auto rsa_oaep_encrypt(const PublicKey &key, const RSAOAEPHash hash, + const std::string_view plaintext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + auto plaintext_data{make_data(plaintext)}; + if (plaintext_data == nullptr) { + return std::nullopt; + } + + auto ciphertext{SecKeyCreateEncryptedData( + internal->key, to_oaep_algorithm(hash), plaintext_data, nullptr)}; + CFRelease(plaintext_data); + if (ciphertext == nullptr) { + return std::nullopt; + } + + std::string result{ + reinterpret_cast(CFDataGetBytePtr(ciphertext)), + static_cast(CFDataGetLength(ciphertext))}; + CFRelease(ciphertext); + return result; +} + +auto rsa_oaep_decrypt(const PrivateKey &key, const RSAOAEPHash hash, + const std::string_view ciphertext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PrivateKey::Type::RSA || + internal->rsa_pss_restricted) { + return std::nullopt; + } + + auto ciphertext_data{make_data(ciphertext)}; + if (ciphertext_data == nullptr) { + return std::nullopt; + } + + auto plaintext{SecKeyCreateDecryptedData( + internal->key, to_oaep_algorithm(hash), ciphertext_data, nullptr)}; + CFRelease(ciphertext_data); + if (plaintext == nullptr) { + return std::nullopt; + } + + std::string result{ + reinterpret_cast(CFDataGetBytePtr(plaintext)), + static_cast(CFDataGetLength(plaintext))}; + CFRelease(plaintext); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_rsa_oaep_openssl.cc b/vendor/core/src/core/crypto/crypto_rsa_oaep_openssl.cc new file mode 100644 index 00000000..7f59cebd --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_rsa_oaep_openssl.cc @@ -0,0 +1,111 @@ +#include + +#include "crypto_openssl.h" + +#include // EVP_* +#include // RSA_PKCS1_OAEP_PADDING + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move, std::unreachable + +namespace sourcemeta::core { + +namespace { +auto to_message_digest(const RSAOAEPHash hash) -> const EVP_MD * { + switch (hash) { + case RSAOAEPHash::SHA1: + return EVP_sha1(); + case RSAOAEPHash::SHA256: + return EVP_sha256(); + } + + std::unreachable(); +} + +auto configure(EVP_PKEY_CTX *context, const RSAOAEPHash hash) -> bool { + return EVP_PKEY_CTX_set_rsa_padding(context, RSA_PKCS1_OAEP_PADDING) == 1 && + EVP_PKEY_CTX_set_rsa_oaep_md(context, to_message_digest(hash)) == 1 && + EVP_PKEY_CTX_set_rsa_mgf1_md(context, to_message_digest(hash)) == 1; +} +} // namespace + +auto rsa_oaep_encrypt(const PublicKey &key, const RSAOAEPHash hash, + const std::string_view plaintext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + auto *context{EVP_PKEY_CTX_new(internal->key, nullptr)}; + if (context == nullptr) { + return std::nullopt; + } + + const auto *const input{ + reinterpret_cast(plaintext.data())}; + std::optional result; + std::size_t length{0}; + if (EVP_PKEY_encrypt_init(context) == 1 && configure(context, hash) && + EVP_PKEY_encrypt(context, nullptr, &length, input, plaintext.size()) == + 1) { + std::string ciphertext(length, '\x00'); + if (EVP_PKEY_encrypt(context, + reinterpret_cast(ciphertext.data()), + &length, input, plaintext.size()) == 1) { + ciphertext.resize(length); + result = std::move(ciphertext); + } + } + + EVP_PKEY_CTX_free(context); + return result; +} + +auto rsa_oaep_decrypt(const PrivateKey &key, const RSAOAEPHash hash, + const std::string_view ciphertext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PrivateKey::Type::RSA || + internal->rsa_pss_restricted) { + return std::nullopt; + } + + // OpenSSL reads the ciphertext as a raw integer, so a shorter input with a + // dropped leading zero would decrypt as the same value. Require the exact + // modulus size so that a malformed ciphertext is rejected + if (ciphertext.size() != + static_cast(EVP_PKEY_get_size(internal->key))) { + return std::nullopt; + } + + auto *context{EVP_PKEY_CTX_new(internal->key, nullptr)}; + if (context == nullptr) { + return std::nullopt; + } + + const auto *const input{ + reinterpret_cast(ciphertext.data())}; + std::optional result; + std::size_t length{0}; + if (EVP_PKEY_decrypt_init(context) == 1 && configure(context, hash) && + EVP_PKEY_decrypt(context, nullptr, &length, input, ciphertext.size()) == + 1) { + std::string plaintext(length, '\x00'); + // A failed OAEP check surfaces as a non-positive return here + if (EVP_PKEY_decrypt(context, + reinterpret_cast(plaintext.data()), + &length, input, ciphertext.size()) == 1) { + plaintext.resize(length); + result = std::move(plaintext); + } + } + + EVP_PKEY_CTX_free(context); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_rsa_oaep_other.cc b/vendor/core/src/core/crypto/crypto_rsa_oaep_other.cc new file mode 100644 index 00000000..d23945bc --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_rsa_oaep_other.cc @@ -0,0 +1,210 @@ +#include +#include + +#include "crypto_bignum.h" +#include "crypto_helpers.h" +#include "crypto_other.h" +#include "crypto_random.h" +#include "crypto_sha1_other.h" + +#include // std::size_t +#include // std::uint8_t, std::uint32_t +#include // std::exception +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view +#include // std::unreachable + +// A from-scratch RSA-OAEP (RFC 8017 Section 7.1) for the reference backend, +// over the shared big integer arithmetic. The decode is not constant-time, +// which is acceptable only because this backend is the non-production fallback +// and the production backends run the platform's constant-time implementation. +// A failed check still surfaces as a single uniform failure, never a +// distinguishing one + +namespace sourcemeta::core { + +namespace { +auto hash_length(const RSAOAEPHash hash) -> std::size_t { + switch (hash) { + case RSAOAEPHash::SHA1: + return 20; + case RSAOAEPHash::SHA256: + return 32; + } + + std::unreachable(); +} + +auto oaep_hash(const RSAOAEPHash hash, const std::string_view input) + -> std::string { + switch (hash) { + case RSAOAEPHash::SHA1: { + const auto digest{sha1_digest(input)}; + return std::string{reinterpret_cast(digest.data()), + digest.size()}; + } + case RSAOAEPHash::SHA256: { + const auto digest{sha256_digest(input)}; + return std::string{reinterpret_cast(digest.data()), + digest.size()}; + } + } + + std::unreachable(); +} + +// The mask generation function (RFC 8017 Appendix B.2.1) +auto mask_generation(const RSAOAEPHash hash, const std::string_view seed, + const std::size_t length) -> std::string { + std::string result; + result.reserve(length + hash_length(hash)); + std::uint32_t counter{0}; + while (result.size() < length) { + std::string block{seed}; + append_big_endian_uint32(block, counter); + result.append(oaep_hash(hash, block)); + counter += 1; + } + + result.resize(length); + return result; +} + +auto exclusive_or(const std::string_view left, const std::string_view right) + -> std::string { + std::string result(left.size(), '\x00'); + for (std::size_t index = 0; index < left.size(); ++index) { + result[index] = static_cast(static_cast(left[index]) ^ + static_cast(right[index])); + } + + return result; +} +} // namespace + +auto rsa_oaep_encrypt(const PublicKey &key, const RSAOAEPHash hash, + const std::string_view plaintext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + const auto key_length{internal->modulus.size()}; + const auto digest_length{hash_length(hash)}; + if (key_length < (2 * digest_length) + 2 || + plaintext.size() > key_length - (2 * digest_length) - 2) { + return std::nullopt; + } + + // DB = lHash || PS || 0x01 || M (RFC 8017 Section 7.1.1) + const auto label_hash{oaep_hash(hash, "")}; + std::string data_block{label_hash}; + data_block.append(key_length - plaintext.size() - (2 * digest_length) - 2, + '\x00'); + data_block.push_back('\x01'); + data_block.append(plaintext); + + std::string seed(digest_length, '\x00'); + try { + fill_random_bytes(std::span{ + reinterpret_cast(seed.data()), digest_length}); + } catch (const std::exception &) { + return std::nullopt; + } + + const auto masked_data_block{exclusive_or( + data_block, mask_generation(hash, seed, key_length - digest_length - 1))}; + const auto masked_seed{exclusive_or( + seed, mask_generation(hash, masked_data_block, digest_length))}; + + // EM = 0x00 || maskedSeed || maskedDB + std::string encoded_message; + encoded_message.reserve(key_length); + encoded_message.push_back('\x00'); + encoded_message.append(masked_seed); + encoded_message.append(masked_data_block); + + const auto ciphertext{bignum_mod_exp(bignum_from_bytes(encoded_message), + bignum_from_bytes(internal->exponent), + bignum_from_bytes(internal->modulus))}; + return bignum_to_bytes(ciphertext, key_length); +} + +auto rsa_oaep_decrypt(const PrivateKey &key, const RSAOAEPHash hash, + const std::string_view ciphertext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PrivateKey::Type::RSA || + internal->rsa_pss_restricted) { + return std::nullopt; + } + + const auto key_length{internal->modulus.size()}; + const auto digest_length{hash_length(hash)}; + if (key_length < (2 * digest_length) + 2 || ciphertext.size() != key_length) { + return std::nullopt; + } + + const auto modulus{bignum_from_bytes(internal->modulus)}; + auto ciphertext_number{bignum_from_bytes(ciphertext)}; + if (bignum_compare(ciphertext_number, modulus) >= 0) { + return std::nullopt; + } + + auto exponent{bignum_from_bytes(internal->private_exponent)}; + const SecureBignumScope exponent_scope{exponent}; + auto message_number{ + bignum_mod_exp_ct(ciphertext_number, exponent, barrett_context(modulus))}; + const SecureBignumScope message_scope{message_number}; + const auto encoded_message{bignum_to_bytes(message_number, key_length)}; + + // EM = Y || maskedSeed || maskedDB (RFC 8017 Section 7.1.2) + const std::string_view masked_seed{encoded_message.data() + 1, digest_length}; + const std::string_view masked_data_block{encoded_message.data() + 1 + + digest_length, + key_length - digest_length - 1}; + const auto seed{exclusive_or( + masked_seed, mask_generation(hash, masked_data_block, digest_length))}; + const auto data_block{exclusive_or( + masked_data_block, + mask_generation(hash, seed, key_length - digest_length - 1))}; + + const auto label_hash{oaep_hash(hash, "")}; + // Accumulate every failure into a single value so that a malformed input is + // rejected uniformly, never revealing which check failed + std::uint8_t error{static_cast(encoded_message[0])}; + for (std::size_t index = 0; index < digest_length; ++index) { + error = static_cast( + error | (static_cast(data_block[index]) ^ + static_cast(label_hash[index]))); + } + + std::size_t separator{0}; + std::uint8_t found{0}; + for (std::size_t index = digest_length; index < data_block.size(); ++index) { + const auto byte{static_cast(data_block[index])}; + const std::uint8_t is_one{static_cast(byte == 0x01 ? 1 : 0)}; + const std::uint8_t is_zero{static_cast(byte == 0x00 ? 1 : 0)}; + const std::uint8_t not_found{static_cast(1 - found)}; + separator += static_cast(not_found & is_one) * index; + // A byte before the separator that is neither the padding zero nor the + // separator one is malformed + error = static_cast( + error | (not_found & static_cast(1 - is_zero) & + static_cast(1 - is_one))); + found = static_cast(found | is_one); + } + + error = + static_cast(error | static_cast(1 - found)); + if (error != 0) { + return std::nullopt; + } + + return data_block.substr(separator + 1); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_rsa_oaep_windows.cc b/vendor/core/src/core/crypto/crypto_rsa_oaep_windows.cc new file mode 100644 index 00000000..6b263260 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_rsa_oaep_windows.cc @@ -0,0 +1,119 @@ +#include + +#include "crypto_windows.h" + +#include // ULONG, PUCHAR, LPCWSTR, DWORD +// clang-format off +#include // BCrypt*, BCRYPT_* +// clang-format on + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move, std::unreachable + +namespace sourcemeta::core { + +namespace { +auto to_cng_algorithm(const RSAOAEPHash hash) -> LPCWSTR { + switch (hash) { + case RSAOAEPHash::SHA1: + return BCRYPT_SHA1_ALGORITHM; + case RSAOAEPHash::SHA256: + return BCRYPT_SHA256_ALGORITHM; + } + + std::unreachable(); +} + +auto as_buffer(const std::string_view value) -> PUCHAR { + return reinterpret_cast(const_cast( + value.data())); // NOLINT(cppcoreguidelines-pro-type-const-cast) +} +} // namespace + +auto rsa_oaep_encrypt(const PublicKey &key, const RSAOAEPHash hash, + const std::string_view plaintext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PublicKey::Type::RSA) { + return std::nullopt; + } + + BCRYPT_OAEP_PADDING_INFO padding{}; + padding.pszAlgId = to_cng_algorithm(hash); + padding.pbLabel = nullptr; + padding.cbLabel = 0; + + ULONG length{0}; + if (!BCRYPT_SUCCESS(BCryptEncrypt(internal->key, as_buffer(plaintext), + static_cast(plaintext.size()), + &padding, nullptr, 0, nullptr, 0, &length, + BCRYPT_PAD_OAEP))) { + return std::nullopt; + } + + std::string ciphertext(length, '\x00'); + ULONG written{0}; + if (!BCRYPT_SUCCESS(BCryptEncrypt(internal->key, as_buffer(plaintext), + static_cast(plaintext.size()), + &padding, nullptr, 0, as_buffer(ciphertext), + static_cast(ciphertext.size()), + &written, BCRYPT_PAD_OAEP))) { + return std::nullopt; + } + + ciphertext.resize(written); + return ciphertext; +} + +auto rsa_oaep_decrypt(const PrivateKey &key, const RSAOAEPHash hash, + const std::string_view ciphertext) + -> std::optional { + const auto *const internal{key.internal()}; + if (internal == nullptr || internal->kind != PrivateKey::Type::RSA || + internal->rsa_pss_restricted) { + return std::nullopt; + } + + // Reject any ciphertext that is not exactly the modulus size, so that an + // input with appended or missing bytes cannot decrypt as the same integer + DWORD key_bits{0}; + ULONG property_size{0}; + if (!BCRYPT_SUCCESS(BCryptGetProperty(internal->key, BCRYPT_KEY_LENGTH, + reinterpret_cast(&key_bits), + sizeof(key_bits), &property_size, 0)) || + ciphertext.size() != (key_bits + 7u) / 8u) { + return std::nullopt; + } + + BCRYPT_OAEP_PADDING_INFO padding{}; + padding.pszAlgId = to_cng_algorithm(hash); + padding.pbLabel = nullptr; + padding.cbLabel = 0; + + ULONG length{0}; + // A failed OAEP check surfaces as an unsuccessful status + if (!BCRYPT_SUCCESS(BCryptDecrypt(internal->key, as_buffer(ciphertext), + static_cast(ciphertext.size()), + &padding, nullptr, 0, nullptr, 0, &length, + BCRYPT_PAD_OAEP))) { + return std::nullopt; + } + + std::string plaintext(length, '\x00'); + ULONG written{0}; + if (!BCRYPT_SUCCESS(BCryptDecrypt(internal->key, as_buffer(ciphertext), + static_cast(ciphertext.size()), + &padding, nullptr, 0, as_buffer(plaintext), + static_cast(plaintext.size()), + &written, BCRYPT_PAD_OAEP))) { + return std::nullopt; + } + + plaintext.resize(written); + return plaintext; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sha1_other.cc b/vendor/core/src/core/crypto/crypto_sha1_other.cc index 89fc9062..e5a7a47d 100644 --- a/vendor/core/src/core/crypto/crypto_sha1_other.cc +++ b/vendor/core/src/core/crypto/crypto_sha1_other.cc @@ -1,10 +1,13 @@ #include #include +#include "crypto_sha1_other.h" + #include // std::array #include // std::size_t -#include // std::uint32_t, std::uint64_t +#include // std::uint8_t, std::uint32_t, std::uint64_t #include // std::memcpy +#include // std::string namespace { @@ -95,7 +98,7 @@ inline auto sha1_process_block(const unsigned char *block, namespace sourcemeta::core { -auto sha1(const std::string_view input) -> std::string { +auto sha1_digest(const std::string_view input) -> std::array { // Initial hash values (RFC 3174 Section 6.1) std::array state{}; state[0] = 0x67452301U; @@ -146,14 +149,19 @@ auto sha1(const std::string_view input) -> std::string { sha1_process_block(final_block.data() + 64u, state); } - std::array digest{}; + std::array digest{}; for (std::uint64_t state_index = 0u; state_index < 5u; ++state_index) { for (std::uint64_t byte_index = 0u; byte_index < 4u; ++byte_index) { - digest[(state_index * 4u) + byte_index] = static_cast( + digest[(state_index * 4u) + byte_index] = static_cast( (state[state_index] >> (8u * (3u - byte_index))) & 0xffu); } } + return digest; +} + +auto sha1(const std::string_view input) -> std::string { + const auto digest{sha1_digest(input)}; return bytes_to_hex( {reinterpret_cast(digest.data()), digest.size()}); } diff --git a/vendor/core/src/core/crypto/crypto_sha1_other.h b/vendor/core/src/core/crypto/crypto_sha1_other.h new file mode 100644 index 00000000..17457d28 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_sha1_other.h @@ -0,0 +1,17 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_SHA1_OTHER_H_ +#define SOURCEMETA_CORE_CRYPTO_SHA1_OTHER_H_ + +// The raw SHA-1 digest (RFC 3174) for the fallback backend, used by the modes +// that need the digest bytes rather than the hexadecimal string + +#include // std::array +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +auto sha1_digest(const std::string_view input) -> std::array; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_sha256_apple.cc b/vendor/core/src/core/crypto/crypto_sha256_apple.cc index 2fccfea2..5a8f5506 100644 --- a/vendor/core/src/core/crypto/crypto_sha256_apple.cc +++ b/vendor/core/src/core/crypto/crypto_sha256_apple.cc @@ -2,18 +2,17 @@ #include // CC_SHA256*, CC_LONG -#include // std::array -#include // std::size_t -#include // std::uint8_t -#include // std::numeric_limits +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::numeric_limits +#include // std::span +#include // std::string_view -namespace sourcemeta::core { - -auto sha256_digest(const std::string_view input) - -> std::array { - CC_SHA256_CTX context; - CC_SHA256_Init(&context); +namespace { +auto sha256_update(CC_SHA256_CTX &context, const std::string_view input) + -> void { // The platform update interface takes a 32-bit length, so larger // inputs must be fed in chunks const auto *remaining_data{input.data()}; @@ -27,10 +26,29 @@ auto sha256_digest(const std::string_view input) remaining_data += chunk_size; remaining_size -= chunk_size; } +} + +} // namespace + +namespace sourcemeta::core { + +auto sha256_digest(const std::span input) + -> std::array { + CC_SHA256_CTX context; + CC_SHA256_Init(&context); + + for (const auto part : input) { + sha256_update(context, part); + } std::array digest{}; CC_SHA256_Final(digest.data(), &context); return digest; } +auto sha256_digest(const std::string_view input) + -> std::array { + return sha256_digest(std::span{&input, 1}); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sha256_openssl.cc b/vendor/core/src/core/crypto/crypto_sha256_openssl.cc index d4d655a2..7750db16 100644 --- a/vendor/core/src/core/crypto/crypto_sha256_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_sha256_openssl.cc @@ -2,25 +2,33 @@ #include // EVP_* -#include // std::array -#include // std::uint8_t -#include // std::runtime_error +#include // std::array +#include // std::uint8_t +#include // std::span +#include // std::runtime_error +#include // std::string_view namespace sourcemeta::core { -auto sha256_digest(const std::string_view input) +auto sha256_digest(const std::span input) -> std::array { auto *context = EVP_MD_CTX_new(); if (context == nullptr) { throw std::runtime_error("Could not allocate OpenSSL digest context"); } - if (EVP_DigestInit_ex(context, EVP_sha256(), nullptr) != 1 || - EVP_DigestUpdate(context, input.data(), input.size()) != 1) { + if (EVP_DigestInit_ex(context, EVP_sha256(), nullptr) != 1) { EVP_MD_CTX_free(context); throw std::runtime_error("Could not compute SHA-256 digest"); } + for (const auto part : input) { + if (EVP_DigestUpdate(context, part.data(), part.size()) != 1) { + EVP_MD_CTX_free(context); + throw std::runtime_error("Could not compute SHA-256 digest"); + } + } + std::array digest{}; unsigned int length = 0; if (EVP_DigestFinal_ex(context, digest.data(), &length) != 1) { @@ -32,4 +40,9 @@ auto sha256_digest(const std::string_view input) return digest; } +auto sha256_digest(const std::string_view input) + -> std::array { + return sha256_digest(std::span{&input, 1}); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sha256_other.cc b/vendor/core/src/core/crypto/crypto_sha256_other.cc index 63f26139..e987f8e6 100644 --- a/vendor/core/src/core/crypto/crypto_sha256_other.cc +++ b/vendor/core/src/core/crypto/crypto_sha256_other.cc @@ -1,185 +1,27 @@ #include -#include // std::array -#include // std::size_t -#include // std::uint8_t, std::uint32_t, std::uint64_t -#include // std::memcpy +#include "crypto_sha256_other.h" -namespace { +#include // std::array +#include // std::uint8_t +#include // std::span +#include // std::string_view -inline constexpr auto rotate_right(std::uint32_t value, - std::uint64_t count) noexcept - -> std::uint32_t { - return (value >> count) | (value << (32u - count)); -} - -// FIPS 180-4 Section 4.1.2 logical functions -inline constexpr auto big_sigma_0(std::uint32_t value) noexcept - -> std::uint32_t { - return rotate_right(value, 2u) ^ rotate_right(value, 13u) ^ - rotate_right(value, 22u); -} - -inline constexpr auto big_sigma_1(std::uint32_t value) noexcept - -> std::uint32_t { - return rotate_right(value, 6u) ^ rotate_right(value, 11u) ^ - rotate_right(value, 25u); -} - -inline constexpr auto small_sigma_0(std::uint32_t value) noexcept - -> std::uint32_t { - return rotate_right(value, 7u) ^ rotate_right(value, 18u) ^ (value >> 3u); -} - -inline constexpr auto small_sigma_1(std::uint32_t value) noexcept - -> std::uint32_t { - return rotate_right(value, 17u) ^ rotate_right(value, 19u) ^ (value >> 10u); -} - -// Equivalent to (x & y) ^ (~x & z) but avoids a bitwise NOT -inline constexpr auto choice(std::uint32_t x, std::uint32_t y, - std::uint32_t z) noexcept -> std::uint32_t { - return z ^ (x & (y ^ z)); -} - -inline constexpr auto majority(std::uint32_t x, std::uint32_t y, - std::uint32_t z) noexcept -> std::uint32_t { - return (x & y) ^ (x & z) ^ (y & z); -} - -inline auto sha256_process_block(const unsigned char *block, - std::array &state) noexcept - -> void { - // First 32 bits of the fractional parts of the cube roots - // of the first 64 prime numbers (FIPS 180-4 Section 4.2.2) - static constexpr std::array round_constants = { - {0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, - 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, 0xd807aa98U, 0x12835b01U, - 0x243185beU, 0x550c7dc3U, 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, - 0xc19bf174U, 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, - 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, 0x983e5152U, - 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, - 0x06ca6351U, 0x14292967U, 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, - 0x53380d13U, 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, - 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, - 0xd6990624U, 0xf40e3585U, 0x106aa070U, 0x19a4c116U, 0x1e376c08U, - 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, - 0x682e6ff3U, 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, - 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U}}; - - // Decode 16 big-endian 32-bit words from the block - std::array schedule; - for (std::uint64_t word_index = 0; word_index < 16u; ++word_index) { - const std::uint64_t byte_index = word_index * 4u; - schedule[word_index] = - (static_cast(block[byte_index]) << 24u) | - (static_cast(block[byte_index + 1u]) << 16u) | - (static_cast(block[byte_index + 2u]) << 8u) | - static_cast(block[byte_index + 3u]); - } - - // Extend the message schedule (FIPS 180-4 Section 6.2.2 step 1) - for (std::uint64_t index = 16u; index < 64u; ++index) { - schedule[index] = - small_sigma_1(schedule[index - 2u]) + schedule[index - 7u] + - small_sigma_0(schedule[index - 15u]) + schedule[index - 16u]; - } - - auto working = state; - - // Compression function (FIPS 180-4 Section 6.2.2 step 3) - for (std::uint64_t round_index = 0u; round_index < 64u; ++round_index) { - const auto temporary_1 = working[7] + big_sigma_1(working[4]) + - choice(working[4], working[5], working[6]) + - round_constants[round_index] + - schedule[round_index]; - const auto temporary_2 = - big_sigma_0(working[0]) + majority(working[0], working[1], working[2]); +namespace sourcemeta::core { - working[7] = working[6]; - working[6] = working[5]; - working[5] = working[4]; - working[4] = working[3] + temporary_1; - working[3] = working[2]; - working[2] = working[1]; - working[1] = working[0]; - working[0] = temporary_1 + temporary_2; +auto sha256_digest(const std::span input) + -> std::array { + Sha256Context context; + for (const auto part : input) { + sha256_update(context, part); } - for (std::uint64_t index = 0u; index < 8u; ++index) { - state[index] += working[index]; - } + return sha256_finalize(context); } -} // namespace - -namespace sourcemeta::core { - auto sha256_digest(const std::string_view input) -> std::array { - // Initial hash values: first 32 bits of the fractional parts of the - // square roots of the first 8 primes (FIPS 180-4 Section 5.3.3) - std::array state{}; - state[0] = 0x6a09e667U; - state[1] = 0xbb67ae85U; - state[2] = 0x3c6ef372U; - state[3] = 0xa54ff53aU; - state[4] = 0x510e527fU; - state[5] = 0x9b05688cU; - state[6] = 0x1f83d9abU; - state[7] = 0x5be0cd19U; - - const auto *const input_bytes = - reinterpret_cast(input.data()); - const std::size_t input_length = input.size(); - - // Process all full 64-byte blocks directly from the input (streaming) - std::size_t processed_bytes = 0u; - while (input_length - processed_bytes >= 64u) { - sha256_process_block(input_bytes + processed_bytes, state); - processed_bytes += 64u; - } - - // Prepare the final block(s) (one or two 64-byte blocks) - std::array final_block{}; - const std::size_t remaining_bytes = input_length - processed_bytes; - if (remaining_bytes > 0u) { - std::memcpy(final_block.data(), input_bytes + processed_bytes, - remaining_bytes); - } - - // Append the 0x80 byte after the message data - final_block[remaining_bytes] = 0x80u; - - // Append length in bits as big-endian 64-bit at the end of the padding - const std::uint64_t message_length_bits = - static_cast(input_length) * 8ull; - - if (remaining_bytes < 56u) { - for (std::uint64_t index = 0u; index < 8u; ++index) { - final_block[56u + index] = static_cast( - (message_length_bits >> (8u * (7u - index))) & 0xffu); - } - sha256_process_block(final_block.data(), state); - } else { - for (std::uint64_t index = 0u; index < 8u; ++index) { - final_block[64u + 56u + index] = static_cast( - (message_length_bits >> (8u * (7u - index))) & 0xffu); - } - - sha256_process_block(final_block.data(), state); - sha256_process_block(final_block.data() + 64u, state); - } - - std::array digest{}; - for (std::size_t word_index = 0u; word_index < 8u; ++word_index) { - for (std::size_t byte_index = 0u; byte_index < 4u; ++byte_index) { - digest[(word_index * 4u) + byte_index] = static_cast( - (state[word_index] >> (8u * (3u - byte_index))) & 0xffu); - } - } - - return digest; + return sha256_digest(std::span{&input, 1}); } } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sha256_other.h b/vendor/core/src/core/crypto/crypto_sha256_other.h new file mode 100644 index 00000000..1238baa5 --- /dev/null +++ b/vendor/core/src/core/crypto/crypto_sha256_other.h @@ -0,0 +1,221 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_SHA256_OTHER_H_ +#define SOURCEMETA_CORE_CRYPTO_SHA256_OTHER_H_ + +// Shared FIPS 180-4 streaming core for SHA-256, used only by the fallback +// implementations + +#include // std::array +#include // std::size_t +#include // std::uint8_t, std::uint32_t, std::uint64_t +#include // std::memcpy +#include // std::string_view + +namespace sourcemeta::core { + +// The count must be between 1 and 31, as the complementary shift is +// undefined otherwise +inline constexpr auto sha256_rotate_right(std::uint32_t value, + std::uint64_t count) noexcept + -> std::uint32_t { + return (value >> count) | (value << (32u - count)); +} + +// FIPS 180-4 Section 4.1.2 logical functions +inline constexpr auto sha256_big_sigma_0(std::uint32_t value) noexcept + -> std::uint32_t { + return sha256_rotate_right(value, 2u) ^ sha256_rotate_right(value, 13u) ^ + sha256_rotate_right(value, 22u); +} + +inline constexpr auto sha256_big_sigma_1(std::uint32_t value) noexcept + -> std::uint32_t { + return sha256_rotate_right(value, 6u) ^ sha256_rotate_right(value, 11u) ^ + sha256_rotate_right(value, 25u); +} + +inline constexpr auto sha256_small_sigma_0(std::uint32_t value) noexcept + -> std::uint32_t { + return sha256_rotate_right(value, 7u) ^ sha256_rotate_right(value, 18u) ^ + (value >> 3u); +} + +inline constexpr auto sha256_small_sigma_1(std::uint32_t value) noexcept + -> std::uint32_t { + return sha256_rotate_right(value, 17u) ^ sha256_rotate_right(value, 19u) ^ + (value >> 10u); +} + +// Equivalent to (x & y) ^ (~x & z) but avoids a bitwise NOT +inline constexpr auto sha256_choice(std::uint32_t x, std::uint32_t y, + std::uint32_t z) noexcept -> std::uint32_t { + return z ^ (x & (y ^ z)); +} + +inline constexpr auto sha256_majority(std::uint32_t x, std::uint32_t y, + std::uint32_t z) noexcept + -> std::uint32_t { + return (x & y) ^ (x & z) ^ (y & z); +} + +inline auto sha256_process_block(const std::uint8_t *block, + std::array &state) noexcept + -> void { + // First 32 bits of the fractional parts of the cube roots + // of the first 64 prime numbers (FIPS 180-4 Section 4.2.2) + static constexpr std::array round_constants = { + {0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, + 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, 0xd807aa98U, 0x12835b01U, + 0x243185beU, 0x550c7dc3U, 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, + 0xc19bf174U, 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, + 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, 0x983e5152U, + 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, + 0x06ca6351U, 0x14292967U, 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, + 0x53380d13U, 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, + 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, + 0xd6990624U, 0xf40e3585U, 0x106aa070U, 0x19a4c116U, 0x1e376c08U, + 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, + 0x682e6ff3U, 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, + 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U}}; + + // Decode 16 big-endian 32-bit words from the block + std::array schedule; + for (std::uint64_t word_index = 0; word_index < 16u; ++word_index) { + const std::uint64_t byte_index = word_index * 4u; + schedule[word_index] = + (static_cast(block[byte_index]) << 24u) | + (static_cast(block[byte_index + 1u]) << 16u) | + (static_cast(block[byte_index + 2u]) << 8u) | + static_cast(block[byte_index + 3u]); + } + + // Extend the message schedule (FIPS 180-4 Section 6.2.2 step 1) + for (std::uint64_t index = 16u; index < 64u; ++index) { + schedule[index] = + sha256_small_sigma_1(schedule[index - 2u]) + schedule[index - 7u] + + sha256_small_sigma_0(schedule[index - 15u]) + schedule[index - 16u]; + } + + auto working = state; + + // Compression function (FIPS 180-4 Section 6.2.2 step 3) + for (std::uint64_t round_index = 0u; round_index < 64u; ++round_index) { + const auto temporary_1 = working[7] + sha256_big_sigma_1(working[4]) + + sha256_choice(working[4], working[5], working[6]) + + round_constants[round_index] + + schedule[round_index]; + const auto temporary_2 = + sha256_big_sigma_0(working[0]) + + sha256_majority(working[0], working[1], working[2]); + + working[7] = working[6]; + working[6] = working[5]; + working[5] = working[4]; + working[4] = working[3] + temporary_1; + working[3] = working[2]; + working[2] = working[1]; + working[1] = working[0]; + working[0] = temporary_1 + temporary_2; + } + + for (std::uint64_t index = 0u; index < 8u; ++index) { + state[index] += working[index]; + } +} + +struct Sha256Context { + // Initial hash values: first 32 bits of the fractional parts of the + // square roots of the first 8 primes (FIPS 180-4 Section 5.3.3) + std::array state{{0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, + 0xa54ff53aU, 0x510e527fU, 0x9b05688cU, + 0x1f83d9abU, 0x5be0cd19U}}; + std::array block{}; + std::size_t block_size{0u}; + std::uint64_t message_size{0u}; +}; + +inline auto sha256_update(Sha256Context &context, + const std::string_view input) noexcept -> void { + if (input.empty()) { + return; + } + + const auto *input_bytes = + reinterpret_cast(input.data()); + std::size_t remaining_bytes = input.size(); + context.message_size += input.size(); + + // Top up a partially filled block first, processing it once full + if (context.block_size > 0u) { + const std::size_t capacity = context.block.size() - context.block_size; + const std::size_t taken_bytes = + remaining_bytes < capacity ? remaining_bytes : capacity; + std::memcpy(context.block.data() + context.block_size, input_bytes, + taken_bytes); + context.block_size += taken_bytes; + input_bytes += taken_bytes; + remaining_bytes -= taken_bytes; + if (context.block_size == context.block.size()) { + sha256_process_block(context.block.data(), context.state); + context.block_size = 0u; + } + } + + // Process all full 64-byte blocks directly from the input (streaming) + while (remaining_bytes >= 64u) { + sha256_process_block(input_bytes, context.state); + input_bytes += 64u; + remaining_bytes -= 64u; + } + + // Buffer whatever is left for a later update or the final padding + if (remaining_bytes > 0u) { + std::memcpy(context.block.data(), input_bytes, remaining_bytes); + context.block_size = remaining_bytes; + } +} + +inline auto sha256_finalize(Sha256Context &context) noexcept + -> std::array { + // Prepare the final block(s) (one or two 64-byte blocks) + std::array final_block{}; + const std::size_t remaining_bytes = context.block_size; + if (remaining_bytes > 0u) { + std::memcpy(final_block.data(), context.block.data(), remaining_bytes); + } + + // Append the 0x80 byte after the message data + final_block[remaining_bytes] = 0x80u; + + // Append length in bits as big-endian 64-bit at the end of the padding + const std::uint64_t message_length_bits = context.message_size * 8ull; + + if (remaining_bytes < 56u) { + for (std::uint64_t index = 0u; index < 8u; ++index) { + final_block[56u + index] = static_cast( + (message_length_bits >> (8u * (7u - index))) & 0xffu); + } + sha256_process_block(final_block.data(), context.state); + } else { + for (std::uint64_t index = 0u; index < 8u; ++index) { + final_block[64u + 56u + index] = static_cast( + (message_length_bits >> (8u * (7u - index))) & 0xffu); + } + + sha256_process_block(final_block.data(), context.state); + sha256_process_block(final_block.data() + 64u, context.state); + } + + std::array digest{}; + for (std::size_t word_index = 0u; word_index < 8u; ++word_index) { + for (std::size_t byte_index = 0u; byte_index < 4u; ++byte_index) { + digest[(word_index * 4u) + byte_index] = static_cast( + (context.state[word_index] >> (8u * (3u - byte_index))) & 0xffu); + } + } + + return digest; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/crypto_sha256_windows.cc b/vendor/core/src/core/crypto/crypto_sha256_windows.cc index e47e9cfd..a7f36266 100644 --- a/vendor/core/src/core/crypto/crypto_sha256_windows.cc +++ b/vendor/core/src/core/crypto/crypto_sha256_windows.cc @@ -6,15 +6,17 @@ #include // BCrypt*, BCRYPT_* -#include // std::array -#include // std::size_t -#include // std::uint8_t -#include // std::numeric_limits -#include // std::runtime_error +#include "crypto_windows.h" + +#include // std::array +#include // std::uint8_t +#include // std::span +#include // std::runtime_error +#include // std::string_view namespace sourcemeta::core { -auto sha256_digest(const std::string_view input) +auto sha256_digest(const std::span input) -> std::array { BCRYPT_ALG_HANDLE algorithm{nullptr}; if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( @@ -29,21 +31,9 @@ auto sha256_digest(const std::string_view input) throw std::runtime_error("Could not create the CNG SHA-256 hash"); } - // The data interface is not const-qualified but never writes through - // the pointer, and it takes a 32-bit length, so larger inputs must be - // fed in chunks - auto *remaining_data{ - reinterpret_cast(const_cast(input.data()))}; - auto remaining_size{input.size()}; - constexpr std::size_t maximum_chunk{std::numeric_limits::max()}; auto success{true}; - while (remaining_size > 0 && success) { - const auto chunk_size{remaining_size > maximum_chunk ? maximum_chunk - : remaining_size}; - success = BCRYPT_SUCCESS(BCryptHashData(hash, remaining_data, - static_cast(chunk_size), 0)); - remaining_data += chunk_size; - remaining_size -= chunk_size; + for (const auto part : input) { + success = success && hash_data_chunked(hash, part); } std::array digest{}; @@ -61,4 +51,9 @@ auto sha256_digest(const std::string_view input) return digest; } +auto sha256_digest(const std::string_view input) + -> std::array { + return sha256_digest(std::span{&input, 1}); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/crypto/crypto_sign_apple.cc b/vendor/core/src/core/crypto/crypto_sign_apple.cc index 2a9af5ce..09de7538 100644 --- a/vendor/core/src/core/crypto/crypto_sign_apple.cc +++ b/vendor/core/src/core/crypto/crypto_sign_apple.cc @@ -19,22 +19,6 @@ #include // std::string_view #include // std::move, std::unreachable -namespace sourcemeta::core { - -// The parsed key keeps the platform key object alive for reuse. The Edwards -// curves have no Security framework primitive, so they keep the raw seed and -// sign through CryptoKit or the reference implementation -struct PrivateKey::Internal { - PrivateKey::Type kind; - SecKeyRef key; - std::size_t field_bytes; - std::string edwards_seed; - EdwardsCurve edwards_curve; - bool rsa_pss_restricted{false}; -}; - -} // namespace sourcemeta::core - namespace { auto to_sec_key_pkcs1_v15_algorithm( @@ -373,6 +357,41 @@ auto make_ec_private_key(const EllipticCurve curve, .edwards_curve = {}}}; } +auto generate_ec_private_key(const EllipticCurve curve) + -> std::optional { + const int bits{static_cast(curve_bit_length(curve))}; + auto size{CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &bits)}; + if (size == nullptr) { + return std::nullopt; + } + + std::array attribute_keys{ + {kSecAttrKeyType, kSecAttrKeySizeInBits}}; + std::array attribute_values{ + {kSecAttrKeyTypeECSECPrimeRandom, size}}; + auto attributes{CFDictionaryCreate( + kCFAllocatorDefault, attribute_keys.data(), attribute_values.data(), + static_cast(attribute_keys.size()), + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks)}; + CFRelease(size); + if (attributes == nullptr) { + return std::nullopt; + } + + auto *key{SecKeyCreateRandomKey(attributes, nullptr)}; + CFRelease(attributes); + if (key == nullptr) { + return std::nullopt; + } + + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .key = key, + .field_bytes = curve_field_bytes(curve), + .edwards_seed = {}, + .edwards_curve = {}}}; +} + auto make_edwards_private_key(const EdwardsCurve curve, const std::string_view seed) -> std::optional { diff --git a/vendor/core/src/core/crypto/crypto_sign_openssl.cc b/vendor/core/src/core/crypto/crypto_sign_openssl.cc index 26d70ca9..1912651b 100644 --- a/vendor/core/src/core/crypto/crypto_sign_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_sign_openssl.cc @@ -21,21 +21,6 @@ #include // std::string_view #include // std::move, std::unreachable -namespace sourcemeta::core { - -// The parsed key keeps the native handle alive so that many signatures are -// produced without rebuilding it -struct PrivateKey::Internal { - PrivateKey::Type kind; - EVP_PKEY *key; - // The field width for the elliptic curve raw signature encoding - std::size_t field_bytes; - // Set for an id-RSASSA-PSS key, which is refused for PKCS1v15 signing - bool rsa_pss_restricted{false}; -}; - -} // namespace sourcemeta::core - namespace { auto to_message_digest( @@ -359,6 +344,19 @@ auto make_ec_private_key(const EllipticCurve curve, .field_bytes = curve_field_bytes(curve)}}; } +auto generate_ec_private_key(const EllipticCurve curve) + -> std::optional { + auto *key{EVP_EC_gen(to_group_name(curve))}; + if (key == nullptr) { + return std::nullopt; + } + + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .key = key, + .field_bytes = curve_field_bytes(curve)}}; +} + auto make_edwards_private_key(const EdwardsCurve curve, const std::string_view seed) -> std::optional { diff --git a/vendor/core/src/core/crypto/crypto_sign_other.cc b/vendor/core/src/core/crypto/crypto_sign_other.cc index 734e45e6..19d6cf8c 100644 --- a/vendor/core/src/core/crypto/crypto_sign_other.cc +++ b/vendor/core/src/core/crypto/crypto_sign_other.cc @@ -6,13 +6,17 @@ #include "crypto_ecc.h" #include "crypto_eddsa.h" #include "crypto_helpers.h" +#include "crypto_other.h" #include "crypto_pkcs8.h" +#include "crypto_random.h" #include // std::array #include // assert #include // std::size_t #include // std::uint8_t, std::uint32_t +#include // std::exception #include // std::optional, std::nullopt +#include // std::span #include // std::string #include // std::string_view #include // std::move, std::unreachable @@ -80,10 +84,7 @@ auto mask_generation(const SignatureHashFunction hash, std::uint32_t counter{0}; while (result.size() < length) { std::string block{seed}; - block.push_back(static_cast((counter >> 24u) & 0xffu)); - block.push_back(static_cast((counter >> 16u) & 0xffu)); - block.push_back(static_cast((counter >> 8u) & 0xffu)); - block.push_back(static_cast(counter & 0xffu)); + append_big_endian_uint32(block, counter); result.append(digest_message(hash, block)); counter += 1; } @@ -381,26 +382,6 @@ auto ec_public_from_scalar(const EllipticCurve curve, } // namespace -// The reference backend parses the key material into big integers inside each -// signature, so the parsed key simply holds the raw material -struct PrivateKey::Internal { - PrivateKey::Type kind; - std::string modulus; - std::string public_exponent; - std::string private_exponent; - std::string scalar; - EllipticCurve elliptic_curve; - std::string edwards_seed; - EdwardsCurve edwards_curve; - bool rsa_pss_restricted{false}; - // The public coordinates, kept so the public key can be recovered without - // recomputing the point from the scalar. A key parsed from a PEM document, - // which carries only the scalar on this backend, recomputes them at parse - // time, so they are populated for every elliptic curve private key - std::string coordinate_x; - std::string coordinate_y; -}; - PrivateKey::PrivateKey(Internal *internal) noexcept : internal_{internal} {} PrivateKey::~PrivateKey() { @@ -625,6 +606,48 @@ auto make_ec_private_key(const EllipticCurve curve, .coordinate_y = expected.second}}; } +auto generate_ec_private_key(const EllipticCurve curve) + -> std::optional { + const auto parameters{to_curve_parameters(curve)}; + const auto order_bits{bignum_bit_length(parameters.order)}; + const auto width{curve_field_bytes(curve)}; + const auto excess_bits{(width * 8u) - order_bits}; + + // Rejection sampling into [1, order). Masking the surplus high bits keeps the + // retry rate low even for P-521, whose order fills only 521 of its 528 bits + std::string scalar(width, '\x00'); + const SecureStringScope scalar_scope{scalar}; + Bignum scalar_number; + const SecureBignumScope scalar_number_scope{scalar_number}; + do { + try { + fill_random_bytes(std::span{ + reinterpret_cast(scalar.data()), width}); + } catch (const std::exception &) { + return std::nullopt; + } + + scalar[0] = + static_cast(static_cast(scalar[0]) & + static_cast(0xffu >> excess_bits)); + scalar_number = bignum_from_bytes(scalar); + } while (bignum_is_zero(scalar_number) || + bignum_compare(scalar_number, parameters.order) >= 0); + + const auto point{ec_public_from_scalar(curve, scalar)}; + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .modulus = {}, + .public_exponent = {}, + .private_exponent = {}, + .scalar = scalar, + .elliptic_curve = curve, + .edwards_seed = {}, + .edwards_curve = {}, + .coordinate_x = point.first, + .coordinate_y = point.second}}; +} + auto make_edwards_private_key(const EdwardsCurve curve, const std::string_view seed) -> std::optional { diff --git a/vendor/core/src/core/crypto/crypto_sign_windows.cc b/vendor/core/src/core/crypto/crypto_sign_windows.cc index 72205c0d..d1b6896e 100644 --- a/vendor/core/src/core/crypto/crypto_sign_windows.cc +++ b/vendor/core/src/core/crypto/crypto_sign_windows.cc @@ -24,23 +24,6 @@ #include // std::string_view #include // std::move, std::unreachable -namespace sourcemeta::core { - -// The parsed key keeps both the algorithm provider and the imported key handle -// alive for reuse. The Edwards curves have no CNG primitive, so they keep the -// raw seed and sign through the reference implementation -struct PrivateKey::Internal { - PrivateKey::Type kind; - BCRYPT_ALG_HANDLE algorithm; - BCRYPT_KEY_HANDLE key; - std::size_t field_bytes; - std::string edwards_seed; - EdwardsCurve edwards_curve; - bool rsa_pss_restricted{false}; -}; - -} // namespace sourcemeta::core - namespace { auto to_cng_algorithm( @@ -449,6 +432,35 @@ auto make_ec_private_key(const EllipticCurve curve, .edwards_curve = {}}}; } +auto generate_ec_private_key(const EllipticCurve curve) + -> std::optional { + BCRYPT_ALG_HANDLE algorithm{nullptr}; + if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( + &algorithm, to_ecdsa_algorithm(curve), nullptr, 0))) { + return std::nullopt; + } + + BCRYPT_KEY_HANDLE key{nullptr}; + if (!BCRYPT_SUCCESS(BCryptGenerateKeyPair( + algorithm, &key, static_cast(curve_bit_length(curve)), 0)) || + !BCRYPT_SUCCESS(BCryptFinalizeKeyPair(key, 0))) { + if (key != nullptr) { + BCryptDestroyKey(key); + } + + BCryptCloseAlgorithmProvider(algorithm, 0); + return std::nullopt; + } + + return PrivateKey{ + new PrivateKey::Internal{.kind = PrivateKey::Type::EllipticCurve, + .algorithm = algorithm, + .key = key, + .field_bytes = curve_field_bytes(curve), + .edwards_seed = {}, + .edwards_curve = {}}}; +} + auto make_edwards_private_key(const EdwardsCurve curve, const std::string_view seed) -> std::optional { diff --git a/vendor/core/src/core/crypto/crypto_verify_apple.cc b/vendor/core/src/core/crypto/crypto_verify_apple.cc index 4f8b6e15..d70cde04 100644 --- a/vendor/core/src/core/crypto/crypto_verify_apple.cc +++ b/vendor/core/src/core/crypto/crypto_verify_apple.cc @@ -18,22 +18,6 @@ #include // std::string_view #include // std::move, std::unreachable -namespace sourcemeta::core { - -// The parsed key keeps the platform key object alive for reuse. The Edwards -// curves have no Security framework primitive, so they keep the raw encoded -// point and verify through CryptoKit or the reference implementation -struct PublicKey::Internal { - PublicKey::Type kind; - SecKeyRef key; - std::string modulus; - std::size_t field_bytes; - std::string edwards_point; - EdwardsCurve edwards_curve; -}; - -} // namespace sourcemeta::core - namespace { auto to_sec_key_pkcs1_v15_algorithm( diff --git a/vendor/core/src/core/crypto/crypto_verify_openssl.cc b/vendor/core/src/core/crypto/crypto_verify_openssl.cc index 8644c98d..a564bf6b 100644 --- a/vendor/core/src/core/crypto/crypto_verify_openssl.cc +++ b/vendor/core/src/core/crypto/crypto_verify_openssl.cc @@ -18,23 +18,6 @@ #include // std::string_view #include // std::move, std::unreachable -namespace sourcemeta::core { - -// The parsed key keeps the native handle alive so that many signatures verify -// without rebuilding it -struct PublicKey::Internal { - PublicKey::Type kind; - EVP_PKEY *key; - // The stripped modulus, kept for the RSA signature range check - std::string modulus; - // The field width for the elliptic curve signature size check - std::size_t field_bytes; - // The expected signature length for the Edwards curve - std::size_t signature_bytes; -}; - -} // namespace sourcemeta::core - namespace { auto to_message_digest( diff --git a/vendor/core/src/core/crypto/crypto_verify_other.cc b/vendor/core/src/core/crypto/crypto_verify_other.cc index a0651343..df77ff86 100644 --- a/vendor/core/src/core/crypto/crypto_verify_other.cc +++ b/vendor/core/src/core/crypto/crypto_verify_other.cc @@ -5,6 +5,7 @@ #include "crypto_ecc.h" #include "crypto_eddsa.h" #include "crypto_helpers.h" +#include "crypto_other.h" #include // std::array #include // assert @@ -87,10 +88,7 @@ auto mask_generation(const SignatureHashFunction hash, std::uint32_t counter{0}; while (result.size() < length) { std::string block{seed}; - block.push_back(static_cast((counter >> 24u) & 0xffu)); - block.push_back(static_cast((counter >> 16u) & 0xffu)); - block.push_back(static_cast((counter >> 8u) & 0xffu)); - block.push_back(static_cast(counter & 0xffu)); + append_big_endian_uint32(block, counter); result.append(digest_message(hash, block)); counter += 1; } @@ -344,19 +342,6 @@ auto verify_ecdsa(const EllipticCurve curve, const SignatureHashFunction hash, } // namespace -// The reference backend parses the key material into big integers inside each -// verification, which is cheap next to the modular arithmetic, so the parsed -// key simply holds the raw material -struct PublicKey::Internal { - PublicKey::Type kind; - std::string modulus; - std::string exponent; - std::string coordinate_x; - std::string coordinate_y; - EllipticCurve elliptic_curve; - EdwardsCurve edwards_curve; -}; - PublicKey::PublicKey(Internal *internal) noexcept : internal_{internal} {} PublicKey::~PublicKey() { delete internal_; } diff --git a/vendor/core/src/core/crypto/crypto_verify_windows.cc b/vendor/core/src/core/crypto/crypto_verify_windows.cc index 9b6deaff..7490bf16 100644 --- a/vendor/core/src/core/crypto/crypto_verify_windows.cc +++ b/vendor/core/src/core/crypto/crypto_verify_windows.cc @@ -22,23 +22,6 @@ #include // std::string_view #include // std::move, std::unreachable -namespace sourcemeta::core { - -// The parsed key keeps both the algorithm provider and the imported key handle -// alive for reuse. The Edwards curves have no CNG primitive, so they keep the -// raw encoded point and verify through the reference implementation -struct PublicKey::Internal { - PublicKey::Type kind; - BCRYPT_ALG_HANDLE algorithm; - BCRYPT_KEY_HANDLE key; - std::size_t field_bytes; - std::string modulus; - std::string edwards_point; - EdwardsCurve edwards_curve; -}; - -} // namespace sourcemeta::core - namespace { auto to_cng_algorithm( diff --git a/vendor/core/src/core/crypto/crypto_windows.h b/vendor/core/src/core/crypto/crypto_windows.h index 4afae01a..62b100c7 100644 --- a/vendor/core/src/core/crypto/crypto_windows.h +++ b/vendor/core/src/core/crypto/crypto_windows.h @@ -1,21 +1,77 @@ #ifndef SOURCEMETA_CORE_CRYPTO_WINDOWS_H_ #define SOURCEMETA_CORE_CRYPTO_WINDOWS_H_ -// CNG key export helper shared by the signing and verification backends, so the -// native blob export stays single-sourced +// The CNG key internals and the helpers shared across the CNG backends, so the +// parsed key layout, the native blob export, and the chunked hash ingestion +// stay single-sourced #ifndef NOMINMAX #define NOMINMAX #endif #include // ULONG, LPCWSTR -#include // BCryptExportKey, BCRYPT_KEY_HANDLE, BCRYPT_SUCCESS +// BCryptExportKey, BCryptHashData, BCRYPT_KEY_HANDLE, BCRYPT_HASH_HANDLE, +// BCRYPT_SUCCESS +#include -#include // std::optional, std::nullopt -#include // std::string +#include +#include + +#include // std::size_t +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view namespace sourcemeta::core { +// The parsed key keeps both the algorithm provider and the imported key handle +// alive for reuse. The Edwards curves have no CNG primitive, so they keep the +// raw seed or point and operate through the reference implementation +struct PublicKey::Internal { + PublicKey::Type kind; + BCRYPT_ALG_HANDLE algorithm; + BCRYPT_KEY_HANDLE key; + std::size_t field_bytes; + std::string modulus; + std::string edwards_point; + EdwardsCurve edwards_curve; +}; + +struct PrivateKey::Internal { + PrivateKey::Type kind; + BCRYPT_ALG_HANDLE algorithm; + BCRYPT_KEY_HANDLE key; + std::size_t field_bytes; + std::string edwards_seed; + EdwardsCurve edwards_curve; + bool rsa_pss_restricted{false}; +}; + +// Feed a buffer into a hash object. The data interface is not const-qualified +// but never writes through the pointer, and it takes a 32-bit length, so +// larger inputs must be fed in chunks +inline auto hash_data_chunked(BCRYPT_HASH_HANDLE hash, + const std::string_view input) -> bool { + auto *remaining_data{ + reinterpret_cast(const_cast(input.data()))}; + auto remaining_size{input.size()}; + constexpr std::size_t maximum_chunk{std::numeric_limits::max()}; + while (remaining_size > 0) { + const auto chunk_size{remaining_size > maximum_chunk ? maximum_chunk + : remaining_size}; + if (!BCRYPT_SUCCESS(BCryptHashData(hash, remaining_data, + static_cast(chunk_size), 0))) { + return false; + } + + remaining_data += chunk_size; + remaining_size -= chunk_size; + } + + return true; +} + // Export a native key blob, sizing the buffer in a first call and filling it in // a second inline auto export_key_blob(BCRYPT_KEY_HANDLE key, LPCWSTR blob_type) diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h index be947c01..932f9f17 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto.h @@ -16,13 +16,17 @@ /// #include /// ``` +#include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_cbc_hmac.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_cbc_hmac.h new file mode 100644 index 00000000..07d5f571 --- /dev/null +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_cbc_hmac.h @@ -0,0 +1,87 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_AES_CBC_HMAC_H_ +#define SOURCEMETA_CORE_CRYPTO_AES_CBC_HMAC_H_ + +#ifndef SOURCEMETA_CORE_CRYPTO_EXPORT +#include +#endif + +#include // assert +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup crypto +/// The output of an AES-CBC-HMAC encryption, a single owned buffer holding the +/// ciphertext followed by its authentication tag, exposed as views. +struct AESCBCHMACCiphertext { + /// The ciphertext followed by its authentication tag. + std::string data; + + /// The length in bytes of the trailing authentication tag. + std::string::size_type tag_length; + + /// The ciphertext, a whole number of 16-byte blocks. + [[nodiscard]] auto ciphertext() const -> std::string_view { + assert(this->data.size() >= this->tag_length); + return std::string_view{this->data}.substr(0, this->data.size() - + this->tag_length); + } + + /// The authentication tag. + [[nodiscard]] auto tag() const -> std::string_view { + assert(this->data.size() >= this->tag_length); + return std::string_view{this->data}.substr(this->data.size() - + this->tag_length); + } +}; + +/// @ingroup crypto +/// Encrypt a plaintext with AES-CBC-HMAC-SHA2 (RFC 7518 Section 5.2) under a +/// key whose size selects the variant, a 128-bit initialization vector, and +/// associated data that is authenticated but not encrypted. A 32, 48, or +/// 64-byte key selects A128CBC-HS256, A192CBC-HS384, or A256CBC-HS512 +/// respectively. Returns no value when the key is not one of the three valid +/// sizes, the initialization vector is not 128 bits, or an input is too large +/// to process. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto result{ +/// sourcemeta::core::aes_cbc_hmac_encrypt(key, iv, "", "hello")}; +/// assert(result.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT aes_cbc_hmac_encrypt( + const std::string_view key, const std::string_view iv, + const std::string_view associated_data, const std::string_view plaintext) + -> std::optional; + +/// @ingroup crypto +/// Decrypt a message produced by aes_cbc_hmac_encrypt under the same key, +/// initialization vector, and associated data, returning the original +/// plaintext. The authentication tag is verified in constant time before, and +/// independent of, the decryption, so a tampered message is rejected rather +/// than decrypted. Returns no value when the key or initialization vector is +/// not a valid length, the tag length does not match the key, an input is +/// malformed or too large to process, or the authentication tag does not +/// verify. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto plaintext{sourcemeta::core::aes_cbc_hmac_decrypt( +/// key, iv, "", result.value().ciphertext(), result.value().tag())}; +/// assert(plaintext.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT aes_cbc_hmac_decrypt( + const std::string_view key, const std::string_view iv, + const std::string_view associated_data, const std::string_view ciphertext, + const std::string_view tag) -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_gcm.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_gcm.h index d81865d7..2a5a0e13 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_gcm.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_gcm.h @@ -5,12 +5,73 @@ #include #endif +#include // assert #include // std::optional #include // std::string #include // std::string_view namespace sourcemeta::core { +/// @ingroup crypto +/// The output of an AES-GCM encryption, a single owned buffer holding the +/// ciphertext followed by its 16-byte authentication tag, exposed as views. +struct AESGCMCiphertext { + /// The ciphertext followed by its 16-byte authentication tag. + std::string data; + + /// The ciphertext, the same length as the plaintext. + [[nodiscard]] auto ciphertext() const -> std::string_view { + assert(this->data.size() >= 16); + return std::string_view{this->data}.substr(0, this->data.size() - 16); + } + + /// The 16-byte authentication tag. + [[nodiscard]] auto tag() const -> std::string_view { + assert(this->data.size() >= 16); + return std::string_view{this->data}.substr(this->data.size() - 16); + } +}; + +/// @ingroup crypto +/// Encrypt a plaintext with AES in Galois/Counter Mode (NIST SP 800-38D) under +/// a 128, 192, or 256-bit key, a 96-bit initialization vector, and associated +/// data that is authenticated but not encrypted. Returns no value when the key +/// is not one of the three valid sizes, the initialization vector is not 96 +/// bits, or an input is too large to process. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto result{sourcemeta::core::aes_gcm_encrypt(key, iv, "", "hello")}; +/// assert(result.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT aes_gcm_encrypt( + const std::string_view key, const std::string_view iv, + const std::string_view associated_data, const std::string_view plaintext) + -> std::optional; + +/// @ingroup crypto +/// Decrypt a message produced by aes_gcm_encrypt under the same key, +/// initialization vector, and associated data, returning the original +/// plaintext. Returns no value when the key or initialization vector is not a +/// valid length, the tag is not 16 bytes, an input is too large to process, or +/// the authentication tag does not verify, so a tampered message is rejected +/// rather than decrypted. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto plaintext{sourcemeta::core::aes_gcm_decrypt( +/// key, iv, "", result.value().ciphertext(), result.value().tag())}; +/// assert(plaintext.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT aes_gcm_decrypt( + const std::string_view key, const std::string_view iv, + const std::string_view associated_data, const std::string_view ciphertext, + const std::string_view tag) -> std::optional; + /// @ingroup crypto /// Seal a plaintext under a 256-bit key using AES-256 in Galois/Counter Mode /// (NIST SP 800-38D) with a fresh random nonce and no associated data. The diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_kw.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_kw.h new file mode 100644 index 00000000..9723490a --- /dev/null +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_aes_kw.h @@ -0,0 +1,55 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_AES_KW_H_ +#define SOURCEMETA_CORE_CRYPTO_AES_KW_H_ + +#ifndef SOURCEMETA_CORE_CRYPTO_EXPORT +#include +#endif + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup crypto +/// Wrap a key with AES Key Wrap (RFC 3394) under a key-encryption key whose +/// size selects AES-128, AES-192, or AES-256. The wrapped output is eight bytes +/// longer than the input and carries an integrity check. Returns no value when +/// the key-encryption key is not 128, 192, or 256 bits, the key being wrapped +/// is not a whole number of at least two 64-bit blocks, or an input is too +/// large to process. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto wrapped{sourcemeta::core::aes_key_wrap(key_encryption_key, key)}; +/// assert(wrapped.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT aes_key_wrap( + const std::string_view key_encryption_key, const std::string_view key) + -> std::optional; + +/// @ingroup crypto +/// Unwrap a key wrapped with aes_key_wrap under the same key-encryption key, +/// returning the original key. The integrity check is verified, so a tampered +/// or wrongly keyed input is rejected rather than returned. Returns no value +/// when the key-encryption key is not a valid length, the wrapped key is not a +/// whole number of at least three 64-bit blocks, an input is too large to +/// process, or the integrity check fails. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{ +/// sourcemeta::core::aes_key_unwrap(key_encryption_key, wrapped.value())}; +/// assert(key.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT aes_key_unwrap( + const std::string_view key_encryption_key, + const std::string_view wrapped_key) -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h index 2183182b..51e1a3e2 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_base64.h @@ -7,8 +7,10 @@ #include +#include // std::uint8_t #include // std::optional #include // std::ostream +#include // std::span #include // std::string #include // std::string_view @@ -120,6 +122,20 @@ base64url_encode(const std::string_view input, std::ostream &output) -> void; auto SOURCEMETA_CORE_CRYPTO_EXPORT base64url_encode(const std::string_view input) -> std::string; +/// @ingroup crypto +/// Encode raw bytes using unpadded Base64url (RFC 4648 Section 5), for a byte +/// buffer such as a hash digest rather than a character string. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto digest{sourcemeta::core::sha256_digest("fo")}; +/// assert(!sourcemeta::core::base64url_encode(digest).empty()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT +base64url_encode(std::span input) -> std::string; + /// @ingroup crypto /// Append the unpadded Base64url encoding (RFC 4648 Section 5) of a byte /// sequence to a wiping string, so that encoding a secret does not leave it in diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_ecdh.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_ecdh.h new file mode 100644 index 00000000..afef7eee --- /dev/null +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_ecdh.h @@ -0,0 +1,60 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_ECDH_H_ +#define SOURCEMETA_CORE_CRYPTO_ECDH_H_ + +#ifndef SOURCEMETA_CORE_CRYPTO_EXPORT +#include +#endif + +#include +#include + +#include // std::size_t +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup crypto +/// Compute the raw Elliptic Curve Diffie-Hellman shared secret from a private +/// key and a peer public key on the same curve, returning the agreed point's x +/// coordinate as fixed-length big-endian bytes. Returns no value when the keys +/// are not both elliptic curve keys on the same curve, or the peer point is not +/// on the curve. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto secret{sourcemeta::core::ecdh_derive(private_key, peer_key)}; +/// assert(secret.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT ecdh_derive(const PrivateKey &private_key, + const PublicKey &public_key) + -> std::optional; + +/// @ingroup crypto +/// Derive a key from a shared secret with the Concat KDF (RFC 7518 +/// Section 4.6), the single-step key derivation function over SHA-256, where +/// the algorithm identifier and the two party information strings are the +/// already decoded bytes that make up the derivation context. Returns no value +/// when the context or the requested key length exceeds the 32-bit fields the +/// construction encodes. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::kdf_concat(secret, "A128GCM", "Alice", +/// "Bob", 16)}; +/// assert(key.has_value()); +/// assert(key.value().size() == 16); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT kdf_concat( + const std::string_view shared_secret, const std::string_view algorithm_id, + const std::string_view party_u_info, const std::string_view party_v_info, + const std::size_t derived_key_bytes) -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_hmac_sha256.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_hmac_sha256.h index 21d72fb2..fc86f989 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_hmac_sha256.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_hmac_sha256.h @@ -8,6 +8,7 @@ #include // std::array #include // std::uint8_t #include // std::ostream +#include // std::span #include // std::string #include // std::string_view @@ -59,6 +60,25 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT hmac_sha256_digest(const std::string_view key, const std::string_view message) -> std::array; +/// @ingroup crypto +/// Authenticate a message given as a sequence of string parts under a key +/// using HMAC-SHA256, as if the parts were a single concatenated message, +/// returning the raw digest bytes. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array parts{{"foo ", "bar"}}; +/// const auto digest{sourcemeta::core::hmac_sha256_digest("key", parts)}; +/// assert(digest.size() == 32); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT hmac_sha256_digest( + const std::string_view key, std::span message) + -> std::array; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_rsa_oaep.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_rsa_oaep.h new file mode 100644 index 00000000..c56da687 --- /dev/null +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_rsa_oaep.h @@ -0,0 +1,66 @@ +#ifndef SOURCEMETA_CORE_CRYPTO_RSA_OAEP_H_ +#define SOURCEMETA_CORE_CRYPTO_RSA_OAEP_H_ + +#ifndef SOURCEMETA_CORE_CRYPTO_EXPORT +#include +#endif + +#include +#include + +#include // std::uint8_t +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup crypto +/// The hash function that RSA-OAEP uses for both the label digest and the mask +/// generation function. +enum class RSAOAEPHash : std::uint8_t { + /// The SHA-1 hash function, selecting RSA-OAEP. + SHA1, + /// The SHA-256 hash function, selecting RSA-OAEP-256. + SHA256 +}; + +/// @ingroup crypto +/// Encrypt a short key or message with RSA-OAEP (RFC 8017) under an RSA public +/// key, where SHA1 selects RSA-OAEP and SHA256 selects RSA-OAEP-256. Returns no +/// value when the key is not RSA, the plaintext is too long for the modulus, or +/// the operation fails. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto wrapped{sourcemeta::core::rsa_oaep_encrypt( +/// public_key, sourcemeta::core::RSAOAEPHash::SHA256, key)}; +/// assert(wrapped.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT rsa_oaep_encrypt( + const PublicKey &key, const RSAOAEPHash hash, + const std::string_view plaintext) -> std::optional; + +/// @ingroup crypto +/// Decrypt a message produced by rsa_oaep_encrypt under the matching RSA +/// private key and hash, returning the original plaintext. Returns no value +/// when the key is not RSA, the ciphertext is malformed, or the padding check +/// fails. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::rsa_oaep_decrypt( +/// private_key, sourcemeta::core::RSAOAEPHash::SHA256, wrapped.value())}; +/// assert(key.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT rsa_oaep_decrypt( + const PrivateKey &key, const RSAOAEPHash hash, + const std::string_view ciphertext) -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sha256.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sha256.h index 4a73f162..ff725fa6 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sha256.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sha256.h @@ -8,6 +8,7 @@ #include // std::array #include // std::uint8_t #include // std::ostream +#include // std::span #include // std::string #include // std::string_view @@ -54,6 +55,23 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT sha256(const std::string_view input) auto SOURCEMETA_CORE_CRYPTO_EXPORT sha256_digest(const std::string_view input) -> std::array; +/// @ingroup crypto +/// Hash a sequence of string parts using SHA-256 as if they were a single +/// concatenated input, returning the raw digest bytes. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array parts{{"foo ", "bar"}}; +/// const auto digest{sourcemeta::core::sha256_digest(parts)}; +/// assert(digest.size() == 32); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT sha256_digest( + std::span input) -> std::array; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h index ab278659..6f07e1d8 100644 --- a/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h +++ b/vendor/core/src/core/crypto/include/sourcemeta/core/crypto_sign.h @@ -85,6 +85,21 @@ auto SOURCEMETA_CORE_CRYPTO_EXPORT make_ec_private_key( const std::string_view coordinate_x, const std::string_view coordinate_y) -> std::optional; +/// @ingroup crypto +/// Generate a new random elliptic curve private key on the given curve, +/// returning no value when the platform cannot produce one. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto key{sourcemeta::core::generate_ec_private_key( +/// sourcemeta::core::EllipticCurve::P256)}; +/// assert(key.has_value()); +/// ``` +auto SOURCEMETA_CORE_CRYPTO_EXPORT +generate_ec_private_key(const EllipticCurve curve) -> std::optional; + /// @ingroup crypto /// Parse an Edwards-curve private key from its raw seed, returning no value /// when the seed is the wrong length for the curve. diff --git a/vendor/core/src/core/http/problem.cc b/vendor/core/src/core/http/problem.cc index 1aee8c9f..f77a76a9 100644 --- a/vendor/core/src/core/http/problem.cc +++ b/vendor/core/src/core/http/problem.cc @@ -7,11 +7,13 @@ namespace { using namespace std::string_view_literals; -const auto HTTP_HASH_TYPE{sourcemeta::core::JSON::Object::hash("type"sv)}; -const auto HTTP_HASH_TITLE{sourcemeta::core::JSON::Object::hash("title"sv)}; -const auto HTTP_HASH_STATUS{sourcemeta::core::JSON::Object::hash("status"sv)}; -const auto HTTP_HASH_DETAIL{sourcemeta::core::JSON::Object::hash("detail"sv)}; -const auto HTTP_HASH_INSTANCE{ +constexpr auto HTTP_HASH_TYPE{sourcemeta::core::JSON::Object::hash("type"sv)}; +constexpr auto HTTP_HASH_TITLE{sourcemeta::core::JSON::Object::hash("title"sv)}; +constexpr auto HTTP_HASH_STATUS{ + sourcemeta::core::JSON::Object::hash("status"sv)}; +constexpr auto HTTP_HASH_DETAIL{ + sourcemeta::core::JSON::Object::hash("detail"sv)}; +constexpr auto HTTP_HASH_INSTANCE{ sourcemeta::core::JSON::Object::hash("instance"sv)}; } // namespace diff --git a/vendor/core/src/core/ip/CMakeLists.txt b/vendor/core/src/core/ip/CMakeLists.txt index 99c9faf2..8eb120b1 100644 --- a/vendor/core/src/core/ip/CMakeLists.txt +++ b/vendor/core/src/core/ip/CMakeLists.txt @@ -1,5 +1,5 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME ip - SOURCES ipv4.cc ipv6.cc) + SOURCES ip.cc ipv4.cc ipv6.cc ip_helpers.h) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME ip) diff --git a/vendor/core/src/core/ip/include/sourcemeta/core/ip.h b/vendor/core/src/core/ip/include/sourcemeta/core/ip.h index 72860f6b..a4025b3e 100644 --- a/vendor/core/src/core/ip/include/sourcemeta/core/ip.h +++ b/vendor/core/src/core/ip/include/sourcemeta/core/ip.h @@ -5,6 +5,8 @@ #include #endif +#include // std::uint8_t +#include // std::optional #include // std::string_view /// @defgroup ip IP @@ -49,6 +51,74 @@ auto is_ipv4(std::string_view address) -> bool; SOURCEMETA_CORE_IP_EXPORT auto is_ipv6(std::string_view address) -> bool; +/// @ingroup ip +/// The reachability class of an IP address (RFC 6890). +enum class IPAddressClass : std::uint8_t { + /// A globally reachable address. + Public, + /// A loopback address (RFC 1122, RFC 4291 Section 2.5.3). + Loopback, + /// A private-use or shared address (RFC 1918, RFC 6598). + Private, + /// A link-local address (RFC 3927, RFC 4291 Section 2.5.6). + LinkLocal, + /// A unique local address (RFC 4193). + UniqueLocal, + /// A multicast address (RFC 5771, RFC 4291 Section 2.7). + Multicast, + /// An address reserved for future or special use (RFC 1112, RFC 5737). + Reserved, + /// The unspecified address (RFC 4291 Section 2.5.2) or the "this host" + /// network (RFC 1122). + Unspecified +}; + +/// @ingroup ip +/// Classify an IPv4 address (RFC 6890), returning no value when the input is +/// not a valid IPv4 address. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::ipv4_classify("127.0.0.1").value() == +/// sourcemeta::core::IPAddressClass::Loopback); +/// ``` +SOURCEMETA_CORE_IP_EXPORT +auto ipv4_classify(const std::string_view address) + -> std::optional; + +/// @ingroup ip +/// Classify an IPv6 address (RFC 6890), returning no value when the input is +/// not a valid IPv6 address. An IPv4-mapped or IPv4-compatible address is +/// classified as its embedded IPv4 address. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::ipv6_classify("::1").value() == +/// sourcemeta::core::IPAddressClass::Loopback); +/// ``` +SOURCEMETA_CORE_IP_EXPORT +auto ipv6_classify(const std::string_view address) + -> std::optional; + +/// @ingroup ip +/// Whether an IPv4 or IPv6 address is globally reachable (RFC 6890), returning +/// false both for an address that is not and for input that is not a valid +/// address. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::ip_is_globally_routable("8.8.8.8")); +/// assert(!sourcemeta::core::ip_is_globally_routable("10.0.0.1")); +/// ``` +SOURCEMETA_CORE_IP_EXPORT +auto ip_is_globally_routable(const std::string_view address) -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/ip/ip.cc b/vendor/core/src/core/ip/ip.cc new file mode 100644 index 00000000..5ddb9362 --- /dev/null +++ b/vendor/core/src/core/ip/ip.cc @@ -0,0 +1,12 @@ +#include + +#include // std::string_view + +namespace sourcemeta::core { + +auto ip_is_globally_routable(const std::string_view address) -> bool { + return ipv4_classify(address) == IPAddressClass::Public || + ipv6_classify(address) == IPAddressClass::Public; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/ip/ip_helpers.h b/vendor/core/src/core/ip/ip_helpers.h new file mode 100644 index 00000000..abd5c6cc --- /dev/null +++ b/vendor/core/src/core/ip/ip_helpers.h @@ -0,0 +1,93 @@ +#ifndef SOURCEMETA_CORE_IP_HELPERS_H_ +#define SOURCEMETA_CORE_IP_HELPERS_H_ + +#include + +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::string_view + +namespace sourcemeta::core { + +// Extract the four octets of a valid IPv4 address (RFC 3986 Section 3.2.2) +inline auto ipv4_octets(const std::string_view address) + -> std::array { + std::array octets{}; + std::size_t index{0}; + unsigned int value{0}; + for (const auto character : address) { + if (character == '.') { + octets[index] = static_cast(value); + index += 1; + value = 0; + } else { + value = (value * 10) + static_cast(character - '0'); + } + } + + octets[index] = static_cast(value); + return octets; +} + +// Classify an IPv4 address from its octets (RFC 6890), shared so an IPv4-mapped +// IPv6 address can be classified as its embedded IPv4 address +inline auto ipv4_classify_octets(const std::array &octets) + -> IPAddressClass { + const auto first{octets[0]}; + const auto second{octets[1]}; + const auto third{octets[2]}; + + // RFC 1122 Section 3.2.1.3: "this host on this network", 0.0.0.0/8 + if (first == 0) { + return IPAddressClass::Unspecified; + } + + // RFC 1122 Section 3.2.1.3: loopback, 127.0.0.0/8 + if (first == 127) { + return IPAddressClass::Loopback; + } + + // RFC 1918 Section 3: private-use, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + if (first == 10 || (first == 172 && second >= 16 && second <= 31) || + (first == 192 && second == 168)) { + return IPAddressClass::Private; + } + + // RFC 6598: shared address space, 100.64.0.0/10 + if (first == 100 && second >= 64 && second <= 127) { + return IPAddressClass::Private; + } + + // RFC 3927 Section 2.1: link-local, 169.254.0.0/16 + if (first == 169 && second == 254) { + return IPAddressClass::LinkLocal; + } + + // RFC 6890, RFC 5737, and RFC 2544: special-purpose ranges that are not + // globally reachable + if ((first == 192 && second == 0 && third == 0) || + (first == 192 && second == 0 && third == 2) || + (first == 198 && (second == 18 || second == 19)) || + (first == 198 && second == 51 && third == 100) || + (first == 203 && second == 0 && third == 113)) { + return IPAddressClass::Reserved; + } + + // RFC 5771: multicast, 224.0.0.0/4 + if (first >= 224 && first <= 239) { + return IPAddressClass::Multicast; + } + + // RFC 1112 Section 4: reserved for future use, 240.0.0.0/4, which subsumes + // the 255.255.255.255 limited broadcast address + if (first >= 240) { + return IPAddressClass::Reserved; + } + + return IPAddressClass::Public; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/ip/ipv4.cc b/vendor/core/src/core/ip/ipv4.cc index 656935a4..c0e9cf2c 100644 --- a/vendor/core/src/core/ip/ipv4.cc +++ b/vendor/core/src/core/ip/ipv4.cc @@ -1,10 +1,11 @@ #include +#include -namespace sourcemeta::core { +#include "ip_helpers.h" -static constexpr auto is_digit(const char character) -> bool { - return character >= '0' && character <= '9'; -} +#include // std::optional, std::nullopt + +namespace sourcemeta::core { auto is_ipv4(const std::string_view address) -> bool { if (address.empty()) { @@ -53,4 +54,13 @@ auto is_ipv4(const std::string_view address) -> bool { return position == address.size(); } +auto ipv4_classify(const std::string_view address) + -> std::optional { + if (!is_ipv4(address)) { + return std::nullopt; + } + + return ipv4_classify_octets(ipv4_octets(address)); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/ip/ipv6.cc b/vendor/core/src/core/ip/ipv6.cc index 4d7883b3..e5faac21 100644 --- a/vendor/core/src/core/ip/ipv6.cc +++ b/vendor/core/src/core/ip/ipv6.cc @@ -1,6 +1,14 @@ #include #include +#include "ip_helpers.h" + +#include // std::array +#include // std::size_t +#include // std::uint8_t, std::uint16_t +#include // std::optional, std::nullopt +#include // std::string_view + namespace sourcemeta::core { auto is_ipv6(const std::string_view address) -> bool { @@ -91,4 +99,211 @@ auto is_ipv6(const std::string_view address) -> bool { return group_count == 8; } +namespace { + +// Append the big-endian bytes of a colon-separated run of hex groups, where the +// final group may be an embedded IPv4 dotted quad, to a buffer at an offset, +// returning how many bytes were written. The run comes from a valid address. +auto fill_ipv6_run(const std::string_view run, + std::array &output, + const std::size_t offset) -> std::size_t { + if (run.empty()) { + return 0; + } + + std::size_t length{offset}; + std::size_t position{0}; + while (true) { + const auto colon{run.find(':', position)}; + const auto piece{run.substr(position, colon == std::string_view::npos + ? std::string_view::npos + : colon - position)}; + if (piece.find('.') != std::string_view::npos) { + for (const auto octet : ipv4_octets(piece)) { + output[length] = octet; + length += 1; + } + } else { + std::uint16_t value{0}; + for (const auto character : piece) { + value = static_cast( + (value << 4U) | + static_cast(hex_digit_value(character))); + } + + output[length] = static_cast(value >> 8U); + output[length + 1] = static_cast(value & 0xffU); + length += 2; + } + + if (colon == std::string_view::npos) { + break; + } + + position = colon + 1; + } + + return length - offset; +} + +// Extract the sixteen bytes of a valid IPv6 address (RFC 4291 Section 2.2), +// expanding a "::" by zero-filling between the head and the tail +auto ipv6_bytes(const std::string_view address) + -> std::array { + std::array bytes{}; + const auto compression{address.find("::")}; + if (compression == std::string_view::npos) { + fill_ipv6_run(address, bytes, 0); + return bytes; + } + + fill_ipv6_run(address.substr(0, compression), bytes, 0); + + std::array tail{}; + const auto tail_length{ + fill_ipv6_run(address.substr(compression + 2), tail, 0)}; + for (std::size_t index = 0; index < tail_length; index += 1) { + bytes[16 - tail_length + index] = tail[index]; + } + + return bytes; +} + +} // namespace + +auto ipv6_classify(const std::string_view address) + -> std::optional { + if (!is_ipv6(address)) { + return std::nullopt; + } + + const auto bytes{ipv6_bytes(address)}; + + // The first ten bytes are zero for the IPv4-mapped and the deprecated + // IPv4-compatible forms, both of which embed an IPv4 address (RFC 4291 + // Section 2.5.5) + bool leading_zero{true}; + for (std::size_t index = 0; index < 10; index += 1) { + if (bytes[index] != 0) { + leading_zero = false; + break; + } + } + + const std::array embedded{ + {bytes[12], bytes[13], bytes[14], bytes[15]}}; + + // RFC 4291 Section 2.5.5.2: an IPv4-mapped address ::ffff:0:0/96 is + // classified as its embedded IPv4 address, so ::ffff:127.0.0.1 cannot bypass + // a check + if (leading_zero && bytes[10] == 0xff && bytes[11] == 0xff) { + return ipv4_classify_octets(embedded); + } + + if (leading_zero && bytes[10] == 0 && bytes[11] == 0) { + // RFC 4291 Section 2.5.2: the unspecified address :: + if (embedded[0] == 0 && embedded[1] == 0 && embedded[2] == 0 && + embedded[3] == 0) { + return IPAddressClass::Unspecified; + } + + // RFC 4291 Section 2.5.3: the loopback address ::1 + if (embedded[0] == 0 && embedded[1] == 0 && embedded[2] == 0 && + embedded[3] == 1) { + return IPAddressClass::Loopback; + } + + // RFC 4291 Section 2.5.5.1: the deprecated IPv4-compatible form embeds an + // IPv4 address, classified as that address so ::127.0.0.1 cannot bypass a + // check + return ipv4_classify_octets(embedded); + } + + // RFC 4291 Section 2.7: multicast, ff00::/8 + if (bytes[0] == 0xff) { + return IPAddressClass::Multicast; + } + + // RFC 4291 Section 2.5.6: link-local unicast, fe80::/10 + if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) { + return IPAddressClass::LinkLocal; + } + + // RFC 4193 Section 3: unique local, fc00::/7 + if ((bytes[0] & 0xfe) == 0xfc) { + return IPAddressClass::UniqueLocal; + } + + // RFC 4291 Section 2.4: only global unicast, 2000::/3, is globally reachable, + // so every other prefix, including the IETF-reserved 0000::/8, is not + if ((bytes[0] & 0xe0) != 0x20) { + return IPAddressClass::Reserved; + } + + // RFC 2928: the IETF protocol assignments block, 2001::/23, which the IANA + // IPv6 Special-Purpose Address Registry marks as not globally reachable + // unless a more specific allocation within it says otherwise + if (bytes[0] == 0x20 && bytes[1] == 0x01 && (bytes[2] & 0xfe) == 0x00) { + // RFC 7723, RFC 8155, and RFC 9665: the globally reachable anycast + // addresses 2001:1::1, 2001:1::2, and 2001:1::3 + if (bytes[2] == 0x00 && bytes[3] == 0x01 && bytes[15] >= 1 && + bytes[15] <= 3) { + bool anycast{true}; + for (std::size_t index = 4; index < 15; index += 1) { + if (bytes[index] != 0) { + anycast = false; + break; + } + } + if (anycast) { + return IPAddressClass::Public; + } + } + + // RFC 7450: AMT, 2001:3::/32, globally reachable + if (bytes[2] == 0x00 && bytes[3] == 0x03) { + return IPAddressClass::Public; + } + + // RFC 7535: AS112-v6, 2001:4:112::/48, globally reachable + if (bytes[2] == 0x00 && bytes[3] == 0x04 && bytes[4] == 0x01 && + bytes[5] == 0x12) { + return IPAddressClass::Public; + } + + // RFC 7343: ORCHIDv2, 2001:20::/28, and RFC 9374: Drone Remote ID + // Protocol Entity Tags, 2001:30::/28, both globally reachable + if (bytes[2] == 0x00 && (bytes[3] & 0xe0) == 0x20) { + return IPAddressClass::Public; + } + + // Everything else in 2001::/23, including TEREDO (RFC 4380), 2001::/32, + // benchmarking (RFC 5180), 2001:2::/48, and the deprecated ORCHID + // (RFC 4843), 2001:10::/28, is not globally reachable + return IPAddressClass::Reserved; + } + + // RFC 3849: documentation, 2001:db8::/32, within global unicast + if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && + bytes[3] == 0xb8) { + return IPAddressClass::Reserved; + } + + // RFC 3056 Section 2: a 6to4 address, 2002::/16, embeds the IPv4 address of + // its tunnel endpoint, so it is classified as that address and a 6to4 form + // of a private or loopback IPv4 address cannot bypass a check. Note that + // RFC 7526 Section 4 deprecated the relay anycast mechanism, not unicast + // 6to4 or its prefix + if (bytes[0] == 0x20 && bytes[1] == 0x02) { + return ipv4_classify_octets({{bytes[2], bytes[3], bytes[4], bytes[5]}}); + } + + // RFC 9637: documentation, 3fff::/20, within global unicast + if (bytes[0] == 0x3f && bytes[1] == 0xff && (bytes[2] & 0xf0) == 0x00) { + return IPAddressClass::Reserved; + } + + return IPAddressClass::Public; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/CMakeLists.txt b/vendor/core/src/core/jose/CMakeLists.txt index ea752575..db73e168 100644 --- a/vendor/core/src/core/jose/CMakeLists.txt +++ b/vendor/core/src/core/jose/CMakeLists.txt @@ -1,11 +1,13 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME jose PRIVATE_HEADERS algorithm.h error.h jwk.h jwk_private.h jwks.h jwks_provider.h - jwt.h sign.h verify.h + jwt.h sign.h verify.h jwe.h encrypt.h decrypt.h SOURCES jose_algorithm.cc jose_jwk.cc jose_jwk_private.cc jose_jwks.cc jose_jwks_provider.cc jose_jwk_thumbprint.cc jose_jwt.cc jose_jwt_check_claims.cc jose_jws_verify_signature.cc jose_jwt_verify_signature.cc jose_jwt_verify.cc - jose_jws_sign.cc jose_jwt_sign.cc jose_key.h) + jose_jws_sign.cc jose_jwt_sign.cc jose_key.h + jose_jwe.cc jose_jwe_encrypt.cc jose_jwe_decrypt.cc + jose_jwe_internal.h) target_link_libraries(sourcemeta_core_jose PUBLIC sourcemeta::core::json) diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose.h index de47d07d..3240e62c 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose.h @@ -7,7 +7,10 @@ // NOLINTBEGIN(misc-include-cleaner) #include +#include +#include #include +#include #include #include #include diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h index bef1d3da..ee432448 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_algorithm.h @@ -5,7 +5,8 @@ #include #endif -#include // std::uint8_t +#include // std::size_t +#include // std::uint8_t, std::uint16_t #include // std::optional #include // std::string_view @@ -93,6 +94,170 @@ auto jws_algorithm_name(const JWSAlgorithm algorithm) noexcept SOURCEMETA_CORE_JOSE_EXPORT auto jws_algorithm_is_asymmetric(const JWSAlgorithm algorithm) noexcept -> bool; +/// @ingroup jose +/// The size, in bits, of the digest each signature algorithm is defined over +/// (RFC 7518 Section 3.1), so that derived hash claims such as OpenID +/// Connect's `at_hash` and `c_hash` select their digest by table rather than +/// by slicing the algorithm name. The Edwards-curve algorithm names no digest +/// and OpenID Connect leaves the choice unspecified, so it is pinned to +/// SHA-512, the convention deployed implementations follow for Ed25519, whose +/// signature scheme is internally defined over that hash (RFC 8032 Section +/// 5.1). Hash claims minted over Ed448 by a SHAKE256-based implementation +/// need curve-specific handling that no algorithm-only table can provide. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jws_algorithm_digest_bits( +/// sourcemeta::core::JWSAlgorithm::ES384) == 384); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jws_algorithm_digest_bits(const JWSAlgorithm algorithm) noexcept + -> std::uint16_t; + +/// @ingroup jose +/// The JSON Web Encryption key-management algorithms (the `alg` value) from RFC +/// 7518 Section 4.1. Each one determines how the content encryption key is +/// delivered to the recipient, and demands a key of exactly one family, which +/// is what keeps algorithm confusion attacks unexploitable. +enum class JWEAlgorithm : std::uint8_t { + /// RSAES OAEP using default parameters (SHA-1). + RSA_OAEP, + /// RSAES OAEP using SHA-256. + RSA_OAEP_256, + /// Elliptic Curve Diffie-Hellman Ephemeral Static key agreement, direct. + ECDH_ES, + /// ECDH-ES using Concat KDF and the CEK wrapped with AES-128 Key Wrap. + ECDH_ES_A128KW, + /// ECDH-ES using Concat KDF and the CEK wrapped with AES-192 Key Wrap. + ECDH_ES_A192KW, + /// ECDH-ES using Concat KDF and the CEK wrapped with AES-256 Key Wrap. + ECDH_ES_A256KW, + /// AES-128 Key Wrap. + A128KW, + /// AES-192 Key Wrap. + A192KW, + /// AES-256 Key Wrap. + A256KW, + /// Direct use of a shared symmetric key as the CEK. + DIR +}; + +/// @ingroup jose +/// The JSON Web Encryption content encryption algorithms (the `enc` value) from +/// RFC 7518 Section 5.1. Each one seals the plaintext under the content +/// encryption key with an authenticated encryption scheme. +enum class JWEEncryption : std::uint8_t { + /// AES-128 in Galois/Counter Mode. + A128GCM, + /// AES-192 in Galois/Counter Mode. + A192GCM, + /// AES-256 in Galois/Counter Mode. + A256GCM, + /// AES-128 in CBC mode with an HMAC SHA-256 authentication tag. + A128CBC_HS256, + /// AES-192 in CBC mode with an HMAC SHA-384 authentication tag. + A192CBC_HS384, + /// AES-256 in CBC mode with an HMAC SHA-512 authentication tag. + A256CBC_HS512 +}; + +/// @ingroup jose +/// Map a JSON Web Encryption `alg` value to its algorithm, returning no value +/// for any unrecognized name. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_jwe_algorithm("RSA-OAEP-256").has_value()); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto to_jwe_algorithm(const std::string_view value) noexcept + -> std::optional; + +/// @ingroup jose +/// Map a JSON Web Encryption key-management algorithm to its `alg` value, the +/// inverse of parsing (RFC 7518 Section 4.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jwe_algorithm_name( +/// sourcemeta::core::JWEAlgorithm::DIR) == "dir"); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jwe_algorithm_name(const JWEAlgorithm algorithm) noexcept + -> std::string_view; + +/// @ingroup jose +/// Map a JSON Web Encryption `enc` value to its algorithm, returning no value +/// for any unrecognized name. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_jwe_encryption("A128GCM").has_value()); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto to_jwe_encryption(const std::string_view value) noexcept + -> std::optional; + +/// @ingroup jose +/// Map a JSON Web Encryption content encryption algorithm to its `enc` value, +/// the inverse of parsing (RFC 7518 Section 5.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jwe_encryption_name( +/// sourcemeta::core::JWEEncryption::A128GCM) == "A128GCM"); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jwe_encryption_name(const JWEEncryption encryption) noexcept + -> std::string_view; + +/// @ingroup jose +/// Whether the key-management algorithm consumes an asymmetric recipient key +/// (`RSA-OAEP*`, `ECDH-ES*`) rather than a shared octet secret (`A*KW`, `dir`) +/// (RFC 7518 Section 4.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jwe_algorithm_is_asymmetric( +/// sourcemeta::core::JWEAlgorithm::RSA_OAEP)); +/// assert(!sourcemeta::core::jwe_algorithm_is_asymmetric( +/// sourcemeta::core::JWEAlgorithm::A128KW)); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jwe_algorithm_is_asymmetric(const JWEAlgorithm algorithm) noexcept -> bool; + +/// @ingroup jose +/// The content encryption key length, in bytes, that a content encryption +/// algorithm requires (RFC 7518 Sections 5.2 and 5.3). The CBC-HMAC algorithms +/// take a double-width key that is split into a MAC key and an encryption key. +/// For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::jwe_encryption_key_bytes( +/// sourcemeta::core::JWEEncryption::A128GCM) == 16); +/// assert(sourcemeta::core::jwe_encryption_key_bytes( +/// sourcemeta::core::JWEEncryption::A128CBC_HS256) == 32); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jwe_encryption_key_bytes(const JWEEncryption encryption) noexcept + -> std::size_t; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_decrypt.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_decrypt.h new file mode 100644 index 00000000..117c3741 --- /dev/null +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_decrypt.h @@ -0,0 +1,40 @@ +#ifndef SOURCEMETA_CORE_JOSE_DECRYPT_H_ +#define SOURCEMETA_CORE_JOSE_DECRYPT_H_ + +#ifndef SOURCEMETA_CORE_JOSE_EXPORT +#include +#endif + +#include +#include + +#include // std::optional +#include // std::string + +namespace sourcemeta::core { + +/// @ingroup jose +/// Decrypt a JSON Web Encryption object with the recipient key, returning the +/// plaintext (RFC 7516). The key is the recipient private key for the +/// asymmetric algorithms and the shared octet secret for `A*KW` and `dir`. +/// Returns no value on any failure, uniformly, so that a key-unwrap failure, an +/// authentication tag mismatch, and a structural error are indistinguishable +/// and the recipient cannot be used as a decryption oracle (RFC 7516 Sections +/// 11.4 and 11.5). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto object{sourcemeta::core::JWE::from(compact)}; +/// assert(object.has_value()); +/// const auto plaintext{sourcemeta::core::jwe_decrypt(object.value(), key)}; +/// assert(plaintext.has_value()); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jwe_decrypt(const JWE &jwe, const JWKPrivate &key) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_encrypt.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_encrypt.h new file mode 100644 index 00000000..01b3a889 --- /dev/null +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_encrypt.h @@ -0,0 +1,51 @@ +#ifndef SOURCEMETA_CORE_JOSE_ENCRYPT_H_ +#define SOURCEMETA_CORE_JOSE_ENCRYPT_H_ + +#ifndef SOURCEMETA_CORE_JOSE_EXPORT +#include +#endif + +#include + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup jose +/// Encrypt a plaintext into a JSON Web Encryption object in compact +/// serialization (RFC 7516). The key-management algorithm and content +/// encryption algorithm are taken from the protected header `alg` and `enc` +/// parameters (RFC 7516 Sections 4.1.1 and 4.1.2). A fresh initialization +/// vector is always generated. The content encryption key is generated as the +/// algorithm requires: freshly random for the key-wrapping and RSA algorithms, +/// derived from the agreement for direct `ECDH-ES`, and the shared secret +/// itself for `dir`. For the `ECDH-ES` algorithms an ephemeral key is minted +/// and its public part added to the emitted protected header as the `epk` +/// parameter. The key is the recipient public key for the asymmetric algorithms +/// and the shared octet secret for `A*KW` and `dir`. Returns no value when the +/// header is not an object, has duplicate members, carries a critical extension +/// or a compression parameter, names an unsupported or key-incompatible +/// algorithm, or the key cannot serve it. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto object{sourcemeta::core::jwe_encrypt( +/// sourcemeta::core::parse_json(R"({ "alg": "RSA-OAEP-256", +/// "enc": "A128GCM" })"), +/// "secret message", recipient)}; +/// assert(object.has_value()); +/// ``` +SOURCEMETA_CORE_JOSE_EXPORT +auto jwe_encrypt(const JSON &header, const std::string_view plaintext, + const JWK &key) -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_error.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_error.h index 9cd17fc5..513e815d 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_error.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_error.h @@ -40,6 +40,15 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWTParseError : public std::exception { } }; +/// @ingroup jose +/// An error that occurs when parsing an invalid JSON Web Encryption object. +class SOURCEMETA_CORE_JOSE_EXPORT JWEParseError : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid JSON Web Encryption object"; + } +}; + #if defined(_MSC_VER) #pragma warning(default : 4251 4275) #endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwe.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwe.h new file mode 100644 index 00000000..ebf610eb --- /dev/null +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwe.h @@ -0,0 +1,120 @@ +#ifndef SOURCEMETA_CORE_JOSE_JWE_H_ +#define SOURCEMETA_CORE_JOSE_JWE_H_ + +#ifndef SOURCEMETA_CORE_JOSE_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +#include +// NOLINTEND(misc-include-cleaner) + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup jose +/// A parsed JSON Web Encryption object in compact serialization (RFC 7516). It +/// owns its decoded segments, so the string it was parsed from need not outlive +/// it. The plaintext is recovered by passing it to `jwe_decrypt`. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto object{sourcemeta::core::JWE::from(compact)}; +/// assert(object.has_value()); +/// assert(object.value().encryption().has_value()); +/// ``` +class SOURCEMETA_CORE_JOSE_EXPORT JWE { +public: + /// Parse a JSON Web Encryption object from its compact serialization, + /// throwing a `JWEParseError` on invalid input. + explicit JWE(const std::string_view input); + + /// Parse a JSON Web Encryption object from its compact serialization, + /// returning no value on invalid input. + [[nodiscard]] static auto from(const std::string_view input) + -> std::optional; + + // Protected header (RFC 7516 Section 4) + + /// The key-management algorithm declared in the protected header, if it names + /// a supported one. + [[nodiscard]] auto algorithm() const noexcept -> std::optional { + return this->algorithm_; + } + + /// The content encryption algorithm declared in the protected header, if it + /// names a supported one. + [[nodiscard]] auto encryption() const noexcept + -> std::optional { + return this->encryption_; + } + + /// The key identifier from the protected header, if present. + [[nodiscard]] auto key_id() const noexcept -> std::optional; + + /// The decoded protected header. + [[nodiscard]] auto header() const noexcept -> const JSON & { + return this->header_; + } + + // Wire segments (RFC 7516 Section 7.1), consumed by jwe_decrypt + + /// The exact base64url-encoded protected header, the Additional Authenticated + /// Data the content encryption is bound to (RFC 7516 Section 5.1). + [[nodiscard]] auto protected_header() const noexcept -> std::string_view { + return this->protected_header_; + } + + /// The decoded JWE Encrypted Key, empty for direct key agreement and direct + /// encryption. + [[nodiscard]] auto encrypted_key() const noexcept -> std::string_view { + return this->encrypted_key_; + } + + /// The decoded JWE Initialization Vector. + [[nodiscard]] auto initialization_vector() const noexcept + -> std::string_view { + return this->initialization_vector_; + } + + /// The decoded JWE Ciphertext. + [[nodiscard]] auto ciphertext() const noexcept -> std::string_view { + return this->ciphertext_; + } + + /// The decoded JWE Authentication Tag. + [[nodiscard]] auto tag() const noexcept -> std::string_view { + return this->tag_; + } + +private: + JWE() = default; + static auto parse(const std::string_view input, JWE &result) -> bool; + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + std::string protected_header_; + std::string encrypted_key_; + std::string initialization_vector_; + std::string ciphertext_; + std::string tag_; + JSON header_{nullptr}; + std::optional algorithm_; + std::optional encryption_; +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif +}; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h index cd13d7cf..8a1e0c2c 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk.h @@ -71,6 +71,21 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWK { /// input. [[nodiscard]] static auto from(JSON &&value) -> std::optional; + /// Build a symmetric octet key (RFC 7518 Section 6.4) directly from raw + /// secret octets, taken as-is rather than base64url-decoded, so a caller + /// holding a shared secret does not assemble and re-parse an `oct` JSON Web + /// Key. The minimum secret size each symmetric algorithm demands is enforced + /// where the key is used, not here. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const auto key{sourcemeta::core::JWK::from_octets("my-shared-secret")}; + /// assert(key.type() == sourcemeta::core::JWK::Type::Octet); + /// ``` + [[nodiscard]] static auto from_octets(const std::string_view secret) -> JWK; + /// The family of key material this key holds. [[nodiscard]] auto type() const noexcept -> Type { return this->type_; } diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h index 6ac81e71..5a96ac93 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwk_private.h @@ -69,6 +69,23 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKPrivate { [[nodiscard]] static auto from_pem(const std::string_view pem) -> std::optional; + /// Build a symmetric octet key (RFC 7518 Section 6.4) directly from raw + /// secret octets, taken as-is rather than base64url-decoded, so a caller + /// minting a token under a shared secret does not assemble and re-parse an + /// `oct` JSON Web Key. The minimum secret size each symmetric algorithm + /// demands is enforced where the key is used, not here. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const auto key{ + /// sourcemeta::core::JWKPrivate::from_octets("my-shared-secret")}; + /// assert(key.type() == sourcemeta::core::JWKPrivate::Type::Octet); + /// ``` + [[nodiscard]] static auto from_octets(const std::string_view secret) + -> JWKPrivate; + /// The family of key material this key holds. [[nodiscard]] auto type() const noexcept -> Type { return this->type_; } diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h index 40b18e30..a9c4529d 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h @@ -66,8 +66,8 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKSProvider { /// The wait before another refetch is allowed after an unknown key /// identifier. std::chrono::seconds unknown_kid_cooldown{std::chrono::minutes{5}}; - /// The tolerance applied to time-based claims. - std::chrono::seconds clock_skew{0}; + /// The tolerance applied to time-based claims, uniform or per claim. + JWTClockSkew clock_skew{}; }; /// Construct a provider for a concrete key set URL with an injected @@ -103,7 +103,30 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKSProvider { const std::optional expected_type = std::nullopt) -> std::optional; + /// Verify a token exactly as the other overload does, additionally exposing + /// the clock reading the verification ran at, so that a caller performing + /// its own time checks on the same token, such as an issued-at age or an + /// authentication-time freshness window, shares the verifier's clock rather + /// than injecting a second, independently read one. + [[nodiscard]] auto + verify(const JWT &token, + const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject, + const std::optional expected_type, + std::chrono::system_clock::time_point &resolved_now) + -> std::optional; + private: + auto verify_at(const JWT &token, + const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject, + const std::optional expected_type, + const std::chrono::system_clock::time_point now) + -> std::optional; auto fetch_and_install_locked(std::chrono::system_clock::time_point now) -> bool; auto refresh_locked(std::chrono::system_clock::time_point now) diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwt.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwt.h index 8938aebb..0e803e8b 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwt.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_jwt.h @@ -59,6 +59,11 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWT { /// The token type declared in the header, if present. [[nodiscard]] auto type() const noexcept -> std::optional; + /// Whether the token declares the given type, treating a subtype without a + /// slash as `application/`-prefixed and comparing case-insensitively (RFC + /// 7519 Section 5.1, RFC 7515 Section 4.1.9). + [[nodiscard]] auto has_type(const std::string_view media_type) const -> bool; + /// The decoded token header. [[nodiscard]] auto header() const noexcept -> const JSON & { return this->header_; diff --git a/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h b/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h index 2e95d554..2788f2cd 100644 --- a/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h +++ b/vendor/core/src/core/jose/include/sourcemeta/core/jose_verify.h @@ -38,6 +38,47 @@ enum class JWTClaimError : std::uint8_t { IssuedAt }; +/// @ingroup jose +/// A tolerance for clock disagreement between the token issuer and the +/// verifier, carried per time-based claim so that, for example, a deployment +/// can forgive a small expiration overshoot without also accepting tokens +/// issued in the future. A plain seconds value converts to a uniform +/// tolerance across every claim. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const sourcemeta::core::JWTClockSkew uniform{std::chrono::seconds{30}}; +/// const sourcemeta::core::JWTClockSkew per_claim{ +/// std::chrono::seconds{60}, std::chrono::seconds{30}, +/// std::chrono::seconds{0}}; +/// ``` +struct JWTClockSkew { + /// Apply no tolerance to any time-based claim. + JWTClockSkew() noexcept = default; + + // The conversion is deliberately implicit so that a caller holding a single + // uniform tolerance can keep passing plain seconds + /// Apply the same tolerance to every time-based claim. + JWTClockSkew(const std::chrono::seconds uniform) noexcept + : expiration{uniform}, not_before{uniform}, issued_at{uniform} {} + + /// Apply a distinct tolerance to each time-based claim. + JWTClockSkew(const std::chrono::seconds expiration_tolerance, + const std::chrono::seconds not_before_tolerance, + const std::chrono::seconds issued_at_tolerance) noexcept + : expiration{expiration_tolerance}, not_before{not_before_tolerance}, + issued_at{issued_at_tolerance} {} + + /// The tolerance applied to the expiration time claim. + std::chrono::seconds expiration{0}; + /// The tolerance applied to the not-before time claim. + std::chrono::seconds not_before{0}; + /// The tolerance applied to the issued-at time claim. + std::chrono::seconds issued_at{0}; +}; + /// @ingroup jose /// Validate the registered claims of a JSON Web Token against the expected /// issuer and audience at a given time, returning the first failing check or no @@ -62,13 +103,12 @@ enum class JWTClaimError : std::uint8_t { /// assert(!error.has_value()); /// ``` SOURCEMETA_CORE_JOSE_EXPORT -auto jwt_check_claims( - const JWT &token, const std::string_view expected_issuer, - const std::string_view expected_audience, - const std::chrono::system_clock::time_point now, - const std::chrono::seconds clock_skew = std::chrono::seconds{0}, - const std::optional expected_subject = std::nullopt) - -> std::optional; +auto jwt_check_claims(const JWT &token, const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::chrono::system_clock::time_point now, + const JWTClockSkew clock_skew = {}, + const std::optional expected_subject = + std::nullopt) -> std::optional; /// @ingroup jose /// Verify a JSON Web Signature given its algorithm, its signing input, and its @@ -184,7 +224,7 @@ auto jwt_verify( const std::string_view expected_issuer, const std::string_view expected_audience, const std::chrono::system_clock::time_point now, - const std::chrono::seconds clock_skew = std::chrono::seconds{0}, + const JWTClockSkew clock_skew = {}, const std::optional expected_subject = std::nullopt, const std::optional expected_type = std::nullopt) -> std::optional; diff --git a/vendor/core/src/core/jose/jose_algorithm.cc b/vendor/core/src/core/jose/jose_algorithm.cc index 8044a08c..4f64dc45 100644 --- a/vendor/core/src/core/jose/jose_algorithm.cc +++ b/vendor/core/src/core/jose/jose_algorithm.cc @@ -1,5 +1,7 @@ #include +#include // std::size_t +#include // std::uint16_t #include // std::optional, std::nullopt #include // std::string_view #include // std::unreachable @@ -96,4 +98,164 @@ auto jws_algorithm_is_asymmetric(const JWSAlgorithm algorithm) noexcept std::unreachable(); } +auto jws_algorithm_digest_bits(const JWSAlgorithm algorithm) noexcept + -> std::uint16_t { + switch (algorithm) { + case JWSAlgorithm::RS256: + case JWSAlgorithm::PS256: + case JWSAlgorithm::ES256: + case JWSAlgorithm::HS256: + return 256; + case JWSAlgorithm::RS384: + case JWSAlgorithm::PS384: + case JWSAlgorithm::ES384: + case JWSAlgorithm::HS384: + return 384; + case JWSAlgorithm::RS512: + case JWSAlgorithm::PS512: + case JWSAlgorithm::ES512: + case JWSAlgorithm::HS512: + case JWSAlgorithm::EdDSA: + return 512; + } + + std::unreachable(); +} + +auto to_jwe_algorithm(const std::string_view value) noexcept + -> std::optional { + if (value == "RSA-OAEP") { + return JWEAlgorithm::RSA_OAEP; + } else if (value == "RSA-OAEP-256") { + return JWEAlgorithm::RSA_OAEP_256; + } else if (value == "ECDH-ES") { + return JWEAlgorithm::ECDH_ES; + } else if (value == "ECDH-ES+A128KW") { + return JWEAlgorithm::ECDH_ES_A128KW; + } else if (value == "ECDH-ES+A192KW") { + return JWEAlgorithm::ECDH_ES_A192KW; + } else if (value == "ECDH-ES+A256KW") { + return JWEAlgorithm::ECDH_ES_A256KW; + } else if (value == "A128KW") { + return JWEAlgorithm::A128KW; + } else if (value == "A192KW") { + return JWEAlgorithm::A192KW; + } else if (value == "A256KW") { + return JWEAlgorithm::A256KW; + } else if (value == "dir") { + return JWEAlgorithm::DIR; + } else { + return std::nullopt; + } +} + +auto jwe_algorithm_name(const JWEAlgorithm algorithm) noexcept + -> std::string_view { + switch (algorithm) { + case JWEAlgorithm::RSA_OAEP: + return "RSA-OAEP"; + case JWEAlgorithm::RSA_OAEP_256: + return "RSA-OAEP-256"; + case JWEAlgorithm::ECDH_ES: + return "ECDH-ES"; + case JWEAlgorithm::ECDH_ES_A128KW: + return "ECDH-ES+A128KW"; + case JWEAlgorithm::ECDH_ES_A192KW: + return "ECDH-ES+A192KW"; + case JWEAlgorithm::ECDH_ES_A256KW: + return "ECDH-ES+A256KW"; + case JWEAlgorithm::A128KW: + return "A128KW"; + case JWEAlgorithm::A192KW: + return "A192KW"; + case JWEAlgorithm::A256KW: + return "A256KW"; + case JWEAlgorithm::DIR: + return "dir"; + } + + std::unreachable(); +} + +auto to_jwe_encryption(const std::string_view value) noexcept + -> std::optional { + if (value == "A128GCM") { + return JWEEncryption::A128GCM; + } else if (value == "A192GCM") { + return JWEEncryption::A192GCM; + } else if (value == "A256GCM") { + return JWEEncryption::A256GCM; + } else if (value == "A128CBC-HS256") { + return JWEEncryption::A128CBC_HS256; + } else if (value == "A192CBC-HS384") { + return JWEEncryption::A192CBC_HS384; + } else if (value == "A256CBC-HS512") { + return JWEEncryption::A256CBC_HS512; + } else { + return std::nullopt; + } +} + +auto jwe_encryption_name(const JWEEncryption encryption) noexcept + -> std::string_view { + switch (encryption) { + case JWEEncryption::A128GCM: + return "A128GCM"; + case JWEEncryption::A192GCM: + return "A192GCM"; + case JWEEncryption::A256GCM: + return "A256GCM"; + case JWEEncryption::A128CBC_HS256: + return "A128CBC-HS256"; + case JWEEncryption::A192CBC_HS384: + return "A192CBC-HS384"; + case JWEEncryption::A256CBC_HS512: + return "A256CBC-HS512"; + } + + std::unreachable(); +} + +auto jwe_algorithm_is_asymmetric(const JWEAlgorithm algorithm) noexcept + -> bool { + switch (algorithm) { + case JWEAlgorithm::RSA_OAEP: + case JWEAlgorithm::RSA_OAEP_256: + case JWEAlgorithm::ECDH_ES: + case JWEAlgorithm::ECDH_ES_A128KW: + case JWEAlgorithm::ECDH_ES_A192KW: + case JWEAlgorithm::ECDH_ES_A256KW: + return true; + case JWEAlgorithm::A128KW: + case JWEAlgorithm::A192KW: + case JWEAlgorithm::A256KW: + case JWEAlgorithm::DIR: + return false; + } + + std::unreachable(); +} + +auto jwe_encryption_key_bytes(const JWEEncryption encryption) noexcept + -> std::size_t { + switch (encryption) { + case JWEEncryption::A128GCM: + return 16; + case JWEEncryption::A192GCM: + return 24; + // A GCM key is used directly, while a CBC-HMAC key of the same length is + // the MAC key followed by an equal-length encryption key (RFC 7518 Section + // 5.2.2.1), so A256GCM and A128CBC-HS256 both take 32 octets + case JWEEncryption::A256GCM: + case JWEEncryption::A128CBC_HS256: + return 32; + case JWEEncryption::A192CBC_HS384: + return 48; + case JWEEncryption::A256CBC_HS512: + return 64; + } + + std::unreachable(); +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwe.cc b/vendor/core/src/core/jose/jose_jwe.cc new file mode 100644 index 00000000..eac85b8d --- /dev/null +++ b/vendor/core/src/core/jose/jose_jwe.cc @@ -0,0 +1,132 @@ +#include + +#include +#include + +#include // std::optional, std::nullopt +#include // std::string_view +#include // std::move + +namespace { +using namespace std::string_view_literals; + +constexpr auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; +constexpr auto HASH_ENC{sourcemeta::core::JSON::Object::hash("enc"sv)}; +constexpr auto HASH_CRIT{sourcemeta::core::JSON::Object::hash("crit"sv)}; +constexpr auto HASH_ZIP{sourcemeta::core::JSON::Object::hash("zip"sv)}; +constexpr auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; + +} // namespace + +namespace sourcemeta::core { + +auto JWE::parse(const std::string_view input, JWE &result) -> bool { + // The compact serialization is exactly five base64url segments joined by dots + // (RFC 7516 Section 7.1) + const auto first{split_once(input, '.')}; + if (!first.has_value()) { + return false; + } + + const auto second{split_once(first->second, '.')}; + if (!second.has_value()) { + return false; + } + + const auto third{split_once(second->second, '.')}; + if (!third.has_value()) { + return false; + } + + const auto fourth{split_once(third->second, '.')}; + if (!fourth.has_value()) { + return false; + } + + const auto header_segment{first->first}; + const auto encrypted_key_segment{second->first}; + const auto iv_segment{third->first}; + const auto ciphertext_segment{fourth->first}; + const auto tag_segment{fourth->second}; + if (tag_segment.find('.') != std::string_view::npos) { + return false; + } + + auto header_bytes{base64url_decode(header_segment)}; + auto encrypted_key_bytes{base64url_decode(encrypted_key_segment)}; + auto iv_bytes{base64url_decode(iv_segment)}; + auto ciphertext_bytes{base64url_decode(ciphertext_segment)}; + auto tag_bytes{base64url_decode(tag_segment)}; + if (!header_bytes.has_value() || !encrypted_key_bytes.has_value() || + !iv_bytes.has_value() || !ciphertext_bytes.has_value() || + !tag_bytes.has_value()) { + return false; + } + + // The JSON layer preserves repeated members rather than collapsing them, so + // the header parameter names must be unique (RFC 7516 Section 4, RFC 8725 + // Section 2.4) + auto header_json{try_parse_json(header_bytes.value())}; + if (!header_json.has_value() || !header_json.value().is_object() || + !header_json.value().unique_keys()) { + return false; + } + + // The algorithm and content encryption header parameters are required and + // must be strings (RFC 7516 Sections 4.1.1 and 4.1.2) + const auto *algorithm{header_json.value().try_at("alg", HASH_ALG)}; + const auto *encryption{header_json.value().try_at("enc", HASH_ENC)}; + if (algorithm == nullptr || !algorithm->is_string() || + encryption == nullptr || !encryption->is_string()) { + return false; + } + + // Critical header extensions are not understood and must be rejected (RFC + // 7516 Section 4.1.13) + if (header_json.value().try_at("crit", HASH_CRIT) != nullptr) { + return false; + } + + // Compression is not implemented, so a compressed object is rejected rather + // than returning the still-compressed octets as the plaintext (RFC 7516 + // Section 5.2 step 17) + if (header_json.value().try_at("zip", HASH_ZIP) != nullptr) { + return false; + } + + result.algorithm_ = to_jwe_algorithm(algorithm->to_string()); + result.encryption_ = to_jwe_encryption(encryption->to_string()); + result.protected_header_ = header_segment; + result.encrypted_key_ = std::move(encrypted_key_bytes).value(); + result.initialization_vector_ = std::move(iv_bytes).value(); + result.ciphertext_ = std::move(ciphertext_bytes).value(); + result.tag_ = std::move(tag_bytes).value(); + result.header_ = std::move(header_json).value(); + return true; +} + +JWE::JWE(const std::string_view input) { + if (!parse(input, *this)) { + throw JWEParseError{}; + } +} + +auto JWE::from(const std::string_view input) -> std::optional { + JWE result; + if (parse(input, result)) { + return result; + } + + return std::nullopt; +} + +auto JWE::key_id() const noexcept -> std::optional { + const auto *member{this->header_.try_at("kid"sv, HASH_KID)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return std::string_view{member->to_string()}; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwe_decrypt.cc b/vendor/core/src/core/jose/jose_jwe_decrypt.cc new file mode 100644 index 00000000..14776158 --- /dev/null +++ b/vendor/core/src/core/jose/jose_jwe_decrypt.cc @@ -0,0 +1,171 @@ +#include + +#include +#include +#include + +#include "jose_jwe_internal.h" + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move + +namespace { +using namespace std::string_view_literals; + +constexpr auto HASH_EPK{sourcemeta::core::JSON::Object::hash("epk"sv)}; +constexpr auto HASH_APU{sourcemeta::core::JSON::Object::hash("apu"sv)}; +constexpr auto HASH_APV{sourcemeta::core::JSON::Object::hash("apv"sv)}; + +// Recover the content encryption key from an ECDH-ES agreement (RFC 7518 +// Section 4.6). Returns no value when the ephemeral key, the agreement, or the +// key derivation cannot be completed +auto ecdh_content_key(const sourcemeta::core::JWE &jwe, + const sourcemeta::core::JWKPrivate &key, + const sourcemeta::core::JWEAlgorithm algorithm, + const sourcemeta::core::JWEEncryption encryption, + const std::size_t content_key_bytes) + -> std::optional { + // The ephemeral public key travels in the protected header (RFC 7518 Section + // 4.6.1), parsed and validated on the curve through the JWK machinery + const auto *ephemeral{jwe.header().try_at("epk", HASH_EPK)}; + if (ephemeral == nullptr || !ephemeral->is_object()) { + return std::nullopt; + } + + const auto ephemeral_key{sourcemeta::core::JWK::from(*ephemeral)}; + if (!ephemeral_key.has_value() || + ephemeral_key.value().type() != + sourcemeta::core::JWK::Type::EllipticCurve || + ephemeral_key.value().public_key() == nullptr) { + return std::nullopt; + } + + // The agreement pairs the recipient private key with the ephemeral public + // key; a curve mismatch is rejected here + const auto shared{sourcemeta::core::ecdh_derive( + *key.private_key(), *ephemeral_key.value().public_key())}; + if (!shared.has_value()) { + return std::nullopt; + } + + std::string party_u; + std::string party_v; + if (!sourcemeta::core::jwe_read_party_info(jwe.header(), "apu", HASH_APU, + party_u) || + !sourcemeta::core::jwe_read_party_info(jwe.header(), "apv", HASH_APV, + party_v)) { + return std::nullopt; + } + + if (algorithm == sourcemeta::core::JWEAlgorithm::ECDH_ES) { + // Direct Key Agreement carries no wrapped key, so the Encrypted Key must be + // empty (RFC 7516 Section 5.2 step 10) + if (!jwe.encrypted_key().empty()) { + return std::nullopt; + } + + // The derived key is the CEK, over the "enc" value (RFC 7518 Section 4.6.2) + return sourcemeta::core::kdf_concat( + shared.value(), sourcemeta::core::jwe_encryption_name(encryption), + party_u, party_v, content_key_bytes); + } + + // Key Agreement with Key Wrapping: the derived key unwraps the CEK, over the + // "alg" value (RFC 7518 Section 4.6.2) + const auto key_wrap_bytes{ + sourcemeta::core::jwe_key_wrap_bytes(algorithm).value()}; + const auto key_encryption_key{sourcemeta::core::kdf_concat( + shared.value(), sourcemeta::core::jwe_algorithm_name(algorithm), party_u, + party_v, key_wrap_bytes)}; + if (!key_encryption_key.has_value()) { + return std::nullopt; + } + + return sourcemeta::core::aes_key_unwrap(key_encryption_key.value(), + jwe.encrypted_key()); +} + +} // namespace + +namespace sourcemeta::core { + +auto jwe_decrypt(const JWE &jwe, const JWKPrivate &key) + -> std::optional { + const auto algorithm{jwe.algorithm()}; + const auto encryption{jwe.encryption()}; + if (!algorithm.has_value() || !encryption.has_value()) { + return std::nullopt; + } + + const auto content_key_bytes{jwe_encryption_key_bytes(encryption.value())}; + + // Recover the content encryption key. The key-type binding is checked first, + // since it depends on the key rather than the ciphertext and blocks algorithm + // confusion (RFC 7516 Section 11.4) + std::optional content_key; + switch (algorithm.value()) { + case JWEAlgorithm::RSA_OAEP: + case JWEAlgorithm::RSA_OAEP_256: + if (key.type() != JWKPrivate::Type::RSA || key.private_key() == nullptr) { + return std::nullopt; + } + + content_key = rsa_oaep_decrypt(*key.private_key(), + jwe_rsa_oaep_hash(algorithm.value()), + jwe.encrypted_key()); + break; + case JWEAlgorithm::A128KW: + case JWEAlgorithm::A192KW: + case JWEAlgorithm::A256KW: { + const auto key_wrap_bytes{jwe_key_wrap_bytes(algorithm.value()).value()}; + if (key.type() != JWKPrivate::Type::Octet || + key.secret().size() != key_wrap_bytes) { + return std::nullopt; + } + + content_key = aes_key_unwrap(key.secret(), jwe.encrypted_key()); + break; + } + case JWEAlgorithm::DIR: + // The Encrypted Key must be empty and the shared secret is the CEK (RFC + // 7518 Section 4.5) + if (key.type() != JWKPrivate::Type::Octet || + key.secret().size() != content_key_bytes || + !jwe.encrypted_key().empty()) { + return std::nullopt; + } + + content_key = std::string{key.secret()}; + break; + case JWEAlgorithm::ECDH_ES: + case JWEAlgorithm::ECDH_ES_A128KW: + case JWEAlgorithm::ECDH_ES_A192KW: + case JWEAlgorithm::ECDH_ES_A256KW: + if (key.type() != JWKPrivate::Type::EllipticCurve || + key.private_key() == nullptr) { + return std::nullopt; + } + + content_key = ecdh_content_key(jwe, key, algorithm.value(), + encryption.value(), content_key_bytes); + break; + } + + // RFC 7516 Section 11.5: the recipient MUST NOT distinguish format, padding, + // and length errors of the encrypted key. Substituting a random CEK, which + // the section strongly recommends, for a missing or wrong-length one makes + // decryption always proceed to the authentication tag check and fail there, + // denying a key-unwrap oracle + std::string effective_key{ + content_key.has_value() && content_key.value().size() == content_key_bytes + ? std::move(content_key).value() + : random_bytes(content_key_bytes)}; + + return jwe_content_decrypt( + encryption.value(), effective_key, jwe.initialization_vector(), + jwe.protected_header(), jwe.ciphertext(), jwe.tag()); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwe_encrypt.cc b/vendor/core/src/core/jose/jose_jwe_encrypt.cc new file mode 100644 index 00000000..71ad1e61 --- /dev/null +++ b/vendor/core/src/core/jose/jose_jwe_encrypt.cc @@ -0,0 +1,261 @@ +#include + +#include +#include + +#include "jose_jwe_internal.h" +#include "jose_key.h" + +#include // std::optional, std::nullopt +#include // std::ostringstream +#include // std::string +#include // std::string_view +#include // std::move + +namespace { +using namespace std::string_view_literals; + +constexpr auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; +constexpr auto HASH_ENC{sourcemeta::core::JSON::Object::hash("enc"sv)}; +constexpr auto HASH_CRIT{sourcemeta::core::JSON::Object::hash("crit"sv)}; +constexpr auto HASH_ZIP{sourcemeta::core::JSON::Object::hash("zip"sv)}; +constexpr auto HASH_APU{sourcemeta::core::JSON::Object::hash("apu"sv)}; +constexpr auto HASH_APV{sourcemeta::core::JSON::Object::hash("apv"sv)}; +constexpr auto HASH_KTY{sourcemeta::core::JSON::Object::hash("kty"sv)}; +constexpr auto HASH_CRV{sourcemeta::core::JSON::Object::hash("crv"sv)}; +constexpr auto HASH_X{sourcemeta::core::JSON::Object::hash("x"sv)}; +constexpr auto HASH_Y{sourcemeta::core::JSON::Object::hash("y"sv)}; + +auto compact(const sourcemeta::core::JSON &document) -> std::string { + std::ostringstream stream; + sourcemeta::core::stringify(document, stream); + return stream.str(); +} + +} // namespace + +namespace sourcemeta::core { + +auto jwe_encrypt(const JSON &header, const std::string_view plaintext, + const JWK &key) -> std::optional { + if (!header.is_object() || !header.unique_keys()) { + return std::nullopt; + } + + // Critical header extensions are not understood, so an object carrying them + // is not emitted (RFC 7516 Section 4.1.13) + if (header.try_at("crit", HASH_CRIT) != nullptr) { + return std::nullopt; + } + + // Compression is not implemented, so a request to compress is refused rather + // than emitting an object that could not be decrypted here (RFC 7516 Section + // 4.1.3) + if (header.try_at("zip", HASH_ZIP) != nullptr) { + return std::nullopt; + } + + // The algorithm and content encryption are taken from the header (RFC 7516 + // Sections 4.1.1 and 4.1.2) + const auto *algorithm_member{header.try_at("alg", HASH_ALG)}; + const auto *encryption_member{header.try_at("enc", HASH_ENC)}; + if (algorithm_member == nullptr || !algorithm_member->is_string() || + encryption_member == nullptr || !encryption_member->is_string()) { + return std::nullopt; + } + + const auto algorithm{to_jwe_algorithm(algorithm_member->to_string())}; + const auto encryption{to_jwe_encryption(encryption_member->to_string())}; + if (!algorithm.has_value() || !encryption.has_value()) { + return std::nullopt; + } + + const auto content_key_bytes{jwe_encryption_key_bytes(encryption.value())}; + + // The protected header that is emitted and authenticated. Only ECDH-ES needs + // to augment it (with the ephemeral public key), so the deep copy is deferred + // to that branch and every other algorithm authenticates the caller header + // directly + const JSON *emitted_header{&header}; + JSON ecdh_header{nullptr}; + + std::string content_key; + std::string encrypted_key; + + switch (algorithm.value()) { + case JWEAlgorithm::RSA_OAEP: + case JWEAlgorithm::RSA_OAEP_256: { + if (key.type() != JWK::Type::RSA || key.public_key() == nullptr) { + return std::nullopt; + } + + content_key = random_bytes(content_key_bytes); + auto wrapped{rsa_oaep_encrypt(*key.public_key(), + jwe_rsa_oaep_hash(algorithm.value()), + content_key)}; + if (!wrapped.has_value()) { + return std::nullopt; + } + + encrypted_key = std::move(wrapped).value(); + break; + } + case JWEAlgorithm::A128KW: + case JWEAlgorithm::A192KW: + case JWEAlgorithm::A256KW: { + // The shared secret is the key-encryption key, of the exact AES Key Wrap + // size (RFC 7518 Section 4.4) + const auto key_wrap_bytes{jwe_key_wrap_bytes(algorithm.value()).value()}; + if (key.type() != JWK::Type::Octet || + key.secret().size() != key_wrap_bytes) { + return std::nullopt; + } + + content_key = random_bytes(content_key_bytes); + auto wrapped{aes_key_wrap(key.secret(), content_key)}; + if (!wrapped.has_value()) { + return std::nullopt; + } + + encrypted_key = std::move(wrapped).value(); + break; + } + case JWEAlgorithm::DIR: { + // The shared secret is used directly as the CEK (RFC 7518 Section 4.5) + if (key.type() != JWK::Type::Octet || + key.secret().size() != content_key_bytes) { + return std::nullopt; + } + + content_key = std::string{key.secret()}; + break; + } + case JWEAlgorithm::ECDH_ES: + case JWEAlgorithm::ECDH_ES_A128KW: + case JWEAlgorithm::ECDH_ES_A192KW: + case JWEAlgorithm::ECDH_ES_A256KW: { + if (key.type() != JWK::Type::EllipticCurve || + key.public_key() == nullptr) { + return std::nullopt; + } + + const auto recipient{ec_public_components(*key.public_key())}; + if (!recipient.has_value()) { + return std::nullopt; + } + + // A fresh ephemeral key on the recipient curve (RFC 7518 Section 4.6.1) + const auto ephemeral{generate_ec_private_key(recipient.value().curve)}; + if (!ephemeral.has_value()) { + return std::nullopt; + } + + const auto ephemeral_public{derive_public_key(ephemeral.value())}; + if (!ephemeral_public.has_value()) { + return std::nullopt; + } + + const auto ephemeral_components{ + ec_public_components(ephemeral_public.value())}; + if (!ephemeral_components.has_value()) { + return std::nullopt; + } + + const auto shared{ecdh_derive(ephemeral.value(), *key.public_key())}; + if (!shared.has_value()) { + return std::nullopt; + } + + std::string party_u; + std::string party_v; + if (!jwe_read_party_info(header, "apu", HASH_APU, party_u) || + !jwe_read_party_info(header, "apv", HASH_APV, party_v)) { + return std::nullopt; + } + + if (algorithm.value() == JWEAlgorithm::ECDH_ES) { + // Direct Key Agreement: AlgorithmID is the "enc" value and the derived + // key is the CEK (RFC 7518 Section 4.6.2) + auto derived{kdf_concat(shared.value(), + jwe_encryption_name(encryption.value()), + party_u, party_v, content_key_bytes)}; + if (!derived.has_value()) { + return std::nullopt; + } + + content_key = std::move(derived).value(); + } else { + // Key Agreement with Key Wrapping: AlgorithmID is the "alg" value and + // the derived key wraps a fresh CEK (RFC 7518 Section 4.6.2) + const auto key_wrap_bytes{ + jwe_key_wrap_bytes(algorithm.value()).value()}; + auto key_encryption_key{ + kdf_concat(shared.value(), jwe_algorithm_name(algorithm.value()), + party_u, party_v, key_wrap_bytes)}; + if (!key_encryption_key.has_value()) { + return std::nullopt; + } + + content_key = random_bytes(content_key_bytes); + auto wrapped{aes_key_wrap(key_encryption_key.value(), content_key)}; + if (!wrapped.has_value()) { + return std::nullopt; + } + + encrypted_key = std::move(wrapped).value(); + } + + // The ephemeral public key is a fresh object with known-unique members, + // so each is inserted with its precomputed hash and no duplicate check + auto ephemeral_key{JSON::make_object()}; + ephemeral_key.assign_if_nonempty("kty", HASH_KTY, "EC"); + ephemeral_key.assign_if_nonempty( + "crv", HASH_CRV, elliptic_curve_to_jwk(recipient.value().curve)); + ephemeral_key.assign_if_nonempty( + "x", HASH_X, base64url_encode(ephemeral_components.value().x)); + ephemeral_key.assign_if_nonempty( + "y", HASH_Y, base64url_encode(ephemeral_components.value().y)); + ecdh_header = header; + ecdh_header.assign("epk", std::move(ephemeral_key)); + emitted_header = &ecdh_header; + break; + } + } + + // The Additional Authenticated Data is the encoded protected header, which is + // also the first compact segment (RFC 7516 Section 5.1 steps 13 and 14) + const auto encoded_header{base64url_encode(compact(*emitted_header))}; + + const auto initialization_vector{ + random_bytes(jwe_encryption_iv_bytes(encryption.value()))}; + const auto content{jwe_content_encrypt(encryption.value(), content_key, + initialization_vector, encoded_header, + plaintext)}; + if (!content.has_value()) { + return std::nullopt; + } + + // Assemble the five compact segments (RFC 7516 Section 7.1), reserving the + // exact length so the growing result never reallocates + const auto encoded_key{base64url_encode(encrypted_key)}; + const auto encoded_iv{base64url_encode(initialization_vector)}; + const auto encoded_ciphertext{base64url_encode(content.value().ciphertext())}; + const auto encoded_tag{base64url_encode(content.value().tag())}; + + std::string result; + result.reserve(encoded_header.size() + encoded_key.size() + + encoded_iv.size() + encoded_ciphertext.size() + + encoded_tag.size() + 4); + result.append(encoded_header); + result.push_back('.'); + result.append(encoded_key); + result.push_back('.'); + result.append(encoded_iv); + result.push_back('.'); + result.append(encoded_ciphertext); + result.push_back('.'); + result.append(encoded_tag); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwe_internal.h b/vendor/core/src/core/jose/jose_jwe_internal.h new file mode 100644 index 00000000..d15faf34 --- /dev/null +++ b/vendor/core/src/core/jose/jose_jwe_internal.h @@ -0,0 +1,192 @@ +#ifndef SOURCEMETA_CORE_JOSE_JWE_INTERNAL_H_ +#define SOURCEMETA_CORE_JOSE_JWE_INTERNAL_H_ + +// The content encryption and key-management dispatch shared by the JWE +// encryption and decryption paths (RFC 7516, RFC 7518) + +#include +#include + +#include + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::move, std::unreachable + +namespace sourcemeta::core { + +// The agreement party information is carried base64url-encoded in the header, +// but the Concat KDF consumes the raw octets (RFC 7518 Section 4.6.2). An +// absent parameter is the empty octet string; a present but malformed one is an +// error +inline auto jwe_read_party_info(const JSON &header, const JSON::StringView name, + const JSON::Object::hash_type hash, + std::string &output) -> bool { + const auto *member{header.try_at(name, hash)}; + if (member == nullptr) { + output.clear(); + return true; + } + + if (!member->is_string()) { + return false; + } + + auto decoded{base64url_decode(member->to_string())}; + if (!decoded.has_value()) { + return false; + } + + output = std::move(decoded).value(); + return true; +} + +// The output of content encryption, holding the ciphertext followed by the +// authentication tag in one buffer as the AEAD primitives produce it, so the +// compact serialization can slice out each segment without a copy (RFC 7516 +// Section 7.1) +struct JWEContent { + std::string data; + std::string::size_type tag_bytes; + + [[nodiscard]] auto ciphertext() const -> std::string_view { + return std::string_view{this->data}.substr(0, this->data.size() - + this->tag_bytes); + } + + [[nodiscard]] auto tag() const -> std::string_view { + return std::string_view{this->data}.substr(this->data.size() - + this->tag_bytes); + } +}; + +// AES GCM takes a 96-bit initialization vector (RFC 7518 Section 5.3) and AES +// CBC a 128-bit one (RFC 7518 Section 5.2.2.1) +inline auto jwe_encryption_iv_bytes(const JWEEncryption encryption) noexcept + -> std::size_t { + switch (encryption) { + case JWEEncryption::A128GCM: + case JWEEncryption::A192GCM: + case JWEEncryption::A256GCM: + return 12; + case JWEEncryption::A128CBC_HS256: + case JWEEncryption::A192CBC_HS384: + case JWEEncryption::A256CBC_HS512: + return 16; + } + + std::unreachable(); +} + +// The AES Key Wrap key length for the wrapping algorithms, both the standalone +// A*KW and the key-wrapping variant of ECDH-ES (RFC 7518 Sections 4.4 and 4.6) +inline auto jwe_key_wrap_bytes(const JWEAlgorithm algorithm) noexcept + -> std::optional { + switch (algorithm) { + case JWEAlgorithm::A128KW: + case JWEAlgorithm::ECDH_ES_A128KW: + return 16; + case JWEAlgorithm::A192KW: + case JWEAlgorithm::ECDH_ES_A192KW: + return 24; + case JWEAlgorithm::A256KW: + case JWEAlgorithm::ECDH_ES_A256KW: + return 32; + case JWEAlgorithm::RSA_OAEP: + case JWEAlgorithm::RSA_OAEP_256: + case JWEAlgorithm::ECDH_ES: + case JWEAlgorithm::DIR: + return std::nullopt; + } + + std::unreachable(); +} + +// The OAEP hash each RSA key-management algorithm uses (RFC 7518 Section 4.3) +inline auto jwe_rsa_oaep_hash(const JWEAlgorithm algorithm) noexcept + -> RSAOAEPHash { + switch (algorithm) { + case JWEAlgorithm::RSA_OAEP: + return RSAOAEPHash::SHA1; + case JWEAlgorithm::RSA_OAEP_256: + return RSAOAEPHash::SHA256; + case JWEAlgorithm::ECDH_ES: + case JWEAlgorithm::ECDH_ES_A128KW: + case JWEAlgorithm::ECDH_ES_A192KW: + case JWEAlgorithm::ECDH_ES_A256KW: + case JWEAlgorithm::A128KW: + case JWEAlgorithm::A192KW: + case JWEAlgorithm::A256KW: + case JWEAlgorithm::DIR: + break; + } + + std::unreachable(); +} + +// Seal the plaintext with the content encryption key, dispatching to the AEAD +// scheme the "enc" algorithm names (RFC 7518 Sections 5.2 and 5.3) +inline auto jwe_content_encrypt(const JWEEncryption encryption, + const std::string_view key, + const std::string_view iv, + const std::string_view associated_data, + const std::string_view plaintext) + -> std::optional { + switch (encryption) { + case JWEEncryption::A128GCM: + case JWEEncryption::A192GCM: + case JWEEncryption::A256GCM: { + auto result{aes_gcm_encrypt(key, iv, associated_data, plaintext)}; + if (!result.has_value()) { + return std::nullopt; + } + + // The GCM tag is always 16 octets (RFC 7518 Section 5.3) + return JWEContent{.data = std::move(result).value().data, + .tag_bytes = 16}; + } + case JWEEncryption::A128CBC_HS256: + case JWEEncryption::A192CBC_HS384: + case JWEEncryption::A256CBC_HS512: { + auto result{aes_cbc_hmac_encrypt(key, iv, associated_data, plaintext)}; + if (!result.has_value()) { + return std::nullopt; + } + + const auto tag_bytes{result.value().tag_length}; + return JWEContent{.data = std::move(result).value().data, + .tag_bytes = tag_bytes}; + } + } + + std::unreachable(); +} + +// Open the ciphertext with the content encryption key, verifying the tag before +// returning the plaintext (RFC 7518 Sections 5.2 and 5.3) +inline auto jwe_content_decrypt(const JWEEncryption encryption, + const std::string_view key, + const std::string_view iv, + const std::string_view associated_data, + const std::string_view ciphertext, + const std::string_view tag) + -> std::optional { + switch (encryption) { + case JWEEncryption::A128GCM: + case JWEEncryption::A192GCM: + case JWEEncryption::A256GCM: + return aes_gcm_decrypt(key, iv, associated_data, ciphertext, tag); + case JWEEncryption::A128CBC_HS256: + case JWEEncryption::A192CBC_HS384: + case JWEEncryption::A256CBC_HS512: + return aes_cbc_hmac_decrypt(key, iv, associated_data, ciphertext, tag); + } + + std::unreachable(); +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/jose/jose_jwk.cc b/vendor/core/src/core/jose/jose_jwk.cc index de344187..faf35b36 100644 --- a/vendor/core/src/core/jose/jose_jwk.cc +++ b/vendor/core/src/core/jose/jose_jwk.cc @@ -11,22 +11,22 @@ namespace { using namespace std::string_view_literals; -const auto HASH_KTY{sourcemeta::core::JSON::Object::hash("kty"sv)}; -const auto HASH_N{sourcemeta::core::JSON::Object::hash("n"sv)}; -const auto HASH_E{sourcemeta::core::JSON::Object::hash("e"sv)}; -const auto HASH_CRV{sourcemeta::core::JSON::Object::hash("crv"sv)}; -const auto HASH_X{sourcemeta::core::JSON::Object::hash("x"sv)}; -const auto HASH_Y{sourcemeta::core::JSON::Object::hash("y"sv)}; -const auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; -const auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; -const auto HASH_D{sourcemeta::core::JSON::Object::hash("d"sv)}; -const auto HASH_P{sourcemeta::core::JSON::Object::hash("p"sv)}; -const auto HASH_Q{sourcemeta::core::JSON::Object::hash("q"sv)}; -const auto HASH_DP{sourcemeta::core::JSON::Object::hash("dp"sv)}; -const auto HASH_DQ{sourcemeta::core::JSON::Object::hash("dq"sv)}; -const auto HASH_QI{sourcemeta::core::JSON::Object::hash("qi"sv)}; -const auto HASH_OTH{sourcemeta::core::JSON::Object::hash("oth"sv)}; -const auto HASH_K{sourcemeta::core::JSON::Object::hash("k"sv)}; +constexpr auto HASH_KTY{sourcemeta::core::JSON::Object::hash("kty"sv)}; +constexpr auto HASH_N{sourcemeta::core::JSON::Object::hash("n"sv)}; +constexpr auto HASH_E{sourcemeta::core::JSON::Object::hash("e"sv)}; +constexpr auto HASH_CRV{sourcemeta::core::JSON::Object::hash("crv"sv)}; +constexpr auto HASH_X{sourcemeta::core::JSON::Object::hash("x"sv)}; +constexpr auto HASH_Y{sourcemeta::core::JSON::Object::hash("y"sv)}; +constexpr auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; +constexpr auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; +constexpr auto HASH_D{sourcemeta::core::JSON::Object::hash("d"sv)}; +constexpr auto HASH_P{sourcemeta::core::JSON::Object::hash("p"sv)}; +constexpr auto HASH_Q{sourcemeta::core::JSON::Object::hash("q"sv)}; +constexpr auto HASH_DP{sourcemeta::core::JSON::Object::hash("dp"sv)}; +constexpr auto HASH_DQ{sourcemeta::core::JSON::Object::hash("dq"sv)}; +constexpr auto HASH_QI{sourcemeta::core::JSON::Object::hash("qi"sv)}; +constexpr auto HASH_OTH{sourcemeta::core::JSON::Object::hash("oth"sv)}; +constexpr auto HASH_K{sourcemeta::core::JSON::Object::hash("k"sv)}; auto to_jwk_kind(const sourcemeta::core::JWK::Type type) noexcept -> sourcemeta::core::JWKKind { @@ -232,4 +232,11 @@ auto JWK::from(const JSON &value) -> std::optional { auto JWK::from(JSON &&value) -> std::optional { return from(value); } +auto JWK::from_octets(const std::string_view secret) -> JWK { + JWK result; + result.type_ = Type::Octet; + result.secret_ = secret; + return result; +} + } // namespace sourcemeta::core diff --git a/vendor/core/src/core/jose/jose_jwk_private.cc b/vendor/core/src/core/jose/jose_jwk_private.cc index a164e6f4..2ce7b63e 100644 --- a/vendor/core/src/core/jose/jose_jwk_private.cc +++ b/vendor/core/src/core/jose/jose_jwk_private.cc @@ -12,22 +12,22 @@ namespace { using namespace std::string_view_literals; -const auto HASH_KTY{sourcemeta::core::JSON::Object::hash("kty"sv)}; -const auto HASH_N{sourcemeta::core::JSON::Object::hash("n"sv)}; -const auto HASH_E{sourcemeta::core::JSON::Object::hash("e"sv)}; -const auto HASH_D{sourcemeta::core::JSON::Object::hash("d"sv)}; -const auto HASH_P{sourcemeta::core::JSON::Object::hash("p"sv)}; -const auto HASH_Q{sourcemeta::core::JSON::Object::hash("q"sv)}; -const auto HASH_DP{sourcemeta::core::JSON::Object::hash("dp"sv)}; -const auto HASH_DQ{sourcemeta::core::JSON::Object::hash("dq"sv)}; -const auto HASH_QI{sourcemeta::core::JSON::Object::hash("qi"sv)}; -const auto HASH_OTH{sourcemeta::core::JSON::Object::hash("oth"sv)}; -const auto HASH_K{sourcemeta::core::JSON::Object::hash("k"sv)}; -const auto HASH_CRV{sourcemeta::core::JSON::Object::hash("crv"sv)}; -const auto HASH_X{sourcemeta::core::JSON::Object::hash("x"sv)}; -const auto HASH_Y{sourcemeta::core::JSON::Object::hash("y"sv)}; -const auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; -const auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; +constexpr auto HASH_KTY{sourcemeta::core::JSON::Object::hash("kty"sv)}; +constexpr auto HASH_N{sourcemeta::core::JSON::Object::hash("n"sv)}; +constexpr auto HASH_E{sourcemeta::core::JSON::Object::hash("e"sv)}; +constexpr auto HASH_D{sourcemeta::core::JSON::Object::hash("d"sv)}; +constexpr auto HASH_P{sourcemeta::core::JSON::Object::hash("p"sv)}; +constexpr auto HASH_Q{sourcemeta::core::JSON::Object::hash("q"sv)}; +constexpr auto HASH_DP{sourcemeta::core::JSON::Object::hash("dp"sv)}; +constexpr auto HASH_DQ{sourcemeta::core::JSON::Object::hash("dq"sv)}; +constexpr auto HASH_QI{sourcemeta::core::JSON::Object::hash("qi"sv)}; +constexpr auto HASH_OTH{sourcemeta::core::JSON::Object::hash("oth"sv)}; +constexpr auto HASH_K{sourcemeta::core::JSON::Object::hash("k"sv)}; +constexpr auto HASH_CRV{sourcemeta::core::JSON::Object::hash("crv"sv)}; +constexpr auto HASH_X{sourcemeta::core::JSON::Object::hash("x"sv)}; +constexpr auto HASH_Y{sourcemeta::core::JSON::Object::hash("y"sv)}; +constexpr auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; +constexpr auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; // A required string member decoded from base64url, empty when the member is // missing, of the wrong type, malformed, or decodes to nothing @@ -278,6 +278,13 @@ auto JWKPrivate::from(JSON &&value) -> std::optional { return from(value); } +auto JWKPrivate::from_octets(const std::string_view secret) -> JWKPrivate { + JWKPrivate result; + result.type_ = Type::Octet; + result.secret_ = secret; + return result; +} + auto JWKPrivate::from_pem(const std::string_view pem) -> std::optional { auto parsed_key{make_private_key(pem)}; diff --git a/vendor/core/src/core/jose/jose_jwks.cc b/vendor/core/src/core/jose/jose_jwks.cc index 83aa68f2..89213a75 100644 --- a/vendor/core/src/core/jose/jose_jwks.cc +++ b/vendor/core/src/core/jose/jose_jwks.cc @@ -7,7 +7,7 @@ namespace { using namespace std::string_view_literals; -const auto HASH_KEYS{sourcemeta::core::JSON::Object::hash("keys"sv)}; +constexpr auto HASH_KEYS{sourcemeta::core::JSON::Object::hash("keys"sv)}; } // namespace diff --git a/vendor/core/src/core/jose/jose_jwks_provider.cc b/vendor/core/src/core/jose/jose_jwks_provider.cc index d9c20ed8..d2293c3e 100644 --- a/vendor/core/src/core/jose/jose_jwks_provider.cc +++ b/vendor/core/src/core/jose/jose_jwks_provider.cc @@ -129,8 +129,33 @@ auto JWKSProvider::verify( const std::optional expected_subject, const std::optional expected_type) -> std::optional { - const auto now{this->clock_()}; + return this->verify_at(token, allowed_algorithms, expected_issuer, + expected_audience, expected_subject, expected_type, + this->clock_()); +} +auto JWKSProvider::verify( + const JWT &token, const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject, + const std::optional expected_type, + std::chrono::system_clock::time_point &resolved_now) + -> std::optional { + resolved_now = this->clock_(); + return this->verify_at(token, allowed_algorithms, expected_issuer, + expected_audience, expected_subject, expected_type, + resolved_now); +} + +auto JWKSProvider::verify_at( + const JWT &token, const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject, + const std::optional expected_type, + const std::chrono::system_clock::time_point now) + -> std::optional { std::shared_ptr snapshot; { const std::scoped_lock lock{this->mutex_}; diff --git a/vendor/core/src/core/jose/jose_jwt.cc b/vendor/core/src/core/jose/jose_jwt.cc index 48496b1c..31d50519 100644 --- a/vendor/core/src/core/jose/jose_jwt.cc +++ b/vendor/core/src/core/jose/jose_jwt.cc @@ -4,27 +4,26 @@ #include #include -#include // std::chrono::duration, std::chrono::system_clock -#include // std::optional, std::nullopt -#include // std::out_of_range -#include // std::string_view -#include // std::unordered_set -#include // std::move +#include // std::chrono::duration, std::chrono::system_clock +#include // std::optional, std::nullopt +#include // std::out_of_range +#include // std::string_view +#include // std::move namespace { using namespace std::string_view_literals; -const auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; -const auto HASH_CRIT{sourcemeta::core::JSON::Object::hash("crit"sv)}; -const auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; -const auto HASH_TYP{sourcemeta::core::JSON::Object::hash("typ"sv)}; -const auto HASH_ISS{sourcemeta::core::JSON::Object::hash("iss"sv)}; -const auto HASH_SUB{sourcemeta::core::JSON::Object::hash("sub"sv)}; -const auto HASH_AUD{sourcemeta::core::JSON::Object::hash("aud"sv)}; -const auto HASH_EXP{sourcemeta::core::JSON::Object::hash("exp"sv)}; -const auto HASH_NBF{sourcemeta::core::JSON::Object::hash("nbf"sv)}; -const auto HASH_IAT{sourcemeta::core::JSON::Object::hash("iat"sv)}; -const auto HASH_JTI{sourcemeta::core::JSON::Object::hash("jti"sv)}; +constexpr auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; +constexpr auto HASH_CRIT{sourcemeta::core::JSON::Object::hash("crit"sv)}; +constexpr auto HASH_KID{sourcemeta::core::JSON::Object::hash("kid"sv)}; +constexpr auto HASH_TYP{sourcemeta::core::JSON::Object::hash("typ"sv)}; +constexpr auto HASH_ISS{sourcemeta::core::JSON::Object::hash("iss"sv)}; +constexpr auto HASH_SUB{sourcemeta::core::JSON::Object::hash("sub"sv)}; +constexpr auto HASH_AUD{sourcemeta::core::JSON::Object::hash("aud"sv)}; +constexpr auto HASH_EXP{sourcemeta::core::JSON::Object::hash("exp"sv)}; +constexpr auto HASH_NBF{sourcemeta::core::JSON::Object::hash("nbf"sv)}; +constexpr auto HASH_IAT{sourcemeta::core::JSON::Object::hash("iat"sv)}; +constexpr auto HASH_JTI{sourcemeta::core::JSON::Object::hash("jti"sv)}; auto string_claim(const sourcemeta::core::JSON &object, const sourcemeta::core::JSON::StringView name, @@ -38,17 +37,20 @@ auto string_claim(const sourcemeta::core::JSON &object, return std::string_view{member->to_string()}; } -// The JSON layer preserves repeated members rather than collapsing them, so -// uniqueness is checked here -auto has_unique_members(const sourcemeta::core::JSON &object) -> bool { - std::unordered_set names; - for (const auto &entry : object.as_object()) { - if (!names.emplace(entry.first).second) { - return false; - } +// RFC 7519 Section 5.1: a "typ" value not containing a slash is treated as if +// "application/" were prepended, so the compact and prefixed forms compare +// equal, and RFC 7515 Section 4.1.9 makes the media type case-insensitive +auto strip_application_prefix(const std::string_view value) + -> std::string_view { + constexpr std::string_view prefix{"application/"}; + if (value.size() > prefix.size() && + sourcemeta::core::equals_ignore_case(value.substr(0, prefix.size()), + prefix) && + value.find('/', prefix.size()) == std::string_view::npos) { + return value.substr(prefix.size()); } - return true; + return value; } auto date_claim(const sourcemeta::core::JSON &object, @@ -117,8 +119,8 @@ auto JWT::parse(const std::string_view input, JWT &result) -> bool { // RFC 7515 Section 4: the header parameter names must be unique, and RFC 7519 // Section 4: the claim names must be unique, so a duplicate in either the // header or the payload is rejected (RFC 8725 Section 2.4) - if (!has_unique_members(header_json.value()) || - !has_unique_members(payload_json.value())) { + if (!header_json.value().unique_keys() || + !payload_json.value().unique_keys()) { return false; } @@ -167,6 +169,13 @@ auto JWT::type() const noexcept -> std::optional { return string_claim(this->header_, "typ", HASH_TYP); } +auto JWT::has_type(const std::string_view media_type) const -> bool { + const auto value{this->type()}; + return value.has_value() && + equals_ignore_case(strip_application_prefix(value.value()), + strip_application_prefix(media_type)); +} + auto JWT::issuer() const noexcept -> std::optional { return string_claim(this->payload_, "iss", HASH_ISS); } diff --git a/vendor/core/src/core/jose/jose_jwt_check_claims.cc b/vendor/core/src/core/jose/jose_jwt_check_claims.cc index 38ca56c1..28f702f1 100644 --- a/vendor/core/src/core/jose/jose_jwt_check_claims.cc +++ b/vendor/core/src/core/jose/jose_jwt_check_claims.cc @@ -5,12 +5,48 @@ #include // std::optional, std::nullopt #include // std::string_view +namespace { + +using Clock = std::chrono::system_clock; + +// The skew is applied to the server clock rather than the attacker-controlled +// claim, so a NumericDate near the representable bound cannot overflow the +// comparison (the two forms are otherwise equivalent). The skew is also +// clamped to a non-negative, bounded grace period and the shift saturates, so +// an extreme caller-supplied clock or skew stays well-defined too +auto skew_ticks(const std::chrono::seconds skew) -> Clock::duration { + return std::chrono::duration_cast(std::clamp( + skew, std::chrono::seconds::zero(), std::chrono::seconds{31556952})); +} + +auto shift_backward(const Clock::time_point now, + const std::chrono::seconds skew) -> Clock::time_point { + const auto ticks{skew_ticks(skew)}; + if (now.time_since_epoch() < Clock::duration::min() + ticks) { + return Clock::time_point{Clock::duration::min()}; + } + + return now - ticks; +} + +auto shift_forward(const Clock::time_point now, const std::chrono::seconds skew) + -> Clock::time_point { + const auto ticks{skew_ticks(skew)}; + if (now.time_since_epoch() > Clock::duration::max() - ticks) { + return Clock::time_point{Clock::duration::max()}; + } + + return now + ticks; +} + +} // namespace + namespace sourcemeta::core { auto jwt_check_claims(const JWT &token, const std::string_view expected_issuer, const std::string_view expected_audience, const std::chrono::system_clock::time_point now, - const std::chrono::seconds clock_skew, + const JWTClockSkew clock_skew, const std::optional expected_subject) -> std::optional { // The issuer must be present and match the expected value (RFC 7519 Section @@ -36,28 +72,12 @@ auto jwt_check_claims(const JWT &token, const std::string_view expected_issuer, return JWTClaimError::Audience; } - // The skew is applied to the server clock rather than the attacker-controlled - // claim, so a NumericDate near the representable bound cannot overflow the - // comparison (the two forms are otherwise equivalent). The skew is also - // clamped to a non-negative, bounded grace period and the shift saturates, so - // an extreme caller-supplied clock or skew stays well-defined too - using Clock = std::chrono::system_clock; - const auto skew{std::clamp(clock_skew, std::chrono::seconds::zero(), - std::chrono::seconds{31556952})}; - const auto skew_ticks{std::chrono::duration_cast(skew)}; - const auto since_epoch{now.time_since_epoch()}; - const auto now_minus_skew{(since_epoch < Clock::duration::min() + skew_ticks) - ? Clock::time_point{Clock::duration::min()} - : now - skew_ticks}; - const auto now_plus_skew{(since_epoch > Clock::duration::max() - skew_ticks) - ? Clock::time_point{Clock::duration::max()} - : now + skew_ticks}; - // A bearer credential without an expiry is not acceptable for authentication, // so the claim is required here even though RFC 7519 makes it optional in // general (RFC 9068 Section 2.2) const auto expires_at{token.expires_at()}; - if (!expires_at.has_value() || now_minus_skew >= expires_at.value()) { + if (!expires_at.has_value() || + shift_backward(now, clock_skew.expiration) >= expires_at.value()) { return JWTClaimError::Expiration; } @@ -67,7 +87,8 @@ auto jwt_check_claims(const JWT &token, const std::string_view expected_issuer, const auto &payload{token.payload()}; if (payload.defines("nbf")) { const auto not_before{token.not_before()}; - if (!not_before.has_value() || now_plus_skew < not_before.value()) { + if (!not_before.has_value() || + shift_forward(now, clock_skew.not_before) < not_before.value()) { return JWTClaimError::NotBefore; } } @@ -76,7 +97,8 @@ auto jwt_check_claims(const JWT &token, const std::string_view expected_issuer, // in the future (RFC 7519 Section 4.1.6) if (payload.defines("iat")) { const auto issued_at{token.issued_at()}; - if (!issued_at.has_value() || now_plus_skew < issued_at.value()) { + if (!issued_at.has_value() || + shift_forward(now, clock_skew.issued_at) < issued_at.value()) { return JWTClaimError::IssuedAt; } } diff --git a/vendor/core/src/core/jose/jose_jwt_sign.cc b/vendor/core/src/core/jose/jose_jwt_sign.cc index a30dfa9a..960f2f08 100644 --- a/vendor/core/src/core/jose/jose_jwt_sign.cc +++ b/vendor/core/src/core/jose/jose_jwt_sign.cc @@ -11,8 +11,8 @@ namespace { using namespace std::string_view_literals; -const auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; -const auto HASH_CRIT{sourcemeta::core::JSON::Object::hash("crit"sv)}; +constexpr auto HASH_ALG{sourcemeta::core::JSON::Object::hash("alg"sv)}; +constexpr auto HASH_CRIT{sourcemeta::core::JSON::Object::hash("crit"sv)}; auto compact(const sourcemeta::core::JSON &document) -> std::string { std::ostringstream stream; diff --git a/vendor/core/src/core/jose/jose_jwt_verify.cc b/vendor/core/src/core/jose/jose_jwt_verify.cc index 1590999b..448b07f2 100644 --- a/vendor/core/src/core/jose/jose_jwt_verify.cc +++ b/vendor/core/src/core/jose/jose_jwt_verify.cc @@ -1,7 +1,5 @@ #include -#include - #include // std::ranges::find #include // std::chrono::seconds, std::chrono::system_clock #include // std::optional, std::nullopt @@ -11,24 +9,6 @@ namespace { -// RFC 7519 Section 5.1: "a recipient using the media type value MUST treat it -// as if `application/` were prepended to any `typ` value not containing a `/`". -// Removing an explicit `application/` prefix, when nothing else in the value -// contains a slash, lets the compact `at+jwt` form and the full -// `application/at+jwt` form compare equal -auto strip_application_prefix(const std::string_view value) - -> std::string_view { - constexpr std::string_view prefix{"application/"}; - if (value.size() > prefix.size() && - sourcemeta::core::equals_ignore_case(value.substr(0, prefix.size()), - prefix) && - value.find('/', prefix.size()) == std::string_view::npos) { - return value.substr(prefix.size()); - } - - return value; -} - auto to_verification_error(const sourcemeta::core::JWTClaimError error) -> sourcemeta::core::JWTVerificationError { using sourcemeta::core::JWTClaimError; @@ -60,7 +40,7 @@ auto jwt_verify(const JWT &token, const JWKS &keys, const std::string_view expected_issuer, const std::string_view expected_audience, const std::chrono::system_clock::time_point now, - const std::chrono::seconds clock_skew, + const JWTClockSkew clock_skew, const std::optional expected_subject, const std::optional expected_type) -> std::optional { @@ -104,13 +84,8 @@ auto jwt_verify(const JWT &token, const JWKS &keys, // The type is a header concern checked only on an authenticated token, which // is how the access token profile is enforced (RFC 9068 Section 2.1) - if (expected_type.has_value()) { - const auto type{token.type()}; - if (!type.has_value() || - !equals_ignore_case(strip_application_prefix(type.value()), - strip_application_prefix(expected_type.value()))) { - return JWTVerificationError::Type; - } + if (expected_type.has_value() && !token.has_type(expected_type.value())) { + return JWTVerificationError::Type; } const auto claim_error{jwt_check_claims(token, expected_issuer, diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json_hash.h b/vendor/core/src/core/json/include/sourcemeta/core/json_hash.h index e18efca9..b3e116ed 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json_hash.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json_hash.h @@ -3,7 +3,9 @@ #include +#include // std::endian #include // assert +#include // std::size_t #include // std::memcpy #include // std::reference_wrapper @@ -42,11 +44,31 @@ template struct PropertyHashJSON { /// Compute a perfect hash from raw data [[nodiscard]] - inline auto perfect(const char *data, const std::size_t size) const noexcept - -> hash_type { + constexpr auto perfect(const char *data, + const std::size_t size) const noexcept -> hash_type { hash_type result; assert(size > 0); - std::memcpy(reinterpret_cast(&result) + 1, data, size); + if consteval { + // A constant evaluation cannot memcpy through a reinterpret_cast, so pack + // the bytes arithmetically instead. This reproduces the runtime memcpy on + // a little-endian target, where the packed bytes read back as the same + // integer, which the static assertion enforces so the two can never + // silently diverge + static_assert(std::endian::native == std::endian::little); + for (std::size_t index = 0; index < size; index += 1) { + const auto byte{static_cast( + static_cast(data[index]))}; + const auto position{index + 1}; + if (position < 16) { + result.a |= byte << static_cast(8 * position); + } else { + result.b |= byte << static_cast(8 * (position - 16)); + } + } + } else { + std::memcpy(reinterpret_cast(&result) + 1, data, size); + } + return result; } @@ -54,7 +76,7 @@ template struct PropertyHashJSON { // std::string to std::string_view, so we provide separate overloads with // duplicated logic instead of unifying on a single parameter type - inline auto operator()(const T &value) const noexcept -> hash_type { + constexpr auto operator()(const T &value) const noexcept -> hash_type { const auto size{value.size()}; switch (size) { case 0: @@ -126,17 +148,18 @@ template struct PropertyHashJSON { // string length, and to exploit the fact that most JSON objects don't // have a lot of entries, so hash collision is not as common auto hash = this->perfect(value.data(), 31); - hash.a |= - 1 + (size + static_cast(value.front()) + - static_cast(value.back())) % - // Make sure the property hash can never exceed 8 bits - 255; + hash.a |= 1 + (static_cast(size) + + static_cast(value.front()) + + static_cast(value.back())) % + // Make sure the property hash can never exceed 8 bits + 255; return hash; } } - inline auto operator()(const char *data, - const std::size_t size) const noexcept -> hash_type { + constexpr auto operator()(const char *data, + const std::size_t size) const noexcept + -> hash_type { switch (size) { case 0: return {}; @@ -207,7 +230,8 @@ template struct PropertyHashJSON { // string length, and to exploit the fact that most JSON objects don't // have a lot of entries, so hash collision is not as common auto hash = this->perfect(data, 31); - hash.a |= 1 + (size + static_cast(data[0]) + + hash.a |= 1 + (static_cast(size) + + static_cast(data[0]) + static_cast(data[size - 1])) % // Make sure the property hash can never exceed 8 bits 255; @@ -217,7 +241,7 @@ template struct PropertyHashJSON { /// Check whether the given hash is a perfect hash [[nodiscard]] - inline auto is_perfect(const hash_type &hash) const noexcept -> bool { + constexpr auto is_perfect(const hash_type &hash) const noexcept -> bool { // If there is anything written past the first byte, // then it is a perfect hash return (hash.a & 255) == 0; diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json_object.h b/vendor/core/src/core/json/include/sourcemeta/core/json_object.h index 49a8b991..559536e3 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json_object.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json_object.h @@ -178,20 +178,21 @@ template class JSONObject { // the `Key`-accepting overload as before. /// Compute a hash for a key - [[nodiscard]] static inline auto hash(const Key &key) noexcept -> hash_type { + [[nodiscard]] static constexpr auto hash(const Key &key) noexcept + -> hash_type { return hasher(key); } /// Compute a hash for a key template requires std::same_as, KeyView> - [[nodiscard]] static inline auto hash(T key) noexcept -> hash_type { + [[nodiscard]] static constexpr auto hash(T key) noexcept -> hash_type { return hasher(key.data(), key.size()); } /// Compute a hash from raw data - [[nodiscard]] static inline auto hash(const char *raw_data, - const std::size_t raw_size) noexcept + [[nodiscard]] static constexpr auto hash(const char *raw_data, + const std::size_t raw_size) noexcept -> hash_type { return hasher(raw_data, raw_size); } diff --git a/vendor/core/src/core/json/include/sourcemeta/core/json_value.h b/vendor/core/src/core/json/include/sourcemeta/core/json_value.h index f1219176..caa84f2e 100644 --- a/vendor/core/src/core/json/include/sourcemeta/core/json_value.h +++ b/vendor/core/src/core/json/include/sourcemeta/core/json_value.h @@ -24,6 +24,7 @@ #include // std::numeric_limits #include // std::allocator #include // std::set +#include // std::span #include // std::basic_istringstream #include // std::out_of_range #include // std::basic_string, std::char_traits @@ -1668,6 +1669,70 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { /// ``` [[nodiscard]] auto contains(const StringView element) const -> bool; + /// This method checks, given a pre-calculated hash, whether an array-valued + /// object member contains a string, returning false when the member is absent + /// or is not such an array. The instance must be an object. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSON document = + /// sourcemeta::core::parse_json(R"JSON({ "tags": [ "a", "b" ] })JSON"); + /// assert(document.array_member_contains("tags", + /// document.as_object().hash("tags"), "b")); + /// ``` + [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto + array_member_contains(const StringView key, + const typename Object::hash_type hash, + const StringView value) const -> bool { + assert(this->is_object()); + const auto *member{this->try_at(key, hash)}; + return member != nullptr && member->is_array() && member->contains(value); + } + + /// This method checks whether an array-valued object member contains a + /// string. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSON document = + /// sourcemeta::core::parse_json(R"JSON({ "tags": [ "a", "b" ] })JSON"); + /// assert(document.array_member_contains("tags", "b")); + /// ``` + [[nodiscard]] SOURCEMETA_FORCEINLINE inline auto + array_member_contains(const StringView key, const StringView value) const + -> bool { + return this->array_member_contains(key, Object::hash(key), value); + } + + /// This method checks whether this value is an array whose every element is a + /// string. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const sourcemeta::core::JSON document = + /// sourcemeta::core::parse_json(R"JSON([ "a", "b" ])JSON"); + /// assert(document.is_array_of_strings()); + /// ``` + [[nodiscard]] auto is_array_of_strings() const -> bool { + if (!this->is_array()) { + return false; + } + + for (const auto &element : this->as_array()) { + if (!element.is_string()) { + return false; + } + } + + return true; + } + /// This method checks if a JSON string includes a given substring. For /// example: /// @@ -1707,6 +1772,19 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { /// ``` [[nodiscard]] auto unique() const -> bool; + /// This method checks if a JSON object does not name any key more than once. + /// The parser preserves repeated members rather than collapsing them, so this + /// detects a document whose object named a key twice. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// const auto document{sourcemeta::core::parse_json(R"({ "foo": 1 })")}; + /// assert(document.unique_keys()); + /// ``` + [[nodiscard]] auto unique_keys() const -> bool; + /* * Write operations */ @@ -1947,6 +2025,95 @@ class SOURCEMETA_CORE_JSON_EXPORT JSON { auto assign_assume_new(String &&key, JSON &&value, Object::hash_type hash) -> JSON &; + /// This method assigns a string object member with a pre-computed hash unless + /// the value is empty, in which case the member is left absent, assuming the + /// key is new. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::JSON document = sourcemeta::core::JSON::make_object(); + /// document.assign_if_nonempty("foo", document.as_object().hash("foo"), + /// "bar"); + /// assert(document.at("foo").to_string() == "bar"); + /// ``` + SOURCEMETA_FORCEINLINE inline auto + assign_if_nonempty(const StringView key, + const typename Object::hash_type hash, + const StringView value) -> void { + if (!value.empty()) { + this->assign_assume_new(String{key}, JSON{value}, hash); + } + } + + /// This method assigns a string object member unless the value is empty, in + /// which case the member is left absent, assuming the key is new. For + /// example: + /// + /// ```cpp + /// #include + /// #include + /// + /// sourcemeta::core::JSON document = sourcemeta::core::JSON::make_object(); + /// document.assign_if_nonempty("foo", "bar"); + /// assert(document.at("foo").to_string() == "bar"); + /// ``` + SOURCEMETA_FORCEINLINE inline auto assign_if_nonempty(const StringView key, + const StringView value) + -> void { + this->assign_if_nonempty(key, Object::hash(key), value); + } + + /// This method assigns an array-of-strings object member with a pre-computed + /// hash unless the array is empty, in which case the member is left absent, + /// assuming the key is new. For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// sourcemeta::core::JSON document = sourcemeta::core::JSON::make_object(); + /// const std::array values{{"a", "b"}}; + /// document.assign_if_nonempty("foo", document.as_object().hash("foo"), + /// values); + /// assert(document.at("foo").size() == 2); + /// ``` + auto assign_if_nonempty(const StringView key, + const typename Object::hash_type hash, + const std::span values) -> void { + if (values.empty()) { + return; + } + + auto array{JSON::make_array()}; + for (const auto value : values) { + array.push_back(JSON{value}); + } + + this->assign_assume_new(String{key}, std::move(array), hash); + } + + /// This method assigns an array-of-strings object member unless the array is + /// empty, in which case the member is left absent, assuming the key is new. + /// For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// sourcemeta::core::JSON document = sourcemeta::core::JSON::make_object(); + /// const std::array values{{"a", "b"}}; + /// document.assign_if_nonempty("foo", values); + /// assert(document.at("foo").size() == 2); + /// ``` + auto assign_if_nonempty(const StringView key, + const std::span values) -> void { + this->assign_if_nonempty(key, Object::hash(key), values); + } + /// This method deletes an object key. For example: /// /// ```cpp diff --git a/vendor/core/src/core/json/json_value.cc b/vendor/core/src/core/json/json_value.cc index 569828ac..75f81623 100644 --- a/vendor/core/src/core/json/json_value.cc +++ b/vendor/core/src/core/json/json_value.cc @@ -919,6 +919,28 @@ JSON::defines_any(std::initializer_list keys) const -> bool { return true; } +[[nodiscard]] auto JSON::unique_keys() const -> bool { + assert(this->is_object()); + const auto &entries{this->data_object.data}; + const auto size{entries.size()}; + + // Objects of 0 or 1 member have unique keys by definition + if (size <= 1) { + return true; + } + + for (std::size_t index = 0; index < size; index++) { + for (std::size_t subindex = index + 1; subindex < size; subindex++) { + if (entries[subindex].key_equals(entries[index].first, + entries[index].hash)) { + return false; + } + } + } + + return true; +} + auto JSON::push_back(const JSON &value) -> void { assert(this->is_array()); return this->data_array.data.push_back(value); diff --git a/vendor/core/src/core/jsonld/jsonld_keywords.h b/vendor/core/src/core/jsonld/jsonld_keywords.h index d84fde5a..56824012 100644 --- a/vendor/core/src/core/jsonld/jsonld_keywords.h +++ b/vendor/core/src/core/jsonld/jsonld_keywords.h @@ -35,29 +35,35 @@ inline constexpr JSON::StringView KEYWORD_VOCAB{"@vocab"}; // Precomputed object-key hashes for each keyword, so that object access never // has to recompute them. -inline const auto KEYWORD_BASE_HASH{JSON::Object::hash(KEYWORD_BASE)}; -inline const auto KEYWORD_CONTAINER_HASH{JSON::Object::hash(KEYWORD_CONTAINER)}; -inline const auto KEYWORD_CONTEXT_HASH{JSON::Object::hash(KEYWORD_CONTEXT)}; -inline const auto KEYWORD_DIRECTION_HASH{JSON::Object::hash(KEYWORD_DIRECTION)}; -inline const auto KEYWORD_GRAPH_HASH{JSON::Object::hash(KEYWORD_GRAPH)}; -inline const auto KEYWORD_ID_HASH{JSON::Object::hash(KEYWORD_ID)}; -inline const auto KEYWORD_IMPORT_HASH{JSON::Object::hash(KEYWORD_IMPORT)}; -inline const auto KEYWORD_INCLUDED_HASH{JSON::Object::hash(KEYWORD_INCLUDED)}; -inline const auto KEYWORD_INDEX_HASH{JSON::Object::hash(KEYWORD_INDEX)}; -inline const auto KEYWORD_JSON_HASH{JSON::Object::hash(KEYWORD_JSON)}; -inline const auto KEYWORD_LANGUAGE_HASH{JSON::Object::hash(KEYWORD_LANGUAGE)}; -inline const auto KEYWORD_LIST_HASH{JSON::Object::hash(KEYWORD_LIST)}; -inline const auto KEYWORD_NEST_HASH{JSON::Object::hash(KEYWORD_NEST)}; -inline const auto KEYWORD_NONE_HASH{JSON::Object::hash(KEYWORD_NONE)}; -inline const auto KEYWORD_PREFIX_HASH{JSON::Object::hash(KEYWORD_PREFIX)}; -inline const auto KEYWORD_PROPAGATE_HASH{JSON::Object::hash(KEYWORD_PROPAGATE)}; -inline const auto KEYWORD_PROTECTED_HASH{JSON::Object::hash(KEYWORD_PROTECTED)}; -inline const auto KEYWORD_REVERSE_HASH{JSON::Object::hash(KEYWORD_REVERSE)}; -inline const auto KEYWORD_SET_HASH{JSON::Object::hash(KEYWORD_SET)}; -inline const auto KEYWORD_TYPE_HASH{JSON::Object::hash(KEYWORD_TYPE)}; -inline const auto KEYWORD_VALUE_HASH{JSON::Object::hash(KEYWORD_VALUE)}; -inline const auto KEYWORD_VERSION_HASH{JSON::Object::hash(KEYWORD_VERSION)}; -inline const auto KEYWORD_VOCAB_HASH{JSON::Object::hash(KEYWORD_VOCAB)}; +inline constexpr auto KEYWORD_BASE_HASH{JSON::Object::hash(KEYWORD_BASE)}; +inline constexpr auto KEYWORD_CONTAINER_HASH{ + JSON::Object::hash(KEYWORD_CONTAINER)}; +inline constexpr auto KEYWORD_CONTEXT_HASH{JSON::Object::hash(KEYWORD_CONTEXT)}; +inline constexpr auto KEYWORD_DIRECTION_HASH{ + JSON::Object::hash(KEYWORD_DIRECTION)}; +inline constexpr auto KEYWORD_GRAPH_HASH{JSON::Object::hash(KEYWORD_GRAPH)}; +inline constexpr auto KEYWORD_ID_HASH{JSON::Object::hash(KEYWORD_ID)}; +inline constexpr auto KEYWORD_IMPORT_HASH{JSON::Object::hash(KEYWORD_IMPORT)}; +inline constexpr auto KEYWORD_INCLUDED_HASH{ + JSON::Object::hash(KEYWORD_INCLUDED)}; +inline constexpr auto KEYWORD_INDEX_HASH{JSON::Object::hash(KEYWORD_INDEX)}; +inline constexpr auto KEYWORD_JSON_HASH{JSON::Object::hash(KEYWORD_JSON)}; +inline constexpr auto KEYWORD_LANGUAGE_HASH{ + JSON::Object::hash(KEYWORD_LANGUAGE)}; +inline constexpr auto KEYWORD_LIST_HASH{JSON::Object::hash(KEYWORD_LIST)}; +inline constexpr auto KEYWORD_NEST_HASH{JSON::Object::hash(KEYWORD_NEST)}; +inline constexpr auto KEYWORD_NONE_HASH{JSON::Object::hash(KEYWORD_NONE)}; +inline constexpr auto KEYWORD_PREFIX_HASH{JSON::Object::hash(KEYWORD_PREFIX)}; +inline constexpr auto KEYWORD_PROPAGATE_HASH{ + JSON::Object::hash(KEYWORD_PROPAGATE)}; +inline constexpr auto KEYWORD_PROTECTED_HASH{ + JSON::Object::hash(KEYWORD_PROTECTED)}; +inline constexpr auto KEYWORD_REVERSE_HASH{JSON::Object::hash(KEYWORD_REVERSE)}; +inline constexpr auto KEYWORD_SET_HASH{JSON::Object::hash(KEYWORD_SET)}; +inline constexpr auto KEYWORD_TYPE_HASH{JSON::Object::hash(KEYWORD_TYPE)}; +inline constexpr auto KEYWORD_VALUE_HASH{JSON::Object::hash(KEYWORD_VALUE)}; +inline constexpr auto KEYWORD_VERSION_HASH{JSON::Object::hash(KEYWORD_VERSION)}; +inline constexpr auto KEYWORD_VOCAB_HASH{JSON::Object::hash(KEYWORD_VOCAB)}; // Markers used internally by inverse context creation, term selection, and node // map generation. These are not JSON-LD document keywords: @default is the name @@ -66,8 +72,8 @@ inline const auto KEYWORD_VOCAB_HASH{JSON::Object::hash(KEYWORD_VOCAB)}; inline constexpr JSON::StringView KEYWORD_ANY{"@any"}; inline constexpr JSON::StringView KEYWORD_DEFAULT{"@default"}; inline constexpr JSON::StringView KEYWORD_NULL{"@null"}; -inline const auto KEYWORD_ANY_HASH{JSON::Object::hash(KEYWORD_ANY)}; -inline const auto KEYWORD_DEFAULT_HASH{JSON::Object::hash(KEYWORD_DEFAULT)}; +inline constexpr auto KEYWORD_ANY_HASH{JSON::Object::hash(KEYWORD_ANY)}; +inline constexpr auto KEYWORD_DEFAULT_HASH{JSON::Object::hash(KEYWORD_DEFAULT)}; // A stable owned copy of the @context keyword, suitable as a JSON Pointer // token. (A namespace-scope JSON::String constant would trip clang-tidy's diff --git a/vendor/core/src/core/jsonrpc/jsonrpc.cc b/vendor/core/src/core/jsonrpc/jsonrpc.cc index e64c0d92..3daed6a4 100644 --- a/vendor/core/src/core/jsonrpc/jsonrpc.cc +++ b/vendor/core/src/core/jsonrpc/jsonrpc.cc @@ -10,19 +10,22 @@ namespace { using namespace std::string_view_literals; -const auto JSONRPC_HASH_ID{sourcemeta::core::JSON::Object::hash("id"sv)}; -const auto JSONRPC_HASH_JSONRPC{ +constexpr auto JSONRPC_HASH_ID{sourcemeta::core::JSON::Object::hash("id"sv)}; +constexpr auto JSONRPC_HASH_JSONRPC{ sourcemeta::core::JSON::Object::hash("jsonrpc"sv)}; -const auto JSONRPC_HASH_METHOD{ +constexpr auto JSONRPC_HASH_METHOD{ sourcemeta::core::JSON::Object::hash("method"sv)}; -const auto JSONRPC_HASH_RESULT{ +constexpr auto JSONRPC_HASH_RESULT{ sourcemeta::core::JSON::Object::hash("result"sv)}; -const auto JSONRPC_HASH_ERROR{sourcemeta::core::JSON::Object::hash("error"sv)}; -const auto JSONRPC_HASH_CODE{sourcemeta::core::JSON::Object::hash("code"sv)}; -const auto JSONRPC_HASH_MESSAGE{ +constexpr auto JSONRPC_HASH_ERROR{ + sourcemeta::core::JSON::Object::hash("error"sv)}; +constexpr auto JSONRPC_HASH_CODE{ + sourcemeta::core::JSON::Object::hash("code"sv)}; +constexpr auto JSONRPC_HASH_MESSAGE{ sourcemeta::core::JSON::Object::hash("message"sv)}; -const auto JSONRPC_HASH_DATA{sourcemeta::core::JSON::Object::hash("data"sv)}; -const auto JSONRPC_HASH_PARAMS{ +constexpr auto JSONRPC_HASH_DATA{ + sourcemeta::core::JSON::Object::hash("data"sv)}; +constexpr auto JSONRPC_HASH_PARAMS{ sourcemeta::core::JSON::Object::hash("params"sv)}; } // namespace diff --git a/vendor/core/src/core/mcp/mcp.cc b/vendor/core/src/core/mcp/mcp.cc index f0b455c0..f29b9dfc 100644 --- a/vendor/core/src/core/mcp/mcp.cc +++ b/vendor/core/src/core/mcp/mcp.cc @@ -16,59 +16,64 @@ namespace { using namespace std::string_view_literals; -const auto MCP_HASH_ANNOTATIONS{ +constexpr auto MCP_HASH_ANNOTATIONS{ sourcemeta::core::JSON::Object::hash("annotations"sv)}; -const auto MCP_HASH_ARGUMENTS{ +constexpr auto MCP_HASH_ARGUMENTS{ sourcemeta::core::JSON::Object::hash("arguments"sv)}; -const auto MCP_HASH_CAPABILITIES{ +constexpr auto MCP_HASH_CAPABILITIES{ sourcemeta::core::JSON::Object::hash("capabilities"sv)}; -const auto MCP_HASH_COMPLETIONS{ +constexpr auto MCP_HASH_COMPLETIONS{ sourcemeta::core::JSON::Object::hash("completions"sv)}; -const auto MCP_HASH_CONTENT{sourcemeta::core::JSON::Object::hash("content"sv)}; -const auto MCP_HASH_CONTENTS{ +constexpr auto MCP_HASH_CONTENT{ + sourcemeta::core::JSON::Object::hash("content"sv)}; +constexpr auto MCP_HASH_CONTENTS{ sourcemeta::core::JSON::Object::hash("contents"sv)}; -const auto MCP_HASH_DESCRIPTION{ +constexpr auto MCP_HASH_DESCRIPTION{ sourcemeta::core::JSON::Object::hash("description"sv)}; -const auto MCP_HASH_DESTRUCTIVE_HINT{ +constexpr auto MCP_HASH_DESTRUCTIVE_HINT{ sourcemeta::core::JSON::Object::hash("destructiveHint"sv)}; -const auto MCP_HASH_IDEMPOTENT_HINT{ +constexpr auto MCP_HASH_IDEMPOTENT_HINT{ sourcemeta::core::JSON::Object::hash("idempotentHint"sv)}; -const auto MCP_HASH_INPUT_SCHEMA{ +constexpr auto MCP_HASH_INPUT_SCHEMA{ sourcemeta::core::JSON::Object::hash("inputSchema"sv)}; -const auto MCP_HASH_INSTRUCTIONS{ +constexpr auto MCP_HASH_INSTRUCTIONS{ sourcemeta::core::JSON::Object::hash("instructions"sv)}; -const auto MCP_HASH_IS_ERROR{sourcemeta::core::JSON::Object::hash("isError"sv)}; -const auto MCP_HASH_LOGGING{sourcemeta::core::JSON::Object::hash("logging"sv)}; -const auto MCP_HASH_MIME_TYPE{ +constexpr auto MCP_HASH_IS_ERROR{ + sourcemeta::core::JSON::Object::hash("isError"sv)}; +constexpr auto MCP_HASH_LOGGING{ + sourcemeta::core::JSON::Object::hash("logging"sv)}; +constexpr auto MCP_HASH_MIME_TYPE{ sourcemeta::core::JSON::Object::hash("mimeType"sv)}; -const auto MCP_HASH_NAME{sourcemeta::core::JSON::Object::hash("name"sv)}; -const auto MCP_HASH_OPEN_WORLD_HINT{ +constexpr auto MCP_HASH_NAME{sourcemeta::core::JSON::Object::hash("name"sv)}; +constexpr auto MCP_HASH_OPEN_WORLD_HINT{ sourcemeta::core::JSON::Object::hash("openWorldHint"sv)}; -const auto MCP_HASH_OUTPUT_SCHEMA{ +constexpr auto MCP_HASH_OUTPUT_SCHEMA{ sourcemeta::core::JSON::Object::hash("outputSchema"sv)}; -const auto MCP_HASH_PRIORITY{ +constexpr auto MCP_HASH_PRIORITY{ sourcemeta::core::JSON::Object::hash("priority"sv)}; -const auto MCP_HASH_PROMPTS{sourcemeta::core::JSON::Object::hash("prompts"sv)}; -const auto MCP_HASH_PROTOCOL_VERSION{ +constexpr auto MCP_HASH_PROMPTS{ + sourcemeta::core::JSON::Object::hash("prompts"sv)}; +constexpr auto MCP_HASH_PROTOCOL_VERSION{ sourcemeta::core::JSON::Object::hash("protocolVersion"sv)}; -const auto MCP_HASH_READ_ONLY_HINT{ +constexpr auto MCP_HASH_READ_ONLY_HINT{ sourcemeta::core::JSON::Object::hash("readOnlyHint"sv)}; -const auto MCP_HASH_RESOURCES{ +constexpr auto MCP_HASH_RESOURCES{ sourcemeta::core::JSON::Object::hash("resources"sv)}; -const auto MCP_HASH_SERVER_INFO{ +constexpr auto MCP_HASH_SERVER_INFO{ sourcemeta::core::JSON::Object::hash("serverInfo"sv)}; -const auto MCP_HASH_SIZE{sourcemeta::core::JSON::Object::hash("size"sv)}; -const auto MCP_HASH_STRUCTURED_CONTENT{ +constexpr auto MCP_HASH_SIZE{sourcemeta::core::JSON::Object::hash("size"sv)}; +constexpr auto MCP_HASH_STRUCTURED_CONTENT{ sourcemeta::core::JSON::Object::hash("structuredContent"sv)}; -const auto MCP_HASH_TEXT{sourcemeta::core::JSON::Object::hash("text"sv)}; -const auto MCP_HASH_TITLE{sourcemeta::core::JSON::Object::hash("title"sv)}; -const auto MCP_HASH_TOOLS{sourcemeta::core::JSON::Object::hash("tools"sv)}; -const auto MCP_HASH_TYPE{sourcemeta::core::JSON::Object::hash("type"sv)}; -const auto MCP_HASH_URI{sourcemeta::core::JSON::Object::hash("uri"sv)}; -const auto MCP_HASH_URI_TEMPLATE{ +constexpr auto MCP_HASH_TEXT{sourcemeta::core::JSON::Object::hash("text"sv)}; +constexpr auto MCP_HASH_TITLE{sourcemeta::core::JSON::Object::hash("title"sv)}; +constexpr auto MCP_HASH_TOOLS{sourcemeta::core::JSON::Object::hash("tools"sv)}; +constexpr auto MCP_HASH_TYPE{sourcemeta::core::JSON::Object::hash("type"sv)}; +constexpr auto MCP_HASH_URI{sourcemeta::core::JSON::Object::hash("uri"sv)}; +constexpr auto MCP_HASH_URI_TEMPLATE{ sourcemeta::core::JSON::Object::hash("uriTemplate"sv)}; -const auto MCP_HASH_VERSION{sourcemeta::core::JSON::Object::hash("version"sv)}; -const auto MCP_HASH_WEBSITE_URL{ +constexpr auto MCP_HASH_VERSION{ + sourcemeta::core::JSON::Object::hash("version"sv)}; +constexpr auto MCP_HASH_WEBSITE_URL{ sourcemeta::core::JSON::Object::hash("websiteUrl"sv)}; } // namespace diff --git a/vendor/core/src/core/oauth/CMakeLists.txt b/vendor/core/src/core/oauth/CMakeLists.txt index 7f5b785f..4d02fc69 100644 --- a/vendor/core/src/core/oauth/CMakeLists.txt +++ b/vendor/core/src/core/oauth/CMakeLists.txt @@ -5,7 +5,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oauth assertion.h registration.h SOURCES oauth_error.cc oauth_pkce.cc oauth_bearer.cc oauth_syntax.h oauth_random.cc oauth_authorization.cc oauth_authorization_parse.h - oauth_encode.h oauth_decode.h + oauth_decode.h oauth_json.h oauth_token.cc oauth_client_authentication.cc oauth_transaction.cc oauth_metadata.cc oauth_metadata_provider.cc oauth_ttl.h oauth_token_exchange.cc oauth_revocation.cc @@ -24,6 +24,8 @@ target_link_libraries(sourcemeta_core_oauth PRIVATE sourcemeta::core::uri) target_link_libraries(sourcemeta_core_oauth PRIVATE sourcemeta::core::text) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::html) if(SOURCEMETA_CORE_INSTALL) sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oauth) diff --git a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h index 0a985562..58a86736 100644 --- a/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h +++ b/vendor/core/src/core/oauth/include/sourcemeta/core/oauth_authorization.h @@ -7,7 +7,9 @@ #include +#include // std::uint8_t #include // std::function +#include // std::optional #include // std::span #include // std::string #include // std::string_view @@ -73,11 +75,12 @@ struct OAuthAuthorizationRequest { std::string_view request_uri; /// The JWK thumbprint of the DPoP proof public key (RFC 9449 Section 10). std::string_view dpop_jkt; - /// The response type, a space-delimited set the authorization URL builder - /// forces to `code` while the pushed request builder honors an override, - /// surfaced on parse so a server can tell a missing value from one it does - /// not understand (RFC 6749 Section 3.1.1). It is placed after the older - /// members so an existing positional initializer keeps populating them. + /// The response type, a space-delimited set that defaults to `code` when + /// unset and is otherwise honored by the authorization URL and pushed request + /// builders alike, surfaced on parse so a server can tell a missing value + /// from one it does not understand (RFC 6749 Section 3.1.1). It is placed + /// after the older members so an existing positional initializer keeps + /// populating them. std::string_view response_type; /// The repeatable resource indicators, each an absolute URI without a /// fragment (RFC 8707 Section 2). @@ -89,11 +92,11 @@ struct OAuthAuthorizationRequest { /// @ingroup oauth /// Build an authorization request URL by appending the query to the endpoint -/// (RFC 6749 Section 4.1.1). The `response_type` is always `code`, since the -/// implicit grant is not represented, every value is percent-escaped, an -/// existing query on the endpoint is honored, and the code challenge method is -/// emitted only when a challenge is present. The sink is appended to and never -/// cleared. For example: +/// (RFC 6749 Section 4.1.1). The `response_type` defaults to `code` and is +/// honored when set, every value is percent-escaped, an existing query on the +/// endpoint is honored, and the code challenge method is emitted only when a +/// challenge is present. The sink is appended to and never cleared. For +/// example: /// /// ```cpp /// #include @@ -293,6 +296,176 @@ auto oauth_build_authorization_error_redirect( const std::string_view redirect_uri, const OAuthAuthorizationResponse &response, std::string &sink) -> bool; +/// @ingroup oauth +/// The mechanism used for returning authorization response parameters from +/// the authorization endpoint (OAuth 2.0 Multiple Response Types Section 2.1, +/// OAuth 2.0 Form Post Response Mode Section 2). +enum class OAuthResponseMode : std::uint8_t { + /// Parameters are "encoded in the query string added to the redirect_uri + /// when redirecting back to the Client" (OAuth 2.0 Multiple Response Types + /// Section 2.1). + Query, + /// Parameters are "encoded in the fragment added to the redirect_uri when + /// redirecting back to the Client" (OAuth 2.0 Multiple Response Types + /// Section 2.1). + Fragment, + /// Parameters are "encoded as HTML form values that are auto-submitted in + /// the User Agent" with the HTTP POST method (OAuth 2.0 Form Post Response + /// Mode Section 2). + FormPost +}; + +/// @ingroup oauth +/// Look up the default response mode of a response type (OAuth 2.0 Multiple +/// Response Types Sections 2.1, 3, 4, and 5), returning no value unless the +/// response type is registered. The response type "is compared as a +/// space-delimited list of values in which the order of values does not +/// matter" (Section 1.2). The lookup covers every registered response type, +/// including token-bearing ones whose successful response this module does +/// not encode, since requests are still built and response modes still +/// negotiated for them. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto mode{sourcemeta::core::oauth_default_response_mode("code")}; +/// assert(mode.has_value()); +/// assert(mode.value() == sourcemeta::core::OAuthResponseMode::Query); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_default_response_mode(const std::string_view response_type) + -> std::optional; + +/// @ingroup oauth +/// Check whether a response mode may encode the response of a response type, +/// as "in no case should a set of Authorization Response parameters whose +/// default Response Mode is the fragment encoding be encoded using the query +/// encoding" (OAuth 2.0 Multiple Response Types Section 7). An unknown +/// response type allows no mode. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(!sourcemeta::core::oauth_is_response_mode_allowed( +/// "id_token", sourcemeta::core::OAuthResponseMode::Query)); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_response_mode_allowed(const std::string_view response_type, + const OAuthResponseMode mode) -> bool; + +/// @ingroup oauth +/// Build a successful authorization redirect at the authorization server in +/// the given response mode, encoding the parameters in the query (RFC 6749 +/// Section 4.1.2) or in the fragment (OAuth 2.0 Multiple Response Types +/// Section 2.1) of the client's redirection endpoint, returning whether it was +/// produced. The form post mode produces no value, as it emits an HTML page +/// rather than a redirect. Every other behavior matches the query-only +/// builder. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.code = "SplxlOBeZQQYbYS6WxSbIA"; +/// std::string url; +/// assert(sourcemeta::core::oauth_build_authorization_redirect( +/// "https://client.example/cb", response, +/// sourcemeta::core::OAuthResponseMode::Fragment, url)); +/// assert(url == "https://client.example/cb#code=SplxlOBeZQQYbYS6WxSbIA"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, const OAuthResponseMode mode, + std::string &sink) -> bool; + +/// @ingroup oauth +/// Build a failed authorization redirect at the authorization server in the +/// given response mode (RFC 6749 Section 4.1.2.1, OAuth 2.0 Multiple Response +/// Types Section 2.1), returning whether it was produced. The form post mode +/// produces no value, as it emits an HTML page rather than a redirect. Every +/// other behavior matches the query-only builder. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.error = "access_denied"; +/// std::string url; +/// assert(sourcemeta::core::oauth_build_authorization_error_redirect( +/// "https://client.example/cb", response, +/// sourcemeta::core::OAuthResponseMode::Fragment, url)); +/// assert(url == "https://client.example/cb#error=access_denied"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, const OAuthResponseMode mode, + std::string &sink) -> bool; + +/// @ingroup oauth +/// Build the auto-submitting HTML page of a successful authorization response +/// in the form post response mode (OAuth 2.0 Form Post Response Mode +/// Section 2), returning whether it was produced. The action of the form is +/// the client's redirection endpoint, the response parameters are the hidden +/// form values, and the page carries the given title, with the same validation +/// as the redirect builder. The redirect URI, the response fields, and the +/// title must not alias the sink. When serving the page, the authorization +/// server "MUST instruct the User Agent (and any intermediaries) not to store +/// or reuse the content of the response". For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.code = "SplxlOBeZQQYbYS6WxSbIA"; +/// std::string page; +/// assert(sourcemeta::core::oauth_build_authorization_form_post( +/// "https://client.example/cb", response, page)); +/// assert(page.find("
bool; + +/// @ingroup oauth +/// Build the auto-submitting HTML page of a failed authorization response in +/// the form post response mode (OAuth 2.0 Form Post Response Mode Section 2, +/// RFC 6749 Section 4.1.2.1), returning whether it was produced, with the same +/// validation as the error redirect builder and the page carrying the given +/// title. The redirect URI, the response fields, and the title must not alias +/// the sink. When serving the page, the authorization server "MUST instruct +/// the User Agent (and any intermediaries) not to store or reuse the content +/// of the response". For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OAuthAuthorizationResponse response; +/// response.error = "access_denied"; +/// std::string page; +/// assert(sourcemeta::core::oauth_build_authorization_error_form_post( +/// "https://client.example/cb", response, page)); +/// assert(page.find("access_denied") != std::string::npos); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_build_authorization_error_form_post( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink, + const std::string_view title = "Submit This Form") -> bool; + } // namespace sourcemeta::core #endif diff --git a/vendor/core/src/core/oauth/oauth_assertion.cc b/vendor/core/src/core/oauth/oauth_assertion.cc index c61d2b71..944c142c 100644 --- a/vendor/core/src/core/oauth/oauth_assertion.cc +++ b/vendor/core/src/core/oauth/oauth_assertion.cc @@ -7,8 +7,7 @@ #include #include #include - -#include "oauth_encode.h" +#include #include // std::ranges::find_if, std::clamp #include // std::chrono::seconds, std::chrono::duration_cast @@ -24,14 +23,14 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_ALG{JSON::Object::hash("alg"sv)}; -const auto HASH_KID{JSON::Object::hash("kid"sv)}; -const auto HASH_ISS{JSON::Object::hash("iss"sv)}; -const auto HASH_SUB{JSON::Object::hash("sub"sv)}; -const auto HASH_AUD{JSON::Object::hash("aud"sv)}; -const auto HASH_EXP{JSON::Object::hash("exp"sv)}; -const auto HASH_IAT{JSON::Object::hash("iat"sv)}; -const auto HASH_JTI{JSON::Object::hash("jti"sv)}; +constexpr auto HASH_ALG{JSON::Object::hash("alg"sv)}; +constexpr auto HASH_KID{JSON::Object::hash("kid"sv)}; +constexpr auto HASH_ISS{JSON::Object::hash("iss"sv)}; +constexpr auto HASH_SUB{JSON::Object::hash("sub"sv)}; +constexpr auto HASH_AUD{JSON::Object::hash("aud"sv)}; +constexpr auto HASH_EXP{JSON::Object::hash("exp"sv)}; +constexpr auto HASH_IAT{JSON::Object::hash("iat"sv)}; +constexpr auto HASH_JTI{JSON::Object::hash("jti"sv)}; auto to_epoch_seconds(const std::chrono::system_clock::time_point point) -> std::int64_t { @@ -142,9 +141,13 @@ auto verify_assertion( const auto skew{std::clamp(options.clock_skew, std::chrono::seconds::zero(), std::chrono::seconds{31556952})}; - const auto remaining{std::chrono::duration_cast( - expiration.value() - now) + - skew}; + // The remaining lifetime is rounded up to the next whole second so the + // stored entry never expires before the sub-second interval during which + // the assertion itself is still accepted, which a truncating cast would + // collapse to zero in the final fractional second + const auto remaining{ + std::chrono::ceil(expiration.value() - now) + + skew}; const auto window{remaining > std::chrono::seconds{0} ? remaining : std::chrono::seconds{0}}; @@ -208,18 +211,18 @@ auto oauth_build_client_assertion( auto oauth_client_assertion(const std::string_view assertion, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "client_assertion_type", + URI::append_query_parameter(sink, "client_assertion_type", OAUTH_CLIENT_ASSERTION_TYPE_JWT_BEARER); - oauth_append_form_parameter(sink, "client_assertion", assertion); + URI::append_query_parameter(sink, "client_assertion", assertion); } auto oauth_build_token_request_jwt_bearer(const std::string_view assertion, const std::string_view scope, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "grant_type", OAUTH_GRANT_TYPE_JWT_BEARER); - oauth_append_form_parameter(sink, "assertion", assertion); + URI::append_query_parameter(sink, "grant_type", OAUTH_GRANT_TYPE_JWT_BEARER); + URI::append_query_parameter(sink, "assertion", assertion); if (!scope.empty()) { - oauth_append_form_parameter(sink, "scope", scope); + URI::append_query_parameter(sink, "scope", scope); } } diff --git a/vendor/core/src/core/oauth/oauth_authorization.cc b/vendor/core/src/core/oauth/oauth_authorization.cc index 695fe8e4..f9dca202 100644 --- a/vendor/core/src/core/oauth/oauth_authorization.cc +++ b/vendor/core/src/core/oauth/oauth_authorization.cc @@ -1,5 +1,6 @@ #include +#include #include #include @@ -16,22 +17,6 @@ namespace sourcemeta::core { namespace { -auto oauth_append_parameter(std::string &sink, char &separator, - const std::string_view name, - const std::string_view value) -> void { - if (separator != '\0') { - sink.push_back(separator); - } - - separator = '&'; - // RFC 6749 Section 4.1.1: the query is application/x-www-form-urlencoded, so - // the name is escaped too, which is a no-op for the fixed keys but keeps a - // caller-supplied resource or extra name from corrupting the query - URI::escape(name, sink); - sink.push_back('='); - URI::escape(value, sink); -} - struct HttpAuthority { std::string_view host; std::string_view rest; @@ -80,6 +65,29 @@ auto split_http_authority(const std::string_view value) return HttpAuthority{.host = host, .rest = rest}; } +auto oauth_append_hidden_input(HTMLWriter &page, const std::string_view name, + const std::string_view value) -> void { + page.input() + .attribute("type", "hidden") + .attribute("name", name) + .attribute("value", value); +} + +// The page follows the shape of the example in OAuth 2.0 Form Post Response +// Mode Appendix A, where "the action attribute of the form MUST be the +// Client's Redirection URI" and "the method of the form attribute MUST be +// POST" (Section 2) +auto oauth_open_form_post_page(HTMLWriter &page, + const std::string_view redirect_uri, + const std::string_view title) -> void { + page.html(); + page.head(); + page.title(title); + page.close(); + page.body().attribute("onload", "javascript:document.forms[0].submit()"); + page.form().attribute("method", "post").attribute("action", redirect_uri); +} + } // namespace auto oauth_build_authorization_url(const std::string_view endpoint, @@ -93,63 +101,58 @@ auto oauth_build_authorization_url(const std::string_view endpoint, request.code_challenge_method.size() + request.request_uri.size() + request.dpop_jkt.size() + 128); sink.append(endpoint); - // The separator before the first parameter: '?' opens a query the endpoint - // lacks, nothing when the endpoint already ends its query with '?' or '&', - // and '&' continues an existing query (RFC 6749 Section 3.1) - char separator{'?'}; - if (endpoint.find('?') != std::string_view::npos) { - separator = - (endpoint.empty() || endpoint.back() == '?' || endpoint.back() == '&') - ? '\0' - : '&'; + // '?' opens a query the endpoint lacks, and the parameter appender continues + // one the endpoint already carries (RFC 6749 Section 3.1) + if (endpoint.find('?') == std::string_view::npos) { + sink.push_back('?'); } - // RFC 6749 Section 4.1.1: the authorization code flow is the only response - // type this builder emits, since the implicit grant is not represented - oauth_append_parameter(sink, separator, "response_type", "code"); + // RFC 6749 Section 3.1.1: the request selects the response type, defaulting + // to the authorization code flow when the caller leaves it unset + URI::append_query_parameter( + sink, "response_type", + request.response_type.empty() ? "code" : request.response_type); if (!request.client_id.empty()) { - oauth_append_parameter(sink, separator, "client_id", request.client_id); + URI::append_query_parameter(sink, "client_id", request.client_id); } if (!request.redirect_uri.empty()) { - oauth_append_parameter(sink, separator, "redirect_uri", - request.redirect_uri); + URI::append_query_parameter(sink, "redirect_uri", request.redirect_uri); } if (!request.scope.empty()) { - oauth_append_parameter(sink, separator, "scope", request.scope); + URI::append_query_parameter(sink, "scope", request.scope); } if (!request.state.empty()) { - oauth_append_parameter(sink, separator, "state", request.state); + URI::append_query_parameter(sink, "state", request.state); } if (!request.code_challenge.empty()) { - oauth_append_parameter(sink, separator, "code_challenge", - request.code_challenge); + URI::append_query_parameter(sink, "code_challenge", request.code_challenge); // RFC 7636 Section 4.3: the method qualifies a challenge, so it is // meaningless and omitted when no challenge is present if (!request.code_challenge_method.empty()) { - oauth_append_parameter(sink, separator, "code_challenge_method", - request.code_challenge_method); + URI::append_query_parameter(sink, "code_challenge_method", + request.code_challenge_method); } } if (!request.request_uri.empty()) { - oauth_append_parameter(sink, separator, "request_uri", request.request_uri); + URI::append_query_parameter(sink, "request_uri", request.request_uri); } if (!request.dpop_jkt.empty()) { - oauth_append_parameter(sink, separator, "dpop_jkt", request.dpop_jkt); + URI::append_query_parameter(sink, "dpop_jkt", request.dpop_jkt); } for (const auto &resource : request.resources) { - oauth_append_parameter(sink, separator, resource.name, resource.value); + URI::append_query_parameter(sink, resource.name, resource.value); } for (const auto ¶meter : request.extra) { - oauth_append_parameter(sink, separator, parameter.name, parameter.value); + URI::append_query_parameter(sink, parameter.name, parameter.value); } } @@ -223,7 +226,14 @@ auto oauth_is_private_use_scheme(const std::string_view scheme) noexcept auto oauth_build_authorization_redirect( const std::string_view redirect_uri, - const OAuthAuthorizationResponse &response, std::string &sink) -> bool { + const OAuthAuthorizationResponse &response, const OAuthResponseMode mode, + std::string &sink) -> bool { + // A form post response is an HTML page rather than a redirect (OAuth 2.0 + // Form Post Response Mode Section 2) + if (mode == OAuthResponseMode::FormPost) { + return false; + } + // A success response carries the code, and RFC 9207 Section 2 constrains the // issuer syntax when one is echoed if (response.code.empty()) { @@ -241,27 +251,49 @@ auto oauth_build_authorization_redirect( return false; } + const auto opener{mode == OAuthResponseMode::Fragment ? '#' : '?'}; sink.reserve(sink.size() + redirect_uri.size() + response.code.size() + response.state.size() + response.iss.size() + 32); sink.append(redirect_uri); - char separator{redirect_uri.find('?') == std::string_view::npos ? '?' : '&'}; - oauth_append_parameter(sink, separator, "code", response.code); + // In the fragment response mode the parameters are "encoded in the fragment + // added to the redirect_uri when redirecting back to the Client" (OAuth 2.0 + // Multiple Response Types Section 2.1), while a query joins any existing one + if (mode == OAuthResponseMode::Fragment || + redirect_uri.find('?') == std::string_view::npos) { + sink.push_back(opener); + } + + URI::append_query_parameter(sink, "code", response.code, opener); // RFC 6749 Section 4.1.2: state is returned when the request carried one, // which the caller reflects by setting it if (!response.state.empty()) { - oauth_append_parameter(sink, separator, "state", response.state); + URI::append_query_parameter(sink, "state", response.state, opener); } if (!response.iss.empty()) { - oauth_append_parameter(sink, separator, "iss", response.iss); + URI::append_query_parameter(sink, "iss", response.iss, opener); } return true; } -auto oauth_build_authorization_error_redirect( +auto oauth_build_authorization_redirect( const std::string_view redirect_uri, const OAuthAuthorizationResponse &response, std::string &sink) -> bool { + return oauth_build_authorization_redirect(redirect_uri, response, + OAuthResponseMode::Query, sink); +} + +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, const OAuthResponseMode mode, + std::string &sink) -> bool { + // A form post response is an HTML page rather than a redirect (OAuth 2.0 + // Form Post Response Mode Section 2) + if (mode == OAuthResponseMode::FormPost) { + return false; + } + // RFC 6749 Section 4.1.2.1: this builder must not be called when the redirect // URI or client identifier failed validation, since the error is then shown // to the resource owner rather than redirected. An error response carries the @@ -281,29 +313,198 @@ auto oauth_build_authorization_error_redirect( return false; } + const auto opener{mode == OAuthResponseMode::Fragment ? '#' : '?'}; sink.reserve(sink.size() + redirect_uri.size() + response.error.size() + response.error_description.size() + response.error_uri.size() + response.state.size() + response.iss.size() + 64); sink.append(redirect_uri); - char separator{redirect_uri.find('?') == std::string_view::npos ? '?' : '&'}; - oauth_append_parameter(sink, separator, "error", response.error); + // In the fragment response mode the parameters are "encoded in the fragment + // added to the redirect_uri when redirecting back to the Client" (OAuth 2.0 + // Multiple Response Types Section 2.1), while a query joins any existing one + if (mode == OAuthResponseMode::Fragment || + redirect_uri.find('?') == std::string_view::npos) { + sink.push_back(opener); + } + + URI::append_query_parameter(sink, "error", response.error, opener); + if (!response.error_description.empty()) { + URI::append_query_parameter(sink, "error_description", + response.error_description, opener); + } + + if (!response.error_uri.empty()) { + URI::append_query_parameter(sink, "error_uri", response.error_uri, opener); + } + + if (!response.state.empty()) { + URI::append_query_parameter(sink, "state", response.state, opener); + } + + if (!response.iss.empty()) { + URI::append_query_parameter(sink, "iss", response.iss, opener); + } + + return true; +} + +auto oauth_build_authorization_error_redirect( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink) -> bool { + return oauth_build_authorization_error_redirect( + redirect_uri, response, OAuthResponseMode::Query, sink); +} + +auto oauth_default_response_mode(const std::string_view response_type) + -> std::optional { + // "If a Response Type contains one of more space characters (%20), it is + // compared as a space-delimited list of values in which the order of values + // does not matter" (OAuth 2.0 Multiple Response Types Section 1.2) + auto has_code{false}; + auto has_token{false}; + auto has_id_token{false}; + auto has_none{false}; + std::size_t position{0}; + while (true) { + const auto space{response_type.find(' ', position)}; + const auto value{space == std::string_view::npos + ? response_type.substr(position) + : response_type.substr(position, space - position)}; + if (value == "code" && !has_code) { + has_code = true; + } else if (value == "token" && !has_token) { + has_token = true; + } else if (value == "id_token" && !has_id_token) { + has_id_token = true; + } else if (value == "none" && !has_none) { + has_none = true; + } else { + return std::nullopt; + } + + if (space == std::string_view::npos) { + break; + } + + position = space + 1; + } + + // The none response type is registered on its own and no combination with it + // is (OAuth 2.0 Multiple Response Types Sections 4 and 5) + if (has_none && (has_code || has_token || has_id_token)) { + return std::nullopt; + } + + // The code and none response types default to "the query encoding" while + // the token and id_token response types and every registered combination + // default to "the fragment encoding" (OAuth 2.0 Multiple Response Types + // Sections 2.1, 3, 4, and 5) + return has_token || has_id_token ? OAuthResponseMode::Fragment + : OAuthResponseMode::Query; +} + +auto oauth_is_response_mode_allowed(const std::string_view response_type, + const OAuthResponseMode mode) -> bool { + const auto default_mode{oauth_default_response_mode(response_type)}; + if (!default_mode.has_value()) { + return false; + } + + // Every response type that defaults to the fragment encoding states that + // "the query encoding MUST NOT be used" (OAuth 2.0 Multiple Response Types + // Sections 3 and 5), and "in no case should a set of Authorization Response + // parameters whose default Response Mode is the fragment encoding be encoded + // using the query encoding" (Section 7) + return mode != OAuthResponseMode::Query || + default_mode.value() != OAuthResponseMode::Fragment; +} + +auto oauth_build_authorization_form_post( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink, + const std::string_view title) -> bool { + // A success response carries the code, and RFC 9207 Section 2 constrains the + // issuer syntax when one is echoed + if (response.code.empty()) { + return false; + } + + // RFC 6749 Section 3.1.2: a redirection endpoint "MUST NOT include a + // fragment component" + if (redirect_uri.find('#') != std::string_view::npos) { + return false; + } + + if (!response.iss.empty() && !oauth_is_issuer_identifier(response.iss)) { + return false; + } + + HTMLWriter page; + page.reserve(redirect_uri.size() + title.size() + response.code.size() + + response.state.size() + response.iss.size() + 256); + oauth_open_form_post_page(page, redirect_uri, title); + oauth_append_hidden_input(page, "code", response.code); + if (!response.state.empty()) { + oauth_append_hidden_input(page, "state", response.state); + } + + if (!response.iss.empty()) { + oauth_append_hidden_input(page, "iss", response.iss); + } + + // Close the form, the body, and the document + page.close().close().close(); + sink.append(page.str()); + return true; +} + +auto oauth_build_authorization_error_form_post( + const std::string_view redirect_uri, + const OAuthAuthorizationResponse &response, std::string &sink, + const std::string_view title) -> bool { + // RFC 6749 Section 4.1.2.1: this builder must not be called when the redirect + // URI or client identifier failed validation, since the error is then shown + // to the resource owner rather than redirected. An error response carries the + // error code + if (response.error.empty()) { + return false; + } + + // RFC 6749 Section 3.1.2: a redirection endpoint "MUST NOT include a + // fragment component" + if (redirect_uri.find('#') != std::string_view::npos) { + return false; + } + + if (!response.iss.empty() && !oauth_is_issuer_identifier(response.iss)) { + return false; + } + + HTMLWriter page; + page.reserve(redirect_uri.size() + title.size() + response.error.size() + + response.error_description.size() + response.error_uri.size() + + response.state.size() + response.iss.size() + 256); + oauth_open_form_post_page(page, redirect_uri, title); + oauth_append_hidden_input(page, "error", response.error); if (!response.error_description.empty()) { - oauth_append_parameter(sink, separator, "error_description", - response.error_description); + oauth_append_hidden_input(page, "error_description", + response.error_description); } if (!response.error_uri.empty()) { - oauth_append_parameter(sink, separator, "error_uri", response.error_uri); + oauth_append_hidden_input(page, "error_uri", response.error_uri); } if (!response.state.empty()) { - oauth_append_parameter(sink, separator, "state", response.state); + oauth_append_hidden_input(page, "state", response.state); } if (!response.iss.empty()) { - oauth_append_parameter(sink, separator, "iss", response.iss); + oauth_append_hidden_input(page, "iss", response.iss); } + // Close the form, the body, and the document + page.close().close().close(); + sink.append(page.str()); return true; } diff --git a/vendor/core/src/core/oauth/oauth_bearer.cc b/vendor/core/src/core/oauth/oauth_bearer.cc index 09fd1c73..6d14349b 100644 --- a/vendor/core/src/core/oauth/oauth_bearer.cc +++ b/vendor/core/src/core/oauth/oauth_bearer.cc @@ -17,9 +17,9 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_AUD{JSON::Object::hash("aud"sv)}; -const auto HASH_CNF{JSON::Object::hash("cnf"sv)}; -const auto HASH_JKT{JSON::Object::hash("jkt"sv)}; +constexpr auto HASH_AUD{JSON::Object::hash("aud"sv)}; +constexpr auto HASH_CNF{JSON::Object::hash("cnf"sv)}; +constexpr auto HASH_JKT{JSON::Object::hash("jkt"sv)}; auto oauth_skip_ows(const std::string_view value, std::size_t position) noexcept -> std::size_t { diff --git a/vendor/core/src/core/oauth/oauth_client_authentication.cc b/vendor/core/src/core/oauth/oauth_client_authentication.cc index b2f27275..483af20c 100644 --- a/vendor/core/src/core/oauth/oauth_client_authentication.cc +++ b/vendor/core/src/core/oauth/oauth_client_authentication.cc @@ -5,7 +5,6 @@ #include #include "oauth_decode.h" -#include "oauth_encode.h" #include // std::size_t #include // std::string_view @@ -53,13 +52,13 @@ auto oauth_client_secret_basic(const std::string_view client_id, auto oauth_client_secret_post(const std::string_view client_id, const std::string_view client_secret, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "client_id", client_id); - oauth_append_form_parameter(sink, "client_secret", client_secret); + URI::append_query_parameter(sink, "client_id", client_id); + URI::append_query_parameter(sink, "client_secret", client_secret); } auto oauth_client_id_only(const std::string_view client_id, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "client_id", client_id); + URI::append_query_parameter(sink, "client_id", client_id); } auto oauth_parse_client_authentication(const std::string_view authorization, diff --git a/vendor/core/src/core/oauth/oauth_device.cc b/vendor/core/src/core/oauth/oauth_device.cc index a6f1d65f..729a0b40 100644 --- a/vendor/core/src/core/oauth/oauth_device.cc +++ b/vendor/core/src/core/oauth/oauth_device.cc @@ -3,8 +3,8 @@ #include #include #include +#include -#include "oauth_encode.h" #include "oauth_json.h" #include // std::array @@ -29,13 +29,13 @@ using namespace std::literals::string_view_literals; constexpr std::string_view USER_CODE_ALPHABET{"BCDFGHJKLMNPQRSTVWXZ"}; constexpr std::chrono::seconds DEFAULT_INTERVAL{5}; -const auto HASH_DEVICE_CODE{JSON::Object::hash("device_code"sv)}; -const auto HASH_USER_CODE{JSON::Object::hash("user_code"sv)}; -const auto HASH_VERIFICATION_URI{JSON::Object::hash("verification_uri"sv)}; -const auto HASH_VERIFICATION_URI_COMPLETE{ +constexpr auto HASH_DEVICE_CODE{JSON::Object::hash("device_code"sv)}; +constexpr auto HASH_USER_CODE{JSON::Object::hash("user_code"sv)}; +constexpr auto HASH_VERIFICATION_URI{JSON::Object::hash("verification_uri"sv)}; +constexpr auto HASH_VERIFICATION_URI_COMPLETE{ JSON::Object::hash("verification_uri_complete"sv)}; -const auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; -const auto HASH_INTERVAL{JSON::Object::hash("interval"sv)}; +constexpr auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; +constexpr auto HASH_INTERVAL{JSON::Object::hash("interval"sv)}; auto normalize_user_code(const std::string_view value) -> SecureString { SecureString result; @@ -63,15 +63,15 @@ auto oauth_build_device_authorization_request( const std::span resources, std::string &sink) -> void { if (!client_id.empty()) { - oauth_append_form_parameter(sink, "client_id", client_id); + URI::append_query_parameter(sink, "client_id", client_id); } if (!scope.empty()) { - oauth_append_form_parameter(sink, "scope", scope); + URI::append_query_parameter(sink, "scope", scope); } for (const auto &resource : resources) { - oauth_append_form_parameter(sink, resource.name, resource.value); + URI::append_query_parameter(sink, resource.name, resource.value); } } @@ -79,11 +79,11 @@ auto oauth_build_token_request_device( const std::string_view device_code, const std::span resources, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "grant_type", + URI::append_query_parameter(sink, "grant_type", "urn:ietf:params:oauth:grant-type:device_code"); - oauth_append_form_parameter(sink, "device_code", device_code); + URI::append_query_parameter(sink, "device_code", device_code); for (const auto &resource : resources) { - oauth_append_form_parameter(sink, resource.name, resource.value); + URI::append_query_parameter(sink, resource.name, resource.value); } } diff --git a/vendor/core/src/core/oauth/oauth_dpop.cc b/vendor/core/src/core/oauth/oauth_dpop.cc index 367bcfb6..754c4f39 100644 --- a/vendor/core/src/core/oauth/oauth_dpop.cc +++ b/vendor/core/src/core/oauth/oauth_dpop.cc @@ -29,27 +29,27 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_TYP{JSON::Object::hash("typ"sv)}; -const auto HASH_ALG{JSON::Object::hash("alg"sv)}; -const auto HASH_JWK{JSON::Object::hash("jwk"sv)}; -const auto HASH_JTI{JSON::Object::hash("jti"sv)}; -const auto HASH_HTM{JSON::Object::hash("htm"sv)}; -const auto HASH_HTU{JSON::Object::hash("htu"sv)}; -const auto HASH_IAT{JSON::Object::hash("iat"sv)}; -const auto HASH_ATH{JSON::Object::hash("ath"sv)}; -const auto HASH_NONCE{JSON::Object::hash("nonce"sv)}; -const auto HASH_JKT{JSON::Object::hash("jkt"sv)}; +constexpr auto HASH_TYP{JSON::Object::hash("typ"sv)}; +constexpr auto HASH_ALG{JSON::Object::hash("alg"sv)}; +constexpr auto HASH_JWK{JSON::Object::hash("jwk"sv)}; +constexpr auto HASH_JTI{JSON::Object::hash("jti"sv)}; +constexpr auto HASH_HTM{JSON::Object::hash("htm"sv)}; +constexpr auto HASH_HTU{JSON::Object::hash("htu"sv)}; +constexpr auto HASH_IAT{JSON::Object::hash("iat"sv)}; +constexpr auto HASH_ATH{JSON::Object::hash("ath"sv)}; +constexpr auto HASH_NONCE{JSON::Object::hash("nonce"sv)}; +constexpr auto HASH_JKT{JSON::Object::hash("jkt"sv)}; // The private JSON Web Key members whose presence marks a private key (RFC 7518 // Sections 6.2.2 and 6.3.2, RFC 8037 Section 2, RFC 7518 Section 6.4) -const auto HASH_D{JSON::Object::hash("d"sv)}; -const auto HASH_P{JSON::Object::hash("p"sv)}; -const auto HASH_Q{JSON::Object::hash("q"sv)}; -const auto HASH_DP{JSON::Object::hash("dp"sv)}; -const auto HASH_DQ{JSON::Object::hash("dq"sv)}; -const auto HASH_QI{JSON::Object::hash("qi"sv)}; -const auto HASH_OTH{JSON::Object::hash("oth"sv)}; -const auto HASH_K{JSON::Object::hash("k"sv)}; +constexpr auto HASH_D{JSON::Object::hash("d"sv)}; +constexpr auto HASH_P{JSON::Object::hash("p"sv)}; +constexpr auto HASH_Q{JSON::Object::hash("q"sv)}; +constexpr auto HASH_DP{JSON::Object::hash("dp"sv)}; +constexpr auto HASH_DQ{JSON::Object::hash("dq"sv)}; +constexpr auto HASH_QI{JSON::Object::hash("qi"sv)}; +constexpr auto HASH_OTH{JSON::Object::hash("oth"sv)}; +constexpr auto HASH_K{JSON::Object::hash("k"sv)}; // RFC 9449 Section 4.2: the HTTP target URI without query and fragment, with // the syntax and scheme normalization Section 4.3 recommends applied so the @@ -245,9 +245,9 @@ auto oauth_dpop_verify(const std::string_view proof, const auto &parsed{token.value()}; - // Check 4: the token type is dpop+jwt - const auto type{parsed.type()}; - if (!type.has_value() || type.value() != "dpop+jwt") { + // Check 4: the token type is dpop+jwt, comparing the media type as RFC 7515 + // Section 4.1.9 requires (case-insensitive, application/ prefix optional) + if (!parsed.has_type("dpop+jwt")) { return OAuthDPoPError::UnexpectedType; } diff --git a/vendor/core/src/core/oauth/oauth_encode.h b/vendor/core/src/core/oauth/oauth_encode.h deleted file mode 100644 index 0903d6cd..00000000 --- a/vendor/core/src/core/oauth/oauth_encode.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef SOURCEMETA_CORE_OAUTH_ENCODE_H_ -#define SOURCEMETA_CORE_OAUTH_ENCODE_H_ - -#include - -#include // std::string_view - -namespace sourcemeta::core { - -// Append one "application/x-www-form-urlencoded" parameter to a body under -// construction, inserting the "&" separator only between parameters so that -// grant and client authentication builders compose into one buffer. The name -// and value are percent-encoded through the URI escaper into the sink, which is -// a wiping string for a body that carries secrets and an ordinary string -// otherwise -template -inline auto oauth_append_form_parameter(Sink &sink, const std::string_view name, - const std::string_view value) -> void { - if (!sink.empty() && sink.back() != '&') { - sink.push_back('&'); - } - - URI::escape(name, sink); - sink.push_back('='); - URI::escape(value, sink); -} - -} // namespace sourcemeta::core - -#endif diff --git a/vendor/core/src/core/oauth/oauth_introspection.cc b/vendor/core/src/core/oauth/oauth_introspection.cc index 285e8a9f..7ba8fc5e 100644 --- a/vendor/core/src/core/oauth/oauth_introspection.cc +++ b/vendor/core/src/core/oauth/oauth_introspection.cc @@ -2,8 +2,8 @@ #include #include +#include -#include "oauth_encode.h" #include "oauth_json.h" #include // std::chrono::seconds @@ -16,26 +16,26 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_ACTIVE{JSON::Object::hash("active"sv)}; -const auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; -const auto HASH_CLIENT_ID{JSON::Object::hash("client_id"sv)}; -const auto HASH_USERNAME{JSON::Object::hash("username"sv)}; -const auto HASH_TOKEN_TYPE{JSON::Object::hash("token_type"sv)}; -const auto HASH_SUB{JSON::Object::hash("sub"sv)}; -const auto HASH_ISS{JSON::Object::hash("iss"sv)}; -const auto HASH_JTI{JSON::Object::hash("jti"sv)}; -const auto HASH_EXP{JSON::Object::hash("exp"sv)}; -const auto HASH_IAT{JSON::Object::hash("iat"sv)}; -const auto HASH_NBF{JSON::Object::hash("nbf"sv)}; +constexpr auto HASH_ACTIVE{JSON::Object::hash("active"sv)}; +constexpr auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; +constexpr auto HASH_CLIENT_ID{JSON::Object::hash("client_id"sv)}; +constexpr auto HASH_USERNAME{JSON::Object::hash("username"sv)}; +constexpr auto HASH_TOKEN_TYPE{JSON::Object::hash("token_type"sv)}; +constexpr auto HASH_SUB{JSON::Object::hash("sub"sv)}; +constexpr auto HASH_ISS{JSON::Object::hash("iss"sv)}; +constexpr auto HASH_JTI{JSON::Object::hash("jti"sv)}; +constexpr auto HASH_EXP{JSON::Object::hash("exp"sv)}; +constexpr auto HASH_IAT{JSON::Object::hash("iat"sv)}; +constexpr auto HASH_NBF{JSON::Object::hash("nbf"sv)}; } // namespace auto oauth_build_introspection_request(const std::string_view token, const std::string_view token_type_hint, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "token", token); + URI::append_query_parameter(sink, "token", token); if (!token_type_hint.empty()) { - oauth_append_form_parameter(sink, "token_type_hint", token_type_hint); + URI::append_query_parameter(sink, "token_type_hint", token_type_hint); } } diff --git a/vendor/core/src/core/oauth/oauth_metadata.cc b/vendor/core/src/core/oauth/oauth_metadata.cc index ec1a9b87..5d9d5dda 100644 --- a/vendor/core/src/core/oauth/oauth_metadata.cc +++ b/vendor/core/src/core/oauth/oauth_metadata.cc @@ -19,41 +19,41 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_ISSUER{JSON::Object::hash("issuer"sv)}; -const auto HASH_AUTHORIZATION_ENDPOINT{ +constexpr auto HASH_ISSUER{JSON::Object::hash("issuer"sv)}; +constexpr auto HASH_AUTHORIZATION_ENDPOINT{ JSON::Object::hash("authorization_endpoint"sv)}; -const auto HASH_TOKEN_ENDPOINT{JSON::Object::hash("token_endpoint"sv)}; -const auto HASH_REGISTRATION_ENDPOINT{ +constexpr auto HASH_TOKEN_ENDPOINT{JSON::Object::hash("token_endpoint"sv)}; +constexpr auto HASH_REGISTRATION_ENDPOINT{ JSON::Object::hash("registration_endpoint"sv)}; -const auto HASH_PAR_ENDPOINT{ +constexpr auto HASH_PAR_ENDPOINT{ JSON::Object::hash("pushed_authorization_request_endpoint"sv)}; -const auto HASH_REQUIRE_PAR{ +constexpr auto HASH_REQUIRE_PAR{ JSON::Object::hash("require_pushed_authorization_requests"sv)}; -const auto HASH_REVOCATION_ENDPOINT{ +constexpr auto HASH_REVOCATION_ENDPOINT{ JSON::Object::hash("revocation_endpoint"sv)}; -const auto HASH_INTROSPECTION_ENDPOINT{ +constexpr auto HASH_INTROSPECTION_ENDPOINT{ JSON::Object::hash("introspection_endpoint"sv)}; -const auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; -const auto HASH_RESPONSE_TYPES{ +constexpr auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; +constexpr auto HASH_RESPONSE_TYPES{ JSON::Object::hash("response_types_supported"sv)}; -const auto HASH_GRANT_TYPES{JSON::Object::hash("grant_types_supported"sv)}; -const auto HASH_CODE_CHALLENGE_METHODS{ +constexpr auto HASH_GRANT_TYPES{JSON::Object::hash("grant_types_supported"sv)}; +constexpr auto HASH_CODE_CHALLENGE_METHODS{ JSON::Object::hash("code_challenge_methods_supported"sv)}; -const auto HASH_TOKEN_AUTH_METHODS{ +constexpr auto HASH_TOKEN_AUTH_METHODS{ JSON::Object::hash("token_endpoint_auth_methods_supported"sv)}; -const auto HASH_TOKEN_AUTH_ALGS{ +constexpr auto HASH_TOKEN_AUTH_ALGS{ JSON::Object::hash("token_endpoint_auth_signing_alg_values_supported"sv)}; -const auto HASH_ISS_SUPPORTED{ +constexpr auto HASH_ISS_SUPPORTED{ JSON::Object::hash("authorization_response_iss_parameter_supported"sv)}; -const auto HASH_RESOURCE{JSON::Object::hash("resource"sv)}; -const auto HASH_AUTHORIZATION_SERVERS{ +constexpr auto HASH_RESOURCE{JSON::Object::hash("resource"sv)}; +constexpr auto HASH_AUTHORIZATION_SERVERS{ JSON::Object::hash("authorization_servers"sv)}; -const auto HASH_BEARER_METHODS{ +constexpr auto HASH_BEARER_METHODS{ JSON::Object::hash("bearer_methods_supported"sv)}; -const auto HASH_SCOPES_SUPPORTED{JSON::Object::hash("scopes_supported"sv)}; -const auto HASH_DPOP_BOUND_REQUIRED{ +constexpr auto HASH_SCOPES_SUPPORTED{JSON::Object::hash("scopes_supported"sv)}; +constexpr auto HASH_DPOP_BOUND_REQUIRED{ JSON::Object::hash("dpop_bound_access_tokens_required"sv)}; -const auto HASH_RESOURCE_SIGNING_ALGS{ +constexpr auto HASH_RESOURCE_SIGNING_ALGS{ JSON::Object::hash("resource_signing_alg_values_supported"sv)}; auto string_member(const JSON &data, const JSON::StringView name, @@ -71,17 +71,6 @@ auto string_member(const JSON &data, const JSON::StringView name, return std::string_view{member->to_string()}; } -auto array_member_contains(const JSON &data, const JSON::StringView name, - const JSON::Object::hash_type hash, - const JSON::StringView value) -> bool { - if (!data.is_object()) { - return false; - } - - const auto *member{data.try_at(name, hash)}; - return member != nullptr && member->is_array() && member->contains(value); -} - auto validated_server_metadata(JSON &&data, const std::string_view issuer) -> JSON { if (!data.is_object()) { @@ -113,10 +102,11 @@ auto validated_server_metadata(JSON &&data, const std::string_view issuer) // RFC 8414 Section 2: a signing algorithm list is REQUIRED alongside the JWT // authentication methods, "none" MUST NOT appear in it, and Section 3.2 // forbids a zero-element array, so an empty list is also invalid - if (array_member_contains(data, "token_endpoint_auth_methods_supported"sv, - HASH_TOKEN_AUTH_METHODS, "private_key_jwt") || - array_member_contains(data, "token_endpoint_auth_methods_supported"sv, - HASH_TOKEN_AUTH_METHODS, "client_secret_jwt")) { + if (data.array_member_contains("token_endpoint_auth_methods_supported"sv, + HASH_TOKEN_AUTH_METHODS, "private_key_jwt") || + data.array_member_contains("token_endpoint_auth_methods_supported"sv, + HASH_TOKEN_AUTH_METHODS, + "client_secret_jwt")) { const auto *algorithms{ data.try_at("token_endpoint_auth_signing_alg_values_supported"sv, HASH_TOKEN_AUTH_ALGS)}; @@ -330,8 +320,8 @@ auto OAuthServerMetadata::authorization_response_iss_parameter_supported() const auto OAuthServerMetadata::supports_response_type( const std::string_view value) const -> bool { - return array_member_contains(this->data_, "response_types_supported"sv, - HASH_RESPONSE_TYPES, value); + return this->data_.array_member_contains("response_types_supported"sv, + HASH_RESPONSE_TYPES, value); } auto OAuthServerMetadata::supports_grant_type( @@ -353,9 +343,8 @@ auto OAuthServerMetadata::supports_code_challenge_method( const std::string_view value) const -> bool { // RFC 8414 Section 2: an omitted list means PKCE is not supported, so there // is no default to fall back to - return array_member_contains(this->data_, - "code_challenge_methods_supported"sv, - HASH_CODE_CHALLENGE_METHODS, value); + return this->data_.array_member_contains("code_challenge_methods_supported"sv, + HASH_CODE_CHALLENGE_METHODS, value); } auto OAuthServerMetadata::supports_token_endpoint_auth_method( @@ -415,8 +404,8 @@ auto OAuthResourceMetadata::first_authorization_server() const auto OAuthResourceMetadata::supports_authorization_server( const std::string_view value) const -> bool { - return array_member_contains(this->data_, "authorization_servers"sv, - HASH_AUTHORIZATION_SERVERS, value); + return this->data_.array_member_contains("authorization_servers"sv, + HASH_AUTHORIZATION_SERVERS, value); } auto OAuthResourceMetadata::jwks_uri() const @@ -426,14 +415,14 @@ auto OAuthResourceMetadata::jwks_uri() const auto OAuthResourceMetadata::supports_bearer_method( const std::string_view value) const -> bool { - return array_member_contains(this->data_, "bearer_methods_supported"sv, - HASH_BEARER_METHODS, value); + return this->data_.array_member_contains("bearer_methods_supported"sv, + HASH_BEARER_METHODS, value); } auto OAuthResourceMetadata::supports_scope(const std::string_view value) const -> bool { - return array_member_contains(this->data_, "scopes_supported"sv, - HASH_SCOPES_SUPPORTED, value); + return this->data_.array_member_contains("scopes_supported"sv, + HASH_SCOPES_SUPPORTED, value); } auto OAuthResourceMetadata::dpop_bound_access_tokens_required() const -> bool { @@ -450,32 +439,6 @@ auto OAuthResourceMetadata::data() const -> const JSON & { return this->data_; } namespace { -auto assign_scalar_member(JSON &object, const JSON::StringView name, - const JSON::Object::hash_type hash, - const std::string_view value) -> void { - if (!value.empty()) { - object.assign_assume_new(std::string{name}, JSON{value}, hash); - } -} - -auto assign_array_member(JSON &object, const JSON::StringView name, - const JSON::Object::hash_type hash, - const std::span values) - -> void { - // RFC 8414 Section 3.2: "Claims with zero elements MUST be omitted", so an - // empty list is not emitted - if (values.empty()) { - return; - } - - auto array{JSON::make_array()}; - for (const auto value : values) { - array.push_back(JSON{value}); - } - - object.assign_assume_new(std::string{name}, std::move(array), hash); -} - auto span_contains(const std::span values, const std::string_view target) -> bool { return std::ranges::find(values, target) != values.end(); @@ -547,34 +510,33 @@ auto oauth_make_server_metadata(const OAuthServerMetadataConfig &config) auto document{JSON::make_object()}; document.assign_assume_new("issuer", JSON{config.issuer}, HASH_ISSUER); - assign_scalar_member(document, "authorization_endpoint", - HASH_AUTHORIZATION_ENDPOINT, - config.authorization_endpoint); - assign_scalar_member(document, "token_endpoint", HASH_TOKEN_ENDPOINT, - config.token_endpoint); - assign_scalar_member(document, "registration_endpoint", - HASH_REGISTRATION_ENDPOINT, - config.registration_endpoint); - assign_scalar_member(document, "pushed_authorization_request_endpoint", - HASH_PAR_ENDPOINT, - config.pushed_authorization_request_endpoint); - assign_scalar_member(document, "jwks_uri", HASH_JWKS_URI, config.jwks_uri); - assign_array_member(document, "response_types_supported", HASH_RESPONSE_TYPES, - config.response_types_supported); - assign_array_member(document, "grant_types_supported", HASH_GRANT_TYPES, - config.grant_types_supported); - assign_array_member(document, "code_challenge_methods_supported", - HASH_CODE_CHALLENGE_METHODS, - config.code_challenge_methods_supported); - assign_array_member(document, "token_endpoint_auth_methods_supported", - HASH_TOKEN_AUTH_METHODS, - config.token_endpoint_auth_methods_supported); - assign_array_member(document, - "token_endpoint_auth_signing_alg_values_supported", - HASH_TOKEN_AUTH_ALGS, - config.token_endpoint_auth_signing_alg_values_supported); - assign_array_member(document, "scopes_supported", HASH_SCOPES_SUPPORTED, - config.scopes_supported); + document.assign_if_nonempty("authorization_endpoint", + HASH_AUTHORIZATION_ENDPOINT, + config.authorization_endpoint); + document.assign_if_nonempty("token_endpoint", HASH_TOKEN_ENDPOINT, + config.token_endpoint); + document.assign_if_nonempty("registration_endpoint", + HASH_REGISTRATION_ENDPOINT, + config.registration_endpoint); + document.assign_if_nonempty("pushed_authorization_request_endpoint", + HASH_PAR_ENDPOINT, + config.pushed_authorization_request_endpoint); + document.assign_if_nonempty("jwks_uri", HASH_JWKS_URI, config.jwks_uri); + document.assign_if_nonempty("response_types_supported", HASH_RESPONSE_TYPES, + config.response_types_supported); + document.assign_if_nonempty("grant_types_supported", HASH_GRANT_TYPES, + config.grant_types_supported); + document.assign_if_nonempty("code_challenge_methods_supported", + HASH_CODE_CHALLENGE_METHODS, + config.code_challenge_methods_supported); + document.assign_if_nonempty("token_endpoint_auth_methods_supported", + HASH_TOKEN_AUTH_METHODS, + config.token_endpoint_auth_methods_supported); + document.assign_if_nonempty( + "token_endpoint_auth_signing_alg_values_supported", HASH_TOKEN_AUTH_ALGS, + config.token_endpoint_auth_signing_alg_values_supported); + document.assign_if_nonempty("scopes_supported", HASH_SCOPES_SUPPORTED, + config.scopes_supported); // RFC 9126 Section 5: the default is false, so the flag is emitted only when // the server requires pushed authorization requests if (config.require_pushed_authorization_requests) { diff --git a/vendor/core/src/core/oauth/oauth_par.cc b/vendor/core/src/core/oauth/oauth_par.cc index 3d9a4bd5..b24904a0 100644 --- a/vendor/core/src/core/oauth/oauth_par.cc +++ b/vendor/core/src/core/oauth/oauth_par.cc @@ -7,7 +7,6 @@ #include #include "oauth_authorization_parse.h" -#include "oauth_encode.h" #include "oauth_json.h" #include // std::chrono::seconds @@ -23,8 +22,8 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_REQUEST_URI{JSON::Object::hash("request_uri"sv)}; -const auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; +constexpr auto HASH_REQUEST_URI{JSON::Object::hash("request_uri"sv)}; +constexpr auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; // RFC 9126 Section 2.2: the recommended request URI form, a URN whose final // segment is the random reference @@ -38,34 +37,34 @@ auto oauth_build_par_request(const OAuthAuthorizationRequest &request, // RFC 6749 Section 4.1.1: the authorization code flow is the response type // this builder assumes when the request names none, and the client may // override it - oauth_append_form_parameter(sink, "response_type", + URI::append_query_parameter(sink, "response_type", request.response_type.empty() ? std::string_view{"code"} : request.response_type); if (!request.redirect_uri.empty()) { - oauth_append_form_parameter(sink, "redirect_uri", request.redirect_uri); + URI::append_query_parameter(sink, "redirect_uri", request.redirect_uri); } if (!request.scope.empty()) { - oauth_append_form_parameter(sink, "scope", request.scope); + URI::append_query_parameter(sink, "scope", request.scope); } if (!request.state.empty()) { - oauth_append_form_parameter(sink, "state", request.state); + URI::append_query_parameter(sink, "state", request.state); } if (!request.code_challenge.empty()) { - oauth_append_form_parameter(sink, "code_challenge", request.code_challenge); + URI::append_query_parameter(sink, "code_challenge", request.code_challenge); // RFC 7636 Section 4.3: the method qualifies a challenge, so it is omitted // when no challenge is present if (!request.code_challenge_method.empty()) { - oauth_append_form_parameter(sink, "code_challenge_method", + URI::append_query_parameter(sink, "code_challenge_method", request.code_challenge_method); } } if (!request.dpop_jkt.empty()) { - oauth_append_form_parameter(sink, "dpop_jkt", request.dpop_jkt); + URI::append_query_parameter(sink, "dpop_jkt", request.dpop_jkt); } // RFC 9126 Section 2.1: a pushed request MUST NOT provide request_uri, so it @@ -73,13 +72,13 @@ auto oauth_build_par_request(const OAuthAuthorizationRequest &request, // parameters rather than the dedicated field for (const auto &resource : request.resources) { if (resource.name != "request_uri") { - oauth_append_form_parameter(sink, resource.name, resource.value); + URI::append_query_parameter(sink, resource.name, resource.value); } } for (const auto ¶meter : request.extra) { if (parameter.name != "request_uri") { - oauth_append_form_parameter(sink, parameter.name, parameter.value); + URI::append_query_parameter(sink, parameter.name, parameter.value); } } } diff --git a/vendor/core/src/core/oauth/oauth_registration.cc b/vendor/core/src/core/oauth/oauth_registration.cc index 18f4061c..948d75c4 100644 --- a/vendor/core/src/core/oauth/oauth_registration.cc +++ b/vendor/core/src/core/oauth/oauth_registration.cc @@ -20,46 +20,37 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_REDIRECT_URIS{JSON::Object::hash("redirect_uris"sv)}; -const auto HASH_TOKEN_ENDPOINT_AUTH_METHOD{ +constexpr auto HASH_REDIRECT_URIS{JSON::Object::hash("redirect_uris"sv)}; +constexpr auto HASH_TOKEN_ENDPOINT_AUTH_METHOD{ JSON::Object::hash("token_endpoint_auth_method"sv)}; -const auto HASH_GRANT_TYPES{JSON::Object::hash("grant_types"sv)}; -const auto HASH_RESPONSE_TYPES{JSON::Object::hash("response_types"sv)}; -const auto HASH_CLIENT_NAME{JSON::Object::hash("client_name"sv)}; -const auto HASH_CLIENT_URI{JSON::Object::hash("client_uri"sv)}; -const auto HASH_LOGO_URI{JSON::Object::hash("logo_uri"sv)}; -const auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; -const auto HASH_CONTACTS{JSON::Object::hash("contacts"sv)}; -const auto HASH_TOS_URI{JSON::Object::hash("tos_uri"sv)}; -const auto HASH_POLICY_URI{JSON::Object::hash("policy_uri"sv)}; -const auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; -const auto HASH_JWKS{JSON::Object::hash("jwks"sv)}; -const auto HASH_SOFTWARE_ID{JSON::Object::hash("software_id"sv)}; -const auto HASH_SOFTWARE_VERSION{JSON::Object::hash("software_version"sv)}; -const auto HASH_SOFTWARE_STATEMENT{JSON::Object::hash("software_statement"sv)}; -const auto HASH_CLIENT_ID{JSON::Object::hash("client_id"sv)}; -const auto HASH_CLIENT_SECRET{JSON::Object::hash("client_secret"sv)}; -const auto HASH_CLIENT_ID_ISSUED_AT{ +constexpr auto HASH_GRANT_TYPES{JSON::Object::hash("grant_types"sv)}; +constexpr auto HASH_RESPONSE_TYPES{JSON::Object::hash("response_types"sv)}; +constexpr auto HASH_CLIENT_NAME{JSON::Object::hash("client_name"sv)}; +constexpr auto HASH_CLIENT_URI{JSON::Object::hash("client_uri"sv)}; +constexpr auto HASH_LOGO_URI{JSON::Object::hash("logo_uri"sv)}; +constexpr auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; +constexpr auto HASH_CONTACTS{JSON::Object::hash("contacts"sv)}; +constexpr auto HASH_TOS_URI{JSON::Object::hash("tos_uri"sv)}; +constexpr auto HASH_POLICY_URI{JSON::Object::hash("policy_uri"sv)}; +constexpr auto HASH_JWKS_URI{JSON::Object::hash("jwks_uri"sv)}; +constexpr auto HASH_JWKS{JSON::Object::hash("jwks"sv)}; +constexpr auto HASH_SOFTWARE_ID{JSON::Object::hash("software_id"sv)}; +constexpr auto HASH_SOFTWARE_VERSION{JSON::Object::hash("software_version"sv)}; +constexpr auto HASH_SOFTWARE_STATEMENT{ + JSON::Object::hash("software_statement"sv)}; +constexpr auto HASH_CLIENT_ID{JSON::Object::hash("client_id"sv)}; +constexpr auto HASH_CLIENT_SECRET{JSON::Object::hash("client_secret"sv)}; +constexpr auto HASH_CLIENT_ID_ISSUED_AT{ JSON::Object::hash("client_id_issued_at"sv)}; -const auto HASH_CLIENT_SECRET_EXPIRES_AT{ +constexpr auto HASH_CLIENT_SECRET_EXPIRES_AT{ JSON::Object::hash("client_secret_expires_at"sv)}; -const auto HASH_REGISTRATION_ACCESS_TOKEN{ +constexpr auto HASH_REGISTRATION_ACCESS_TOKEN{ JSON::Object::hash("registration_access_token"sv)}; -const auto HASH_REGISTRATION_CLIENT_URI{ +constexpr auto HASH_REGISTRATION_CLIENT_URI{ JSON::Object::hash("registration_client_uri"sv)}; -const auto HASH_ERROR{JSON::Object::hash("error"sv)}; -const auto HASH_ERROR_DESCRIPTION{JSON::Object::hash("error_description"sv)}; - -auto array_member_contains(const JSON &data, const JSON::StringView name, - const JSON::Object::hash_type hash, - const JSON::StringView value) -> bool { - if (!data.is_object()) { - return false; - } - - const auto *member{data.try_at(name, hash)}; - return member != nullptr && member->is_array() && member->contains(value); -} +constexpr auto HASH_ERROR{JSON::Object::hash("error"sv)}; +constexpr auto HASH_ERROR_DESCRIPTION{ + JSON::Object::hash("error_description"sv)}; auto array_member_is_present(const JSON &data, const JSON::StringView name, const JSON::Object::hash_type hash) -> bool { @@ -67,20 +58,6 @@ auto array_member_is_present(const JSON &data, const JSON::StringView name, return member != nullptr && member->is_array(); } -auto is_string_array(const JSON &value) -> bool { - if (!value.is_array()) { - return false; - } - - for (const auto &element : value.as_array()) { - if (!element.is_string()) { - return false; - } - } - - return true; -} - // A membership predicate that falls back to the specification default when the // array is absent, so an omitted list is read as the default value rather than // as an empty registration (RFC 7591 Section 2) @@ -123,8 +100,8 @@ auto validated_client_metadata(JSON &&data) -> JSON { data.try_at("response_types"sv, HASH_RESPONSE_TYPES)}; const auto *auth_method{data.try_at("token_endpoint_auth_method"sv, HASH_TOKEN_ENDPOINT_AUTH_METHOD)}; - if ((grant_types != nullptr && !is_string_array(*grant_types)) || - (response_types != nullptr && !is_string_array(*response_types)) || + if ((grant_types != nullptr && !grant_types->is_array_of_strings()) || + (response_types != nullptr && !response_types->is_array_of_strings()) || (auth_method != nullptr && !auth_method->is_string())) { throw OAuthRegistrationParseError{}; } @@ -132,30 +109,6 @@ auto validated_client_metadata(JSON &&data) -> JSON { return std::move(data); } -auto assign_scalar_member(JSON &object, const JSON::StringView name, - const JSON::Object::hash_type hash, - const std::string_view value) -> void { - if (!value.empty()) { - object.assign_assume_new(std::string{name}, JSON{value}, hash); - } -} - -auto assign_array_member(JSON &object, const JSON::StringView name, - const JSON::Object::hash_type hash, - const std::span values) - -> void { - if (values.empty()) { - return; - } - - auto array{JSON::make_array()}; - for (const auto value : values) { - array.push_back(JSON{value}); - } - - object.assign_assume_new(std::string{name}, std::move(array), hash); -} - // RFC 7519 Section 4.1: the registered claims describe the JSON Web Token that // carries the software statement rather than the client, so they are not // flattened onto the registration record as client metadata (RFC 7591 @@ -182,8 +135,8 @@ auto OAuthClientMetadata::from(JSON &&data) auto OAuthClientMetadata::has_redirect_uri(const std::string_view value) const -> bool { - return array_member_contains(this->data_, "redirect_uris"sv, - HASH_REDIRECT_URIS, value); + return this->data_.array_member_contains("redirect_uris"sv, + HASH_REDIRECT_URIS, value); } auto OAuthClientMetadata::token_endpoint_auth_method() const @@ -234,7 +187,7 @@ auto OAuthClientMetadata::scope() const -> std::optional { auto OAuthClientMetadata::has_contact(const std::string_view value) const -> bool { - return array_member_contains(this->data_, "contacts"sv, HASH_CONTACTS, value); + return this->data_.array_member_contains("contacts"sv, HASH_CONTACTS, value); } auto OAuthClientMetadata::tos_uri() const -> std::optional { @@ -339,36 +292,34 @@ auto oauth_make_registration_request( } auto document{JSON::make_object()}; - assign_array_member(document, "redirect_uris", HASH_REDIRECT_URIS, - config.redirect_uris); + document.assign_if_nonempty("redirect_uris", HASH_REDIRECT_URIS, + config.redirect_uris); if (config.jwks != nullptr) { document.assign_assume_new(std::string{"jwks"}, JSON{*config.jwks}, HASH_JWKS); } - assign_scalar_member(document, "token_endpoint_auth_method", - HASH_TOKEN_ENDPOINT_AUTH_METHOD, - config.token_endpoint_auth_method); - assign_array_member(document, "grant_types", HASH_GRANT_TYPES, - config.grant_types); - assign_array_member(document, "response_types", HASH_RESPONSE_TYPES, - config.response_types); - assign_scalar_member(document, "client_name", HASH_CLIENT_NAME, - config.client_name); - assign_scalar_member(document, "client_uri", HASH_CLIENT_URI, - config.client_uri); - assign_scalar_member(document, "logo_uri", HASH_LOGO_URI, config.logo_uri); - assign_scalar_member(document, "scope", HASH_SCOPE, config.scope); - assign_array_member(document, "contacts", HASH_CONTACTS, config.contacts); - assign_scalar_member(document, "tos_uri", HASH_TOS_URI, config.tos_uri); - assign_scalar_member(document, "policy_uri", HASH_POLICY_URI, - config.policy_uri); - assign_scalar_member(document, "jwks_uri", HASH_JWKS_URI, config.jwks_uri); - assign_scalar_member(document, "software_id", HASH_SOFTWARE_ID, - config.software_id); - assign_scalar_member(document, "software_version", HASH_SOFTWARE_VERSION, - config.software_version); - assign_scalar_member(document, "software_statement", HASH_SOFTWARE_STATEMENT, - config.software_statement); + document.assign_if_nonempty("token_endpoint_auth_method", + HASH_TOKEN_ENDPOINT_AUTH_METHOD, + config.token_endpoint_auth_method); + document.assign_if_nonempty("grant_types", HASH_GRANT_TYPES, + config.grant_types); + document.assign_if_nonempty("response_types", HASH_RESPONSE_TYPES, + config.response_types); + document.assign_if_nonempty("client_name", HASH_CLIENT_NAME, + config.client_name); + document.assign_if_nonempty("client_uri", HASH_CLIENT_URI, config.client_uri); + document.assign_if_nonempty("logo_uri", HASH_LOGO_URI, config.logo_uri); + document.assign_if_nonempty("scope", HASH_SCOPE, config.scope); + document.assign_if_nonempty("contacts", HASH_CONTACTS, config.contacts); + document.assign_if_nonempty("tos_uri", HASH_TOS_URI, config.tos_uri); + document.assign_if_nonempty("policy_uri", HASH_POLICY_URI, config.policy_uri); + document.assign_if_nonempty("jwks_uri", HASH_JWKS_URI, config.jwks_uri); + document.assign_if_nonempty("software_id", HASH_SOFTWARE_ID, + config.software_id); + document.assign_if_nonempty("software_version", HASH_SOFTWARE_VERSION, + config.software_version); + document.assign_if_nonempty("software_statement", HASH_SOFTWARE_STATEMENT, + config.software_statement); return document; } @@ -396,14 +347,14 @@ auto oauth_registration_grant_response_consistent( const bool response_types_present{ data.is_object() && array_member_is_present(data, "response_types"sv, HASH_RESPONSE_TYPES)}; - const bool grants_authorization_code{array_member_contains( - data, "grant_types"sv, HASH_GRANT_TYPES, "authorization_code"sv)}; - const bool grants_implicit{array_member_contains( - data, "grant_types"sv, HASH_GRANT_TYPES, "implicit"sv)}; - const bool responds_code{array_member_contains( - data, "response_types"sv, HASH_RESPONSE_TYPES, "code"sv)}; - const bool responds_token{array_member_contains( - data, "response_types"sv, HASH_RESPONSE_TYPES, "token"sv)}; + const bool grants_authorization_code{data.array_member_contains( + "grant_types"sv, HASH_GRANT_TYPES, "authorization_code"sv)}; + const bool grants_implicit{data.array_member_contains( + "grant_types"sv, HASH_GRANT_TYPES, "implicit"sv)}; + const bool responds_code{data.array_member_contains( + "response_types"sv, HASH_RESPONSE_TYPES, "code"sv)}; + const bool responds_token{data.array_member_contains( + "response_types"sv, HASH_RESPONSE_TYPES, "token"sv)}; // RFC 7591 Section 2.1: the authorization code grant pairs with the code // response type and the implicit grant with the token response type. Only diff --git a/vendor/core/src/core/oauth/oauth_revocation.cc b/vendor/core/src/core/oauth/oauth_revocation.cc index c8415756..d40231f0 100644 --- a/vendor/core/src/core/oauth/oauth_revocation.cc +++ b/vendor/core/src/core/oauth/oauth_revocation.cc @@ -5,7 +5,6 @@ #include #include "oauth_decode.h" -#include "oauth_encode.h" #include // std::function #include // std::string_view @@ -35,9 +34,9 @@ auto assign_lookup_scalar(const std::string_view value, SecureString &storage, auto oauth_build_revocation_request(const std::string_view token, const std::string_view token_type_hint, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "token", token); + URI::append_query_parameter(sink, "token", token); if (!token_type_hint.empty()) { - oauth_append_form_parameter(sink, "token_type_hint", token_type_hint); + URI::append_query_parameter(sink, "token_type_hint", token_type_hint); } } diff --git a/vendor/core/src/core/oauth/oauth_token.cc b/vendor/core/src/core/oauth/oauth_token.cc index 6f2fc2df..a01ceccc 100644 --- a/vendor/core/src/core/oauth/oauth_token.cc +++ b/vendor/core/src/core/oauth/oauth_token.cc @@ -5,7 +5,6 @@ #include #include "oauth_decode.h" -#include "oauth_encode.h" #include // std::chrono::seconds #include // std::size_t @@ -21,14 +20,15 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_ACCESS_TOKEN{JSON::Object::hash("access_token"sv)}; -const auto HASH_TOKEN_TYPE{JSON::Object::hash("token_type"sv)}; -const auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; -const auto HASH_REFRESH_TOKEN{JSON::Object::hash("refresh_token"sv)}; -const auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; -const auto HASH_ERROR{JSON::Object::hash("error"sv)}; -const auto HASH_ERROR_DESCRIPTION{JSON::Object::hash("error_description"sv)}; -const auto HASH_ERROR_URI{JSON::Object::hash("error_uri"sv)}; +constexpr auto HASH_ACCESS_TOKEN{JSON::Object::hash("access_token"sv)}; +constexpr auto HASH_TOKEN_TYPE{JSON::Object::hash("token_type"sv)}; +constexpr auto HASH_EXPIRES_IN{JSON::Object::hash("expires_in"sv)}; +constexpr auto HASH_REFRESH_TOKEN{JSON::Object::hash("refresh_token"sv)}; +constexpr auto HASH_SCOPE{JSON::Object::hash("scope"sv)}; +constexpr auto HASH_ERROR{JSON::Object::hash("error"sv)}; +constexpr auto HASH_ERROR_DESCRIPTION{ + JSON::Object::hash("error_description"sv)}; +constexpr auto HASH_ERROR_URI{JSON::Object::hash("error_uri"sv)}; auto string_member(const JSON &data, const JSON::StringView name, const JSON::Object::hash_type hash) @@ -49,7 +49,7 @@ auto oauth_append_resources(SecureString &sink, const std::span resources) -> void { for (const auto &resource : resources) { - oauth_append_form_parameter(sink, resource.name, resource.value); + URI::append_query_parameter(sink, resource.name, resource.value); } } @@ -79,14 +79,14 @@ auto oauth_build_token_request_code( const std::string_view code_verifier, const std::span resources, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "grant_type", "authorization_code"); - oauth_append_form_parameter(sink, "code", code); + URI::append_query_parameter(sink, "grant_type", "authorization_code"); + URI::append_query_parameter(sink, "code", code); if (!redirect_uri.empty()) { - oauth_append_form_parameter(sink, "redirect_uri", redirect_uri); + URI::append_query_parameter(sink, "redirect_uri", redirect_uri); } if (!code_verifier.empty()) { - oauth_append_form_parameter(sink, "code_verifier", code_verifier); + URI::append_query_parameter(sink, "code_verifier", code_verifier); } oauth_append_resources(sink, resources); @@ -96,10 +96,10 @@ auto oauth_build_token_request_refresh( const std::string_view refresh_token, const std::string_view scope, const std::span resources, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "grant_type", "refresh_token"); - oauth_append_form_parameter(sink, "refresh_token", refresh_token); + URI::append_query_parameter(sink, "grant_type", "refresh_token"); + URI::append_query_parameter(sink, "refresh_token", refresh_token); if (!scope.empty()) { - oauth_append_form_parameter(sink, "scope", scope); + URI::append_query_parameter(sink, "scope", scope); } oauth_append_resources(sink, resources); @@ -109,9 +109,9 @@ auto oauth_build_token_request_client_credentials( const std::string_view scope, const std::span resources, SecureString &sink) -> void { - oauth_append_form_parameter(sink, "grant_type", "client_credentials"); + URI::append_query_parameter(sink, "grant_type", "client_credentials"); if (!scope.empty()) { - oauth_append_form_parameter(sink, "scope", scope); + URI::append_query_parameter(sink, "scope", scope); } oauth_append_resources(sink, resources); diff --git a/vendor/core/src/core/oauth/oauth_token_exchange.cc b/vendor/core/src/core/oauth/oauth_token_exchange.cc index 6135fd42..adb5fa18 100644 --- a/vendor/core/src/core/oauth/oauth_token_exchange.cc +++ b/vendor/core/src/core/oauth/oauth_token_exchange.cc @@ -2,8 +2,8 @@ #include #include +#include -#include "oauth_encode.h" #include "oauth_json.h" #include // std::optional, std::nullopt @@ -16,13 +16,14 @@ namespace { using namespace std::literals::string_view_literals; -const auto HASH_ISSUED_TOKEN_TYPE{JSON::Object::hash("issued_token_type"sv)}; +constexpr auto HASH_ISSUED_TOKEN_TYPE{ + JSON::Object::hash("issued_token_type"sv)}; auto oauth_append_repeated(SecureString &sink, const std::string_view name, const std::span values) -> void { for (const auto value : values) { - oauth_append_form_parameter(sink, name, value); + URI::append_query_parameter(sink, name, value); } } @@ -43,24 +44,24 @@ auto oauth_build_token_request_exchange( return false; } - oauth_append_form_parameter( + URI::append_query_parameter( sink, "grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); - oauth_append_form_parameter(sink, "subject_token", request.subject_token); - oauth_append_form_parameter(sink, "subject_token_type", + URI::append_query_parameter(sink, "subject_token", request.subject_token); + URI::append_query_parameter(sink, "subject_token_type", request.subject_token_type); if (!request.actor_token.empty()) { - oauth_append_form_parameter(sink, "actor_token", request.actor_token); - oauth_append_form_parameter(sink, "actor_token_type", + URI::append_query_parameter(sink, "actor_token", request.actor_token); + URI::append_query_parameter(sink, "actor_token_type", request.actor_token_type); } if (!request.requested_token_type.empty()) { - oauth_append_form_parameter(sink, "requested_token_type", + URI::append_query_parameter(sink, "requested_token_type", request.requested_token_type); } if (!request.scope.empty()) { - oauth_append_form_parameter(sink, "scope", request.scope); + URI::append_query_parameter(sink, "scope", request.scope); } // RFC 8693 Section 2.1 and RFC 8707 Section 2: each audience and resource is diff --git a/vendor/core/src/core/oidc/CMakeLists.txt b/vendor/core/src/core/oidc/CMakeLists.txt new file mode 100644 index 00000000..ff80de25 --- /dev/null +++ b/vendor/core/src/core/oidc/CMakeLists.txt @@ -0,0 +1,27 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oidc + PRIVATE_HEADERS error.h metadata.h discovery.h hash.h id_token.h + authentication.h claims.h userinfo.h registration.h subject.h + request_object.h logout.h encryption.h profile.h + SOURCES oidc_error.cc oidc_metadata.cc oidc_discovery.cc oidc_hash.cc + oidc_id_token.cc oidc_authentication.cc oidc_claims.cc oidc_userinfo.cc + oidc_registration.cc oidc_subject.cc oidc_verify.h oidc_request_object.cc + oidc_logout.cc oidc_encryption.cc) + +target_link_libraries(sourcemeta_core_oidc + PUBLIC sourcemeta::core::json) +target_link_libraries(sourcemeta_core_oidc + PUBLIC sourcemeta::core::jose) +target_link_libraries(sourcemeta_core_oidc + PUBLIC sourcemeta::core::oauth) +target_link_libraries(sourcemeta_core_oidc + PRIVATE sourcemeta::core::crypto) +target_link_libraries(sourcemeta_core_oidc + PRIVATE sourcemeta::core::time) +target_link_libraries(sourcemeta_core_oidc + PRIVATE sourcemeta::core::uri) +target_link_libraries(sourcemeta_core_oidc + PRIVATE sourcemeta::core::text) + +if(SOURCEMETA_CORE_INSTALL) + sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oidc) +endif() diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc.h new file mode 100644 index 00000000..a37d1da1 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc.h @@ -0,0 +1,35 @@ +#ifndef SOURCEMETA_CORE_OIDC_H_ +#define SOURCEMETA_CORE_OIDC_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +// NOLINTEND(misc-include-cleaner) + +/// @defgroup oidc OpenID Connect +/// @brief A standards-driven implementation of the OpenID Connect 1.0 message +/// family. +/// +/// This functionality is included as follows: +/// +/// ```cpp +/// #include +/// ``` + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h new file mode 100644 index 00000000..fc479d72 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_authentication.h @@ -0,0 +1,188 @@ +#ifndef SOURCEMETA_CORE_OIDC_AUTHENTICATION_H_ +#define SOURCEMETA_CORE_OIDC_AUTHENTICATION_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include + +#include // std::array +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Generate a fresh nonce for an authentication request, an unguessable value +/// bound to the session and returned in the ID Token (OpenID Connect Core 1.0 +/// Section 15.5.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto nonce{sourcemeta::core::oidc_nonce()}; +/// assert(nonce.size() == 43); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_nonce() -> std::array; + +/// @ingroup oidc +/// An OpenID Connect authentication request, the OAuth authorization request +/// extended with the OpenID Connect parameters (OpenID Connect Core 1.0 +/// Section 3.1.2.1). Each field is a non-owning view. +struct OIDCAuthenticationRequest { + /// The client identifier (OpenID Connect Core 1.0 Section 3.1.2.1), REQUIRED. + std::string_view client_id; + /// The redirection URI (OpenID Connect Core 1.0 Section 3.1.2.1), REQUIRED. + std::string_view redirect_uri; + /// The space-delimited scope, which must contain `openid` (OpenID Connect + /// Core 1.0 Section 3.1.2.1), REQUIRED. + std::string_view scope; + /// The response type, defaulting to `code` (OpenID Connect Core 1.0 + /// Section 3.1.2.1). + std::string_view response_type; + /// The opaque CSRF state value (OpenID Connect Core 1.0 Section 3.1.2.1). + std::string_view state; + /// The PKCE code challenge (RFC 7636 Section 4.3). + std::string_view code_challenge; + /// The PKCE code challenge method (RFC 7636 Section 4.3). + std::string_view code_challenge_method; + /// The string bound to the session and returned in the ID Token (OpenID + /// Connect Core 1.0 Section 3.1.2.1). + std::string_view nonce; + /// How the provider displays the authentication interface (OpenID Connect + /// Core 1.0 Section 3.1.2.1). + std::string_view display; + /// The space-delimited prompt values, where `none` must appear alone (OpenID + /// Connect Core 1.0 Section 3.1.2.1). + std::string_view prompt; + /// The maximum authentication age in seconds (OpenID Connect Core 1.0 + /// Section 3.1.2.1). + std::string_view max_age; + /// The space-delimited preferred UI locales (OpenID Connect Core 1.0 + /// Section 3.1.2.1). + std::string_view ui_locales; + /// The ID Token previously issued, hinting the session (OpenID Connect Core + /// 1.0 Section 3.1.2.1). + std::string_view id_token_hint; + /// A hint about the login identifier (OpenID Connect Core 1.0 + /// Section 3.1.2.1). + std::string_view login_hint; + /// The space-delimited requested authentication context class references + /// (OpenID Connect Core 1.0 Section 3.1.2.1). + std::string_view acr_values; + /// The serialized `claims` request parameter (OpenID Connect Core 1.0 + /// Section 5.5). + std::string_view claims; + /// The request object carried by value as a signed JWT (OpenID Connect Core + /// 1.0 Section 6.1). + std::string_view request; + /// The request object carried by reference (OpenID Connect Core 1.0 + /// Section 6.2). + std::string_view request_uri; + /// The response mode override (OAuth 2.0 Multiple Response Type Encoding + /// Practices). + std::string_view response_mode; +}; + +/// @ingroup oidc +/// Build an OpenID Connect authentication request URL from an endpoint and a +/// request, returning whether the request is well formed (OpenID Connect Core +/// 1.0 Section 3.1.2.1). The `client_id` and `redirect_uri` are REQUIRED, the +/// `scope` must contain `openid`, when `prompt` carries `none` it must be the +/// only value, and `offline_access` cannot pair with a `none` prompt. The +/// `response_type` is limited by the profile, which permits the Authorization +/// Code flow by default and additionally the Hybrid `code id_token` flow under +/// `OIDCProfile::Legacy`, any flow that returns an ID Token requires a `nonce`, +/// and `OIDCProfile::Strict` requires a `code_challenge` with the `S256` method +/// (PKCE). The OpenID Connect parameters are appended to the OAuth +/// authorization query, percent-escaped, and the sink is appended to and never +/// cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OIDCAuthenticationRequest request; +/// request.client_id = "s6BhdRkqt3"; +/// request.redirect_uri = "https://client.example/cb"; +/// request.scope = "openid profile"; +/// request.nonce = "n-0S6_WzA2Mj"; +/// request.code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; +/// request.code_challenge_method = "S256"; +/// std::string url; +/// assert(sourcemeta::core::oidc_build_authentication_url( +/// "https://server.example/authorize", request, url)); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_build_authentication_url( + const std::string_view endpoint, const OIDCAuthenticationRequest &request, + std::string &sink, const OIDCProfile profile = OIDCProfile::Strict) -> bool; + +/// @ingroup oidc +/// A convenience for the common authorization code flow authentication request, +/// enforcing `scope=openid`, `response_type=code`, and the PKCE S256 method, +/// returning the URL or no value when the request is malformed (OpenID Connect +/// Core 1.0 Section 3.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto url{sourcemeta::core::oidc_authorization_url( +/// "https://server.example/authorize", "s6BhdRkqt3", +/// "https://client.example/cb", "xyz", +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "n-0S6_WzA2Mj")}; +/// assert(url.has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_authorization_url(const std::string_view authorization_endpoint, + const std::string_view client_id, + const std::string_view redirect_uri, + const std::string_view state, + const std::string_view code_challenge, + const std::string_view nonce) + -> std::optional; + +/// @ingroup oidc +/// Parse the query of an OpenID Connect authentication request at the provider +/// into the result, returning whether it is well formed (OpenID Connect Core +/// 1.0 Section 3.1.2.1). The `client_id`, `redirect_uri`, and `response_type` +/// are REQUIRED, the scope must contain `openid`, a `none` prompt must appear +/// alone, `offline_access` cannot pair with a `none` prompt, the +/// `response_type` is limited by the profile, and `OIDCProfile::Strict` +/// requires a `code_challenge` with the `S256` method (PKCE), the same checks +/// the builder applies. Each recognized value is form-decoded, borrowing from +/// the input when it carries no escape and otherwise from the storage arena, +/// which the caller owns and reuses across parses. The result is reset first, +/// then borrows from the input and the storage, so both must outlive it. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// sourcemeta::core::OIDCAuthenticationRequest request; +/// assert(sourcemeta::core::oidc_parse_authentication_request( +/// "response_type=code&client_id=s6BhdRkqt3&" +/// "redirect_uri=https%3A%2F%2Fclient.example%2Fcb&scope=openid&" +/// "nonce=n-0S6&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&" +/// "code_challenge_method=S256", +/// storage, request)); +/// assert(request.nonce == "n-0S6"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_parse_authentication_request( + const std::string_view query, std::string &storage, + OIDCAuthenticationRequest &result, + const OIDCProfile profile = OIDCProfile::Strict) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h new file mode 100644 index 00000000..d985b844 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_claims.h @@ -0,0 +1,173 @@ +#ifndef SOURCEMETA_CORE_OIDC_CLAIMS_H_ +#define SOURCEMETA_CORE_OIDC_CLAIMS_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include + +#include // std::function +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Whether a name is one of the OpenID Connect standard claims (OpenID Connect +/// Core 1.0 Section 5.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_is_standard_claim("email")); +/// assert(!sourcemeta::core::oidc_is_standard_claim("custom_claim")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_is_standard_claim(const std::string_view name) noexcept -> bool; + +/// @ingroup oidc +/// Invoke the callback with each standard claim that the space-delimited scopes +/// request (OpenID Connect Core 1.0 Section 5.4). The `openid` scope maps to +/// `sub`, and `profile`, `email`, `address`, and `phone` map to their claim +/// sets. A claim requested by more than one scope is reported once. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// bool has_email{false}; +/// sourcemeta::core::oidc_scope_to_claims( +/// "openid email", [&has_email](std::string_view claim) { +/// has_email = has_email || claim == "email"; +/// }); +/// assert(has_email); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_scope_to_claims(const std::string_view scopes, + const std::function &on_claim) + -> void; + +/// @ingroup oidc +/// A claim requested through the `claims` request parameter (OpenID Connect +/// Core 1.0 Section 5.5). +struct OIDCClaimRequest { + /// The requested claim name. + std::string_view name; + /// Whether the claim is essential (OpenID Connect Core 1.0 Section 5.5.1). + bool essential{false}; + /// A specific value the claim is requested to have, or no value (OpenID + /// Connect Core 1.0 Section 5.5.1). + const JSON *value{nullptr}; + /// A set of values the claim is requested to have one of, in order of + /// preference (OpenID Connect Core 1.0 Section 5.5.1). + std::span values{}; +}; + +/// @ingroup oidc +/// Build a `claims` request parameter object requesting the given claims for +/// the UserInfo endpoint and the ID Token (OpenID Connect Core 1.0 +/// Section 5.5). An essential claim is requested with `{"essential":true}`, and +/// a voluntary claim with `null`. A target with no claims is omitted. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const std::array userinfo{ +/// {{.name = "email", .essential = true}}}; +/// const auto document{ +/// sourcemeta::core::oidc_build_claims_parameter(userinfo, {})}; +/// assert(document.defines("userinfo")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_build_claims_parameter( + const std::span userinfo_claims, + const std::span id_token_claims) -> JSON; + +/// @ingroup oidc +/// Whether a `claims` request parameter requests a claim for a target member, +/// which is `userinfo` or `id_token` (OpenID Connect Core 1.0 Section 5.5). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto claims{sourcemeta::core::parse_json( +/// R"JSON({"id_token":{"auth_time":{"essential":true}}})JSON")}; +/// assert(sourcemeta::core::oidc_claims_parameter_requests( +/// claims, "id_token", "auth_time")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_claims_parameter_requests(const JSON &claims, + const std::string_view target, + const std::string_view claim) -> bool; + +/// @ingroup oidc +/// Whether a `claims` request parameter marks a claim as essential for a target +/// member, which is `userinfo` or `id_token` (OpenID Connect Core 1.0 +/// Section 5.5.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto claims{sourcemeta::core::parse_json( +/// R"JSON({"id_token":{"auth_time":{"essential":true}}})JSON")}; +/// assert(sourcemeta::core::oidc_claims_parameter_is_essential( +/// claims, "id_token", "auth_time")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_claims_parameter_is_essential(const JSON &claims, + const std::string_view target, + const std::string_view claim) -> bool; + +/// @ingroup oidc +/// The specific value a `claims` request parameter asks a claim to have for a +/// target member, which is `userinfo` or `id_token`, or no value when none is +/// requested (OpenID Connect Core 1.0 Section 5.5.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto claims{sourcemeta::core::parse_json( +/// R"JSON({"id_token":{"acr":{"value":"urn:mace:silver"}}})JSON")}; +/// const auto *value{sourcemeta::core::oidc_claims_parameter_value( +/// claims, "id_token", "acr")}; +/// assert(value != nullptr && value->to_string() == "urn:mace:silver"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_claims_parameter_value(const JSON &claims, + const std::string_view target, + const std::string_view claim) -> const JSON *; + +/// @ingroup oidc +/// Whether the request for a claim permits the given value for a target member, +/// which is `userinfo` or `id_token` (OpenID Connect Core 1.0 Section 5.5.1). A +/// request with neither a `value` nor a `values` constraint permits any value, +/// and a claim that is not requested permits none. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto claims{sourcemeta::core::parse_json( +/// R"JSON({"id_token":{"acr":{"values":["a","b"]}}})JSON")}; +/// assert(sourcemeta::core::oidc_claims_parameter_accepts( +/// claims, "id_token", "acr", sourcemeta::core::JSON{"b"})); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_claims_parameter_accepts(const JSON &claims, + const std::string_view target, + const std::string_view claim, + const JSON &value) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_discovery.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_discovery.h new file mode 100644 index 00000000..22c75773 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_discovery.h @@ -0,0 +1,93 @@ +#ifndef SOURCEMETA_CORE_OIDC_DISCOVERY_H_ +#define SOURCEMETA_CORE_OIDC_DISCOVERY_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Derive the OpenID Provider Configuration well-known URL from an issuer +/// identifier, returning no value when the issuer is not a valid identifier +/// (OpenID Connect Discovery 1.0 Section 4.1). Unlike the RFC 8414 form, the +/// well-known string is appended after the issuer path rather than inserted +/// before it. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto url{sourcemeta::core::oidc_discovery_url("https://example.com")}; +/// assert(url.has_value()); +/// assert(url.value() == +/// "https://example.com/.well-known/openid-configuration"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_discovery_url(const std::string_view issuer) + -> std::optional; + +/// @ingroup oidc +/// A WebFinger issuer discovery request (OpenID Connect Discovery 1.0 +/// Section 2), owning the normalized resource and the request URL. +struct OIDCWebFingerRequest { + /// The normalized resource identifier, an `acct:` URI or an https URL (OpenID + /// Connect Discovery 1.0 Section 2.1). + std::string resource; + /// The WebFinger request URL to retrieve (OpenID Connect Discovery 1.0 + /// Section 2). + std::string url; +}; + +/// @ingroup oidc +/// Normalize a user-supplied identifier and build its WebFinger issuer +/// discovery request, returning no value when the identifier has no host +/// (OpenID Connect Discovery 1.0 Section 2, 2.1). An `acct:` identifier and an +/// https URL are kept as the resource, a bare `user@host` becomes an `acct:` +/// URI, and any other input is treated as an https URL. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto request{sourcemeta::core::oidc_webfinger_request( +/// "acct:joe@example.com")}; +/// assert(request.has_value()); +/// assert(request.value().url.starts_with( +/// "https://example.com/.well-known/webfinger")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_webfinger_request(const std::string_view identifier) + -> std::optional; + +/// @ingroup oidc +/// Extract the issuer from a WebFinger JSON Resource Descriptor, the `href` of +/// the link whose `rel` is the OpenID Connect issuer relation and whose value +/// is a valid https issuer identifier, returning no value when none is present +/// (OpenID Connect Discovery 1.0 Section 2). The returned view borrows from the +/// descriptor, which must outlive it. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto jrd{sourcemeta::core::parse_json(R"JSON({"links":[{ +/// "rel":"http://openid.net/specs/connect/1.0/issuer", +/// "href":"https://example.com"}]})JSON")}; +/// const auto issuer{sourcemeta::core::oidc_webfinger_issuer(jrd)}; +/// assert(issuer.has_value()); +/// assert(issuer.value() == "https://example.com"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_webfinger_issuer(const JSON &descriptor) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_encryption.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_encryption.h new file mode 100644 index 00000000..5543d781 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_encryption.h @@ -0,0 +1,41 @@ +#ifndef SOURCEMETA_CORE_OIDC_ENCRYPTION_H_ +#define SOURCEMETA_CORE_OIDC_ENCRYPTION_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Decrypt a nested encrypted token, recovering the inner signed JWT from a +/// compact JWE, returning no value when the input is not a valid JWE or the key +/// cannot decrypt it (OpenID Connect Core 1.0 Section 10.2). OpenID Connect +/// encrypted ID Tokens, UserInfo responses, and request objects are a signed +/// JWT wrapped in a JWE, so the Relying Party decrypts first and then verifies +/// the recovered signature. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto inner{sourcemeta::core::oidc_decrypt_nested_jwt(compact, key)}; +/// if (inner.has_value()) { +/// const auto token{sourcemeta::core::JWT::from(inner.value())}; +/// assert(token.has_value()); +/// } +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_decrypt_nested_jwt(const std::string_view compact, + const JWKPrivate &key) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_error.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_error.h new file mode 100644 index 00000000..1af3c2bc --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_error.h @@ -0,0 +1,108 @@ +#ifndef SOURCEMETA_CORE_OIDC_ERROR_H_ +#define SOURCEMETA_CORE_OIDC_ERROR_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include // std::uint8_t +#include // std::exception +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251 4275) +#endif + +/// @ingroup oidc +/// An error that occurs when parsing an invalid OpenID Provider metadata +/// document. +class SOURCEMETA_CORE_OIDC_EXPORT OIDCMetadataParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OpenID Provider metadata document"; + } +}; + +/// @ingroup oidc +/// An error that occurs when parsing an invalid OpenID Connect client +/// registration document. +class SOURCEMETA_CORE_OIDC_EXPORT OIDCRegistrationParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OpenID Connect client registration " + "document"; + } +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251 4275) +#endif + +/// @ingroup oidc +/// The error codes an authentication endpoint returns in addition to the OAuth +/// authorization error codes (OpenID Connect Core 1.0 Section 3.1.2.6). +enum class OIDCAuthenticationError : std::uint8_t { + /// The authentication request cannot be completed without end user + /// interaction, returned when `prompt=none` was requested. + InteractionRequired, + /// The authentication request cannot be completed without end user + /// authentication, returned when `prompt=none` was requested. + LoginRequired, + /// The end user must select a session, returned when `prompt=none` was + /// requested and more than one session is available. + AccountSelectionRequired, + /// The authentication request cannot be completed without end user consent, + /// returned when `prompt=none` was requested. + ConsentRequired, + /// The `request_uri` is invalid or unreachable. + InvalidRequestURI, + /// The request object is invalid. + InvalidRequestObject, + /// The provider does not support the `request` parameter. + RequestNotSupported, + /// The provider does not support the `request_uri` parameter. + RequestURINotSupported, + /// The provider does not support the `registration` parameter. + RegistrationNotSupported +}; + +/// @ingroup oidc +/// The wire code for an authentication endpoint error (OpenID Connect Core 1.0 +/// Section 3.1.2.6). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_error_code( +/// sourcemeta::core::OIDCAuthenticationError::LoginRequired) == +/// "login_required"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_error_code(const OIDCAuthenticationError error) noexcept + -> std::string_view; + +/// @ingroup oidc +/// Map an authentication endpoint error code to its value, returning no value +/// for an unrecognized code (OpenID Connect Core 1.0 Section 3.1.2.6). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oidc_authentication_error("consent_required") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto to_oidc_authentication_error(const std::string_view code) noexcept + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_hash.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_hash.h new file mode 100644 index 00000000..f6705ada --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_hash.h @@ -0,0 +1,61 @@ +#ifndef SOURCEMETA_CORE_OIDC_HASH_H_ +#define SOURCEMETA_CORE_OIDC_HASH_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Compute an OpenID Connect access token hash (`at_hash`) or authorization +/// code hash (`c_hash`) for a token and the ID Token signing algorithm (OpenID +/// Connect Core 1.0 Section 3.1.3.6). The token is hashed with the SHA variant +/// the algorithm is defined over, the left-most half of the digest is kept, and +/// that half is base64url-encoded without padding. The digest is selected from +/// the algorithm by table rather than by slicing its name. Returns no value for +/// `EdDSA`, whose correct digest depends on the signing curve (SHA-512 for +/// Ed25519, SHAKE256 for Ed448) that the algorithm alone does not convey. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto hash{sourcemeta::core::oidc_token_hash( +/// "ya29.CjHSA1l5WUn8xZ6HanHFzzdHdbXm-14rxnC7JHch9eFIsZkQEGoWzaYG4o7k5f6BnPLj", +/// sourcemeta::core::JWSAlgorithm::RS256)}; +/// assert(hash.has_value()); +/// assert(hash.value() == "piwt8oCH-K2D9pXlaS1Y-w"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_token_hash(const std::string_view token, const JWSAlgorithm algorithm) + -> std::optional; + +/// @ingroup oidc +/// Whether an `at_hash` or `c_hash` claim matches a token under the ID Token +/// signing algorithm, comparing in constant time (OpenID Connect Core 1.0 +/// Section 3.1.3.6). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_verify_token_hash( +/// "ya29.CjHSA1l5WUn8xZ6HanHFzzdHdbXm-14rxnC7JHch9eFIsZkQEGoWzaYG4o7k5f6BnPLj", +/// sourcemeta::core::JWSAlgorithm::RS256, "piwt8oCH-K2D9pXlaS1Y-w")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_verify_token_hash(const std::string_view token, + const JWSAlgorithm algorithm, + const std::string_view claim) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h new file mode 100644 index 00000000..df0d7841 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_id_token.h @@ -0,0 +1,216 @@ +#ifndef SOURCEMETA_CORE_OIDC_ID_TOKEN_H_ +#define SOURCEMETA_CORE_OIDC_ID_TOKEN_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include +#include + +#include // std::chrono::system_clock, std::chrono::seconds +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Extract the ID Token from a token endpoint response document, returning no +/// value when the `id_token` member is absent or not a string (OpenID Connect +/// Core 1.0 Section 3.1.3.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto response{sourcemeta::core::parse_json( +/// R"JSON({"access_token":"at","id_token":"eyJ..."})JSON")}; +/// const auto id_token{sourcemeta::core::oidc_parse_id_token(response)}; +/// assert(id_token.has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_parse_id_token(const JSON &token_response) + -> std::optional; + +/// @ingroup oidc +/// The identity a validated ID Token asserts, owning its strings so it outlives +/// the token it was extracted from (OpenID Connect Core 1.0 Section 2). +struct OIDCIdentity { + /// The subject identifier, locally unique and never reassigned at the issuer + /// (OpenID Connect Core 1.0 Section 2). + std::string subject; + /// The issuer that produced the token (OpenID Connect Core 1.0 Section 2). + std::string issuer; + /// The authentication context class reference, when present (OpenID Connect + /// Core 1.0 Section 2). + std::optional authentication_context_class; + /// The time the end user authenticated, when present (OpenID Connect Core 1.0 + /// Section 2). + std::optional authentication_time; +}; + +/// @ingroup oidc +/// The OpenID Connect specific checks a Relying Party layers on top of the base +/// JSON Web Token verification when validating an ID Token (OpenID Connect Core +/// 1.0 Section 3.1.3.7). +struct OIDCValidationOptions { + /// The nonce sent in the authentication request, which the token must echo + /// when supplied (OpenID Connect Core 1.0 Section 3.1.3.7 step 11). + std::optional nonce; + /// The acceptable authentication context class references, checked when + /// non-empty (OpenID Connect Core 1.0 Section 3.1.3.7 step 12). + std::span acceptable_authentication_context_classes; + /// The maximum authentication age, requiring a fresh `auth_time` when set + /// (OpenID Connect Core 1.0 Section 3.1.3.7 step 13). + std::optional maximum_authentication_age; + /// The maximum age of the `iat` claim, checked when set (OpenID Connect Core + /// 1.0 Section 3.1.3.7 step 10). + std::optional maximum_issued_at_age; + /// The access token to bind through `at_hash`, verified against the claim + /// when both are present (OpenID Connect Core 1.0 Section 3.1.3.6). + std::optional access_token; + /// The authorization code to bind through `c_hash`, verified against the + /// claim when both are present (OpenID Connect Core 1.0 Section 3.3.2.11). + std::optional code; + /// Whether the `at_hash` claim is required, the case for the implicit and + /// hybrid flows that return an access token from the authorization endpoint. + /// When set, the claim must be present and verified against `access_token`, + /// which must also be supplied (OpenID Connect Core 1.0 Sections 3.2.2.10 and + /// 3.3.2.11). + bool require_access_token_hash{false}; + /// Whether the `c_hash` claim is required, the case for the hybrid + /// `code id_token` flow. When set, the claim must be present and verified + /// against `code`, which must also be supplied (OpenID Connect Core 1.0 + /// Section 3.3.2.11). + bool require_code_hash{false}; +}; + +/// @ingroup oidc +/// Validate an ID Token against a key set at a given time, returning the +/// asserted identity or no value when any check fails (OpenID Connect Core 1.0 +/// Section 3.1.3.7). The base JSON Web Token verification (signature under a +/// pinned algorithm, issuer, audience, expiration, and skew) runs first, then +/// the OpenID Connect steps: the subject and issued-at are required, an `azp` +/// matching the client is required when the audience carries more than one +/// value, the nonce is echoed when one was sent, the authentication context and +/// age constraints hold, and a required binding hash is present and matches. +/// The algorithm allow-list is pinned by the caller and must never contain +/// `none`. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::JWT::from(compact)}; +/// const auto keys{sourcemeta::core::JWKS::from(key_set)}; +/// const std::array allowed{sourcemeta::core::JWSAlgorithm::RS256}; +/// assert(token.has_value() && keys.has_value()); +/// const auto identity{sourcemeta::core::oidc_validate_id_token( +/// token.value(), keys.value(), allowed, "https://issuer.example", +/// "client-id", std::chrono::system_clock::now())}; +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_validate_id_token( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view issuer, const std::string_view client_id, + const std::chrono::system_clock::time_point now, + const OIDCValidationOptions &options = {}, + const JWTClockSkew clock_skew = {}) -> std::optional; + +/// @ingroup oidc +/// Validate an ID Token against a caching key set provider, returning the +/// asserted identity or no value when any check fails (OpenID Connect Core 1.0 +/// Section 3.1.3.7). The provider supplies the keys, the current time, and the +/// clock skew, and the OpenID Connect steps run against the same clock the +/// signature verification used. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::JWT::from(compact)}; +/// const std::array allowed{sourcemeta::core::JWSAlgorithm::RS256}; +/// assert(token.has_value()); +/// const auto identity{sourcemeta::core::oidc_validate_id_token( +/// provider, token.value(), allowed, "https://issuer.example", +/// "client-id")}; +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_validate_id_token( + JWKSProvider &provider, const JWT &token, + const std::span allowed_algorithms, + const std::string_view issuer, const std::string_view client_id, + const OIDCValidationOptions &options = {}) -> std::optional; + +/// @ingroup oidc +/// The claims an OpenID Provider mints into an ID Token (OpenID Connect +/// Core 1.0 Section 2). The access token and code, when supplied, are hashed +/// into the `at_hash` and `c_hash` claims under the signing algorithm. +struct OIDCIdTokenClaims { + /// The issuer identifier (OpenID Connect Core 1.0 Section 2), REQUIRED. + std::string_view issuer; + /// The subject identifier (OpenID Connect Core 1.0 Section 2), REQUIRED. + std::string_view subject; + /// The audience, the client the token is for (OpenID Connect Core 1.0 + /// Section 2), REQUIRED. + std::string_view audience; + /// The time the token was issued (OpenID Connect Core 1.0 Section 2), + /// REQUIRED. + std::chrono::system_clock::time_point issued_at; + /// The time the token expires (OpenID Connect Core 1.0 Section 2), REQUIRED. + std::chrono::system_clock::time_point expiration; + /// The nonce echoed from the authentication request (OpenID Connect Core 1.0 + /// Section 2). + std::optional nonce; + /// The authorized party, set when the audience carries more than one value + /// (OpenID Connect Core 1.0 Section 2). + std::optional authorized_party; + /// The authentication context class reference (OpenID Connect Core 1.0 + /// Section 2). + std::optional authentication_context_class; + /// The time the end user authenticated (OpenID Connect Core 1.0 Section 2). + std::optional authentication_time; + /// The access token to bind through `at_hash` (OpenID Connect Core 1.0 + /// Section 3.1.3.6). + std::optional access_token; + /// The authorization code to bind through `c_hash` (OpenID Connect Core 1.0 + /// Section 3.3.2.11). + std::optional code; +}; + +/// @ingroup oidc +/// Mint and sign an ID Token from a claim set under a signing key and +/// algorithm, returning the compact serialization or no value when the key +/// cannot produce the signature (OpenID Connect Core 1.0 Section 2). The +/// `at_hash` and `c_hash` claims are computed and embedded when the access +/// token or code is supplied. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OIDCIdTokenClaims claims; +/// claims.issuer = "https://issuer.example"; +/// claims.subject = "user-1"; +/// claims.audience = "client-id"; +/// claims.issued_at = std::chrono::system_clock::now(); +/// claims.expiration = claims.issued_at + std::chrono::hours{1}; +/// const auto token{sourcemeta::core::oidc_mint_id_token( +/// claims, key, sourcemeta::core::JWSAlgorithm::RS256)}; +/// assert(token.has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_mint_id_token(const OIDCIdTokenClaims &claims, const JWKPrivate &key, + const JWSAlgorithm algorithm) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_logout.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_logout.h new file mode 100644 index 00000000..fcee9b50 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_logout.h @@ -0,0 +1,136 @@ +#ifndef SOURCEMETA_CORE_OIDC_LOGOUT_H_ +#define SOURCEMETA_CORE_OIDC_LOGOUT_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include + +#include // std::chrono::system_clock +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// An RP-Initiated Logout request (OpenID Connect RP-Initiated Logout 1.0 +/// Section 2). Each field is a non-owning view. +struct OIDCLogoutRequest { + /// The ID Token previously issued, hinting which session to end (OpenID + /// Connect RP-Initiated Logout 1.0 Section 2). + std::string_view id_token_hint; + /// A hint about the end user, when no ID Token hint is available (OpenID + /// Connect RP-Initiated Logout 1.0 Section 2). + std::string_view logout_hint; + /// The client identifier (OpenID Connect RP-Initiated Logout 1.0 Section 2). + std::string_view client_id; + /// Where to redirect after logout, matched against the registered set (OpenID + /// Connect RP-Initiated Logout 1.0 Section 2). + std::string_view post_logout_redirect_uri; + /// The opaque state echoed back to the client (OpenID Connect RP-Initiated + /// Logout 1.0 Section 2). + std::string_view state; + /// The space-delimited preferred UI locales (OpenID Connect RP-Initiated + /// Logout 1.0 Section 2). + std::string_view ui_locales; +}; + +/// @ingroup oidc +/// Build an RP-Initiated Logout request URL from an end session endpoint and a +/// request (OpenID Connect RP-Initiated Logout 1.0 Section 2). Each present +/// parameter is percent-escaped, an existing query on the endpoint is honored, +/// and the sink is appended to and never cleared. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// sourcemeta::core::OIDCLogoutRequest request; +/// request.id_token_hint = "eyJ..."; +/// request.post_logout_redirect_uri = "https://client.example/after"; +/// std::string url; +/// sourcemeta::core::oidc_build_logout_url( +/// "https://server.example/logout", request, url); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_build_logout_url(const std::string_view end_session_endpoint, + const OIDCLogoutRequest &request, std::string &sink) + -> void; + +/// @ingroup oidc +/// Validate a Back-Channel Logout token against a key set at a given time +/// (OpenID Connect Back-Channel Logout 1.0 Section 2.6). The signature is +/// verified under a pinned algorithm, the issuer and audience must match, the +/// `iat` must be present and not in the future, the `exp` must be present and +/// not have passed, the `typ` header when present must be `logout+jwt`, the +/// token must carry a `sub` or `sid`, an `events` object with the back-channel +/// logout member, a `jti`, and it must not carry a `nonce`. Replay rejection by +/// `jti` is the caller's responsibility. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::JWT::from(compact)}; +/// const auto keys{sourcemeta::core::JWKS::from(key_set)}; +/// const std::array allowed{sourcemeta::core::JWSAlgorithm::RS256}; +/// assert(token.has_value() && keys.has_value()); +/// const auto valid{sourcemeta::core::oidc_validate_logout_token( +/// token.value(), keys.value(), allowed, "https://issuer.example", +/// "client-id", std::chrono::system_clock::now())}; +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_validate_logout_token( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view issuer, const std::string_view client_id, + const std::chrono::system_clock::time_point now, + const JWTClockSkew clock_skew = {}) -> bool; + +/// @ingroup oidc +/// Whether a Front-Channel Logout `iss` and `sid` pair is used in a valid +/// combination, which is both present or both absent (OpenID Connect +/// Front-Channel Logout 1.0 Section 3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_front_channel_pairing_is_valid( +/// "https://issuer.example", "session-1")); +/// assert(!sourcemeta::core::oidc_front_channel_pairing_is_valid( +/// "https://issuer.example", "")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_front_channel_pairing_is_valid(const std::string_view issuer, + const std::string_view session_id) + -> bool; + +/// @ingroup oidc +/// Compute a Session Management `session_state` value from the client, the +/// client origin, the OpenID Provider browser state, and a salt (OpenID Connect +/// Session Management 1.0 Section 4.2). The value is the base64url of the +/// SHA-256 of the space-joined inputs, a dot, and the salt. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto state{sourcemeta::core::oidc_session_state( +/// "client-id", "https://client.example", "browser-state", "salt")}; +/// assert(!state.empty()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_session_state(const std::string_view client_id, + const std::string_view origin, + const std::string_view provider_browser_state, + const std::string_view salt) -> std::string; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h new file mode 100644 index 00000000..a9117548 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_metadata.h @@ -0,0 +1,244 @@ +#ifndef SOURCEMETA_CORE_OIDC_METADATA_H_ +#define SOURCEMETA_CORE_OIDC_METADATA_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include +#include + +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oidc +/// An OpenID Provider metadata document (OpenID Connect Discovery 1.0), owning +/// its JSON. It is a superset of an OAuth authorization server metadata +/// document, validated on construction against the issuer it was retrieved for: +/// the OAuth checks apply, and in addition `jwks_uri`, +/// `subject_types_supported`, and `id_token_signing_alg_values_supported` are +/// REQUIRED and non-empty, and the signing algorithm list must include `RS256`. +/// A string accessor returns a view into the owned document, valid for the +/// lifetime of this object. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json( +/// R"JSON({"issuer":"https://example.com", +/// "authorization_endpoint":"https://example.com/authorize", +/// "token_endpoint":"https://example.com/token", +/// "jwks_uri":"https://example.com/jwks", +/// "response_types_supported":["code"], +/// "subject_types_supported":["public"], +/// "id_token_signing_alg_values_supported":["RS256"]})JSON")}; +/// const auto metadata{sourcemeta::core::OIDCProviderMetadata::from( +/// std::move(document), "https://example.com")}; +/// assert(metadata.has_value()); +/// assert(metadata.value().jwks_uri() == "https://example.com/jwks"); +/// ``` +class SOURCEMETA_CORE_OIDC_EXPORT OIDCProviderMetadata { +public: + /// Construct and validate a metadata document for an expected issuer, + /// throwing when it is invalid. The document is moved in. + OIDCProviderMetadata(JSON &&data, const std::string_view issuer); + + /// Construct and validate a metadata document for an expected issuer, + /// returning no value when it is invalid. The document is moved in. + [[nodiscard]] static auto from(JSON &&data, const std::string_view issuer) + -> std::optional; + + /// The issuer identifier (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto issuer() const -> std::string_view; + + /// The authorization endpoint (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto authorization_endpoint() const + -> std::optional; + + /// The token endpoint (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto token_endpoint() const -> std::optional; + + /// The UserInfo endpoint (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto userinfo_endpoint() const + -> std::optional; + + /// The dynamic client registration endpoint (OpenID Connect Discovery 1.0 + /// Section 3). + [[nodiscard]] auto registration_endpoint() const + -> std::optional; + + /// The JWK Set document location, REQUIRED by OpenID Connect (OpenID Connect + /// Discovery 1.0 Section 3). + [[nodiscard]] auto jwks_uri() const -> std::string_view; + + /// The RP-Initiated Logout end session endpoint (OpenID Connect RP-Initiated + /// Logout 1.0 Section 2). + [[nodiscard]] auto end_session_endpoint() const + -> std::optional; + + /// The Session Management check session iframe (OpenID Connect Session + /// Management 1.0 Section 2.1). + [[nodiscard]] auto check_session_iframe() const + -> std::optional; + + /// Whether a subject identifier type is supported (OpenID Connect Discovery + /// 1.0 Section 3). + [[nodiscard]] auto supports_subject_type(const std::string_view value) const + -> bool; + + /// Whether an ID Token signing algorithm is supported (OpenID Connect + /// Discovery 1.0 Section 3). + [[nodiscard]] auto + supports_id_token_signing_alg(const std::string_view value) const -> bool; + + /// Whether a response type is supported (OpenID Connect Discovery 1.0 + /// Section 3). + [[nodiscard]] auto supports_response_type(const std::string_view value) const + -> bool; + + /// Whether a scope is supported (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto supports_scope(const std::string_view value) const -> bool; + + /// Whether a claim is supported (OpenID Connect Discovery 1.0 Section 3). + [[nodiscard]] auto supports_claim(const std::string_view value) const -> bool; + + /// The underlying OAuth authorization server metadata this document is a + /// superset of, for reaching the OAuth typed accessors. + [[nodiscard]] auto oauth() const -> const OAuthServerMetadata &; + + /// The underlying document, for reaching members without a typed accessor. + [[nodiscard]] auto data() const -> const JSON &; + +private: + OAuthServerMetadata oauth_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oidc +/// The configuration an OpenID Provider publishes as its metadata (OpenID +/// Connect Discovery 1.0 Section 3), a superset of the OAuth authorization +/// server configuration, each field a non-owning view. An empty scalar and a +/// zero-element array are omitted, and a capability flag is emitted only when +/// it differs from its specification default. +struct OIDCProviderMetadataConfig { + /// The OAuth authorization server configuration this is a superset of. Its + /// `jwks_uri` is REQUIRED for OpenID Connect. + OAuthServerMetadataConfig base; + /// The UserInfo endpoint (OpenID Connect Discovery 1.0 Section 3). + std::string_view userinfo_endpoint; + /// The RP-Initiated Logout end session endpoint (OpenID Connect RP-Initiated + /// Logout 1.0 Section 3). + std::string_view end_session_endpoint; + /// The Session Management check session iframe (OpenID Connect Session + /// Management 1.0 Section 2.1). + std::string_view check_session_iframe; + /// The supported subject identifier types (OpenID Connect Discovery 1.0 + /// Section 3), REQUIRED and non-empty. + std::span subject_types_supported; + /// The supported ID Token signing algorithms (OpenID Connect Discovery 1.0 + /// Section 3), REQUIRED, non-empty, and including `RS256`. The module never + /// advertises `none`. + std::span id_token_signing_alg_values_supported; + /// The supported ID Token encryption algorithms (OpenID Connect Discovery 1.0 + /// Section 3). + std::span id_token_encryption_alg_values_supported; + /// The supported ID Token content encryptions (OpenID Connect Discovery 1.0 + /// Section 3). + std::span id_token_encryption_enc_values_supported; + /// The supported UserInfo signing algorithms (OpenID Connect Discovery 1.0 + /// Section 3). + std::span userinfo_signing_alg_values_supported; + /// The supported UserInfo encryption algorithms (OpenID Connect Discovery 1.0 + /// Section 3). + std::span userinfo_encryption_alg_values_supported; + /// The supported UserInfo content encryptions (OpenID Connect Discovery 1.0 + /// Section 3). + std::span userinfo_encryption_enc_values_supported; + /// The supported request object signing algorithms (OpenID Connect Discovery + /// 1.0 Section 3). + std::span request_object_signing_alg_values_supported; + /// The supported request object encryption algorithms (OpenID Connect + /// Discovery 1.0 Section 3). + std::span + request_object_encryption_alg_values_supported; + /// The supported request object content encryptions (OpenID Connect Discovery + /// 1.0 Section 3). + std::span + request_object_encryption_enc_values_supported; + /// The supported claims (OpenID Connect Discovery 1.0 Section 3). + std::span claims_supported; + /// The supported authentication context class references (OpenID Connect + /// Discovery 1.0 Section 3). + std::span acr_values_supported; + /// The supported claim types (OpenID Connect Discovery 1.0 Section 3). + std::span claim_types_supported; + /// The supported display values (OpenID Connect Discovery 1.0 Section 3). + std::span display_values_supported; + /// The supported claims locales (OpenID Connect Discovery 1.0 Section 3). + std::span claims_locales_supported; + /// The supported UI locales (OpenID Connect Discovery 1.0 Section 3). + std::span ui_locales_supported; + /// Whether the `claims` request parameter is supported, default false, so + /// emitted only when true (OpenID Connect Discovery 1.0 Section 3). + bool claims_parameter_supported{false}; + /// Whether the `request` request parameter is supported, default false, so + /// emitted only when true (OpenID Connect Discovery 1.0 Section 3). + bool request_parameter_supported{false}; + /// Whether the `request_uri` request parameter is supported, default true, so + /// emitted only when false (OpenID Connect Discovery 1.0 Section 3). + bool request_uri_parameter_supported{true}; + /// Whether a `request_uri` must be pre-registered, default false, so emitted + /// only when true (OpenID Connect Discovery 1.0 Section 3). + bool require_request_uri_registration{false}; +}; + +/// @ingroup oidc +/// Build an OpenID Provider metadata document for the well-known endpoint +/// (OpenID Connect Discovery 1.0 Section 3), returning no value when the +/// document would be unusable: the OAuth base is unusable, the REQUIRED +/// `jwks_uri`, `subject_types_supported`, or +/// `id_token_signing_alg_values_supported` is missing or empty, the ID Token +/// signing algorithm list omits `RS256` or contains `none`, or an advertised +/// OpenID Connect endpoint is not a valid https URL. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const std::array response_types{{"code"}}; +/// const std::array subject_types{{"public"}}; +/// const std::array id_token_algs{{"RS256"}}; +/// sourcemeta::core::OIDCProviderMetadataConfig config; +/// config.base.issuer = "https://server.example"; +/// config.base.authorization_endpoint = "https://server.example/authorize"; +/// config.base.token_endpoint = "https://server.example/token"; +/// config.base.jwks_uri = "https://server.example/jwks"; +/// config.base.response_types_supported = response_types; +/// config.subject_types_supported = subject_types; +/// config.id_token_signing_alg_values_supported = id_token_algs; +/// const auto document{sourcemeta::core::oidc_make_provider_metadata(config)}; +/// assert(document.has_value()); +/// assert(document.value().at("issuer").to_string() == +/// "https://server.example"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_make_provider_metadata(const OIDCProviderMetadataConfig &config) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_profile.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_profile.h new file mode 100644 index 00000000..998bceca --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_profile.h @@ -0,0 +1,37 @@ +#ifndef SOURCEMETA_CORE_OIDC_PROFILE_H_ +#define SOURCEMETA_CORE_OIDC_PROFILE_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include // std::uint8_t + +namespace sourcemeta::core { + +/// @ingroup oidc +/// The behavioural profile a builder or validator runs under. `Strict` allows +/// only the Authorization Code flow with PKCE, and `Legacy` additionally +/// permits the Hybrid `code id_token` flow, which is weaker and off by default +/// (OpenID Connect Core 1.0 Section 3, the module design Section 14.2). For +/// example: +/// +/// ```cpp +/// #include +/// +/// const auto profile{sourcemeta::core::OIDCProfile::Strict}; +/// ``` +enum class OIDCProfile : std::uint8_t { + /// The default, allowing only the Authorization Code flow and requiring PKCE + /// with the `S256` method. + Strict, + /// Additionally permits the Hybrid `code id_token` flow, still seen in older + /// deployments, which requires a `nonce` on the request. Validating its ID + /// Token still requires the caller to enable `c_hash` checking through the ID + /// Token validation options. + Legacy +}; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_registration.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_registration.h new file mode 100644 index 00000000..7e4d4528 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_registration.h @@ -0,0 +1,146 @@ +#ifndef SOURCEMETA_CORE_OIDC_REGISTRATION_H_ +#define SOURCEMETA_CORE_OIDC_REGISTRATION_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include +#include + +#include // std::chrono::seconds +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +#if defined(_MSC_VER) +#pragma warning(disable : 4251) +#endif + +/// @ingroup oidc +/// An OpenID Connect client registration document (OpenID Connect Dynamic +/// Client Registration 1.0), owning its JSON. It is a superset of an OAuth +/// client registration document, validated on construction: the OAuth checks +/// apply, and in addition `redirect_uris` is REQUIRED and non-empty, which +/// OpenID Connect tightens from the OAuth OPTIONAL. A string accessor returns a +/// view into the owned document, valid for the lifetime of this object. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// auto document{sourcemeta::core::parse_json( +/// R"JSON({"redirect_uris":["https://client.example/cb"], +/// "subject_type":"public"})JSON")}; +/// const auto metadata{ +/// sourcemeta::core::OIDCClientMetadata::from(std::move(document))}; +/// assert(metadata.has_value()); +/// assert(metadata.value().application_type() == "web"); +/// ``` +class SOURCEMETA_CORE_OIDC_EXPORT OIDCClientMetadata { +public: + /// Construct and validate a client registration document, throwing when it is + /// invalid. The document is moved in. + explicit OIDCClientMetadata(JSON &&data); + + /// Construct and validate a client registration document, returning no value + /// when it is invalid. The document is moved in. + [[nodiscard]] static auto from(JSON &&data) + -> std::optional; + + /// Whether a redirection URI is registered (OpenID Connect Dynamic Client + /// Registration 1.0 Section 2). + [[nodiscard]] auto has_redirect_uri(const std::string_view value) const + -> bool; + + /// The application type, defaulting to `web` (OpenID Connect Dynamic Client + /// Registration 1.0 Section 2). + [[nodiscard]] auto application_type() const -> std::string_view; + + /// The subject identifier type (OpenID Connect Dynamic Client Registration + /// 1.0 Section 2). + [[nodiscard]] auto subject_type() const -> std::optional; + + /// The sector identifier URI for pairwise subjects (OpenID Connect Dynamic + /// Client Registration 1.0 Section 2). + [[nodiscard]] auto sector_identifier_uri() const + -> std::optional; + + /// The ID Token signing algorithm, defaulting to `RS256` (OpenID Connect + /// Dynamic Client Registration 1.0 Section 2). + [[nodiscard]] auto id_token_signed_response_alg() const -> std::string_view; + + /// The ID Token encryption algorithm (OpenID Connect Dynamic Client + /// Registration 1.0 Section 2). + [[nodiscard]] auto id_token_encrypted_response_alg() const + -> std::optional; + + /// The UserInfo signing algorithm (OpenID Connect Dynamic Client Registration + /// 1.0 Section 2). + [[nodiscard]] auto userinfo_signed_response_alg() const + -> std::optional; + + /// The default maximum authentication age (OpenID Connect Dynamic Client + /// Registration 1.0 Section 2). + [[nodiscard]] auto default_max_age() const + -> std::optional; + + /// Whether the `auth_time` claim is always required, defaulting to false + /// (OpenID Connect Dynamic Client Registration 1.0 Section 2). + [[nodiscard]] auto require_auth_time() const -> bool; + + /// The URI a third party uses to initiate login (OpenID Connect Dynamic + /// Client Registration 1.0 Section 2). + [[nodiscard]] auto initiate_login_uri() const + -> std::optional; + + /// Whether a post-logout redirection URI is registered (OpenID Connect + /// RP-Initiated Logout 1.0 Section 3). + [[nodiscard]] auto + has_post_logout_redirect_uri(const std::string_view value) const -> bool; + + /// The underlying OAuth client registration this document is a superset of. + [[nodiscard]] auto oauth() const -> const OAuthClientMetadata &; + + /// The underlying document, for reaching members without a typed accessor. + [[nodiscard]] auto data() const -> const JSON &; + +private: + OAuthClientMetadata oauth_; +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251) +#endif + +/// @ingroup oidc +/// Whether a fetched `sector_identifier_uri` document, a JSON array of +/// redirection URIs, contains every registered redirection URI (OpenID Connect +/// Dynamic Client Registration 1.0 Section 5). The OpenID Provider performs +/// this check at registration before accepting a pairwise client. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// const auto document{sourcemeta::core::parse_json( +/// R"JSON(["https://client.example/cb"])JSON")}; +/// const std::array registered{ +/// {"https://client.example/cb"}}; +/// assert(sourcemeta::core::oidc_sector_identifier_contains(document, +/// registered)); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_sector_identifier_contains( + const JSON §or_document, + const std::span redirect_uris) -> bool; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h new file mode 100644 index 00000000..d5b83fea --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_request_object.h @@ -0,0 +1,86 @@ +#ifndef SOURCEMETA_CORE_OIDC_REQUEST_OBJECT_H_ +#define SOURCEMETA_CORE_OIDC_REQUEST_OBJECT_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include +#include + +#include // std::optional +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Whether the `request` and `request_uri` parameters are used in a valid +/// combination, which is at most one of them (OpenID Connect Core 1.0 +/// Section 6). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_request_object_pairing_is_valid( +/// "eyJ...", "")); +/// assert(!sourcemeta::core::oidc_request_object_pairing_is_valid( +/// "eyJ...", "https://client.example/request.jwt")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_request_object_pairing_is_valid(const std::string_view request, + const std::string_view request_uri) + -> bool; + +/// @ingroup oidc +/// Build and sign a request object from a set of authentication request +/// parameters, returning the compact JWT or no value when the key cannot +/// produce the signature (OpenID Connect Core 1.0 Section 6.1). The parameters +/// should carry the `iss` set to the client and the `aud` set to the OpenID +/// Provider issuer. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto parameters{sourcemeta::core::parse_json( +/// R"JSON({"iss":"client","aud":"https://op.example","scope":"openid"})JSON")}; +/// const auto object{sourcemeta::core::oidc_build_request_object( +/// parameters, key, sourcemeta::core::JWSAlgorithm::RS256)}; +/// assert(object.has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_build_request_object(const JSON ¶meters, const JWKPrivate &key, + const JWSAlgorithm algorithm) + -> std::optional; + +/// @ingroup oidc +/// Verify a request object and return its parameters, or no value when the +/// signature does not verify under a pinned algorithm, the issuer is not the +/// client, or the audience is not the OpenID Provider (OpenID Connect Core 1.0 +/// Section 6.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::JWT::from(compact)}; +/// const auto keys{sourcemeta::core::JWKS::from(client_keys)}; +/// const std::array allowed{sourcemeta::core::JWSAlgorithm::RS256}; +/// assert(token.has_value() && keys.has_value()); +/// const auto parameters{sourcemeta::core::oidc_verify_request_object( +/// token.value(), keys.value(), allowed, "client", "https://op.example")}; +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_verify_request_object( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view client_id, const std::string_view provider_issuer) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_subject.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_subject.h new file mode 100644 index 00000000..408e17d1 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_subject.h @@ -0,0 +1,82 @@ +#ifndef SOURCEMETA_CORE_OIDC_SUBJECT_H_ +#define SOURCEMETA_CORE_OIDC_SUBJECT_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include // std::uint8_t +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// The subject identifier type an OpenID Provider assigns (OpenID Connect Core +/// 1.0 Section 8). A public subject is shared across clients, while a pairwise +/// subject is distinct per sector so clients cannot correlate the end user. +enum class OIDCSubjectType : std::uint8_t { + /// The same subject value is returned to every client (OpenID Connect Core + /// 1.0 Section 8). + Public, + /// A distinct subject value is returned to each sector (OpenID Connect Core + /// 1.0 Section 8). + Pairwise +}; + +/// @ingroup oidc +/// The wire name of a subject identifier type (OpenID Connect Core 1.0 +/// Section 8). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oidc_subject_type_name( +/// sourcemeta::core::OIDCSubjectType::Pairwise) == "pairwise"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_subject_type_name(const OIDCSubjectType type) noexcept + -> std::string_view; + +/// @ingroup oidc +/// Map a subject identifier type name to its value, returning no value for an +/// unrecognized name (OpenID Connect Core 1.0 Section 8). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oidc_subject_type("public").has_value()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto to_oidc_subject_type(const std::string_view name) noexcept + -> std::optional; + +/// @ingroup oidc +/// Derive a pairwise subject identifier from a sector identifier and a local +/// account identifier, keyed by a confidential OpenID Provider secret (OpenID +/// Connect Core 1.0 Section 8.1). The `provider_secret` must be kept secret, as +/// a public value would let account identifiers be enumerated from the pairwise +/// subjects. The value is a keyed HMAC-SHA256 over the sector and account, +/// base64url-encoded, so it is stable per client, distinct per sector, +/// non-reversible, and well under the 255 character limit. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto subject{sourcemeta::core::oidc_pairwise_subject( +/// "client.example", "user-1", "provider-secret")}; +/// assert(!subject.empty()); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_pairwise_subject(const std::string_view sector_identifier, + const std::string_view local_account_identifier, + const std::string_view provider_secret) + -> std::string; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h new file mode 100644 index 00000000..22cd5895 --- /dev/null +++ b/vendor/core/src/core/oidc/include/sourcemeta/core/oidc_userinfo.h @@ -0,0 +1,84 @@ +#ifndef SOURCEMETA_CORE_OIDC_USERINFO_H_ +#define SOURCEMETA_CORE_OIDC_USERINFO_H_ + +#ifndef SOURCEMETA_CORE_OIDC_EXPORT +#include +#endif + +#include +#include + +#include // std::optional +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oidc +/// Build a UserInfo response object for the given subject and additional +/// claims, ensuring the REQUIRED `sub` claim is present (OpenID Connect +/// Core 1.0 Section 5.3.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto +/// claims{sourcemeta::core::parse_json(R"JSON({"email":"a@b"})JSON")}; const +/// auto document{sourcemeta::core::oidc_build_userinfo("user-1", claims)}; +/// assert(document.at("sub").to_string() == "user-1"); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_build_userinfo(const std::string_view subject, + const JSON &additional_claims) -> JSON; + +/// @ingroup oidc +/// Whether a UserInfo response subject matches the ID Token subject, comparing +/// in constant time, the defence against a substituted response (OpenID Connect +/// Core 1.0 Section 5.3.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto +/// userinfo{sourcemeta::core::parse_json(R"JSON({"sub":"user-1"})JSON")}; +/// assert(sourcemeta::core::oidc_userinfo_matches_subject(userinfo, "user-1")); +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_userinfo_matches_subject(const JSON &userinfo, + const std::string_view expected_subject) + -> bool; + +/// @ingroup oidc +/// Verify a signed UserInfo response and its subject, returning the claims or +/// no value when the signature does not verify under a pinned algorithm, the +/// subject does not match the ID Token subject, or a present `iss` or `aud` +/// does not identify this provider and client (OpenID Connect Core 1.0 +/// Section 5.3.2). Checking `aud` binds the response to this client, preventing +/// one minted for another client from being accepted here. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto token{sourcemeta::core::JWT::from(compact)}; +/// const auto keys{sourcemeta::core::JWKS::from(key_set)}; +/// const std::array allowed{sourcemeta::core::JWSAlgorithm::RS256}; +/// assert(token.has_value() && keys.has_value()); +/// const auto claims{sourcemeta::core::oidc_verify_userinfo( +/// token.value(), keys.value(), allowed, "user-1", +/// "https://op.example", "client-id")}; +/// ``` +SOURCEMETA_CORE_OIDC_EXPORT +auto oidc_verify_userinfo( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view expected_subject, + const std::string_view expected_issuer, + const std::string_view expected_client_id) -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/oidc/oidc_authentication.cc b/vendor/core/src/core/oidc/oidc_authentication.cc new file mode 100644 index 00000000..2f6ceaba --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_authentication.cc @@ -0,0 +1,346 @@ +#include + +#include +#include + +#include // std::ranges::all_of +#include // std::array +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +// Whether a space-delimited list carries a token (OpenID Connect Core 1.0 +// Section 3.1.2.1, which compares such parameters as space-delimited lists) +auto space_list_contains(const std::string_view list, + const std::string_view token) -> bool { + bool found{false}; + split(list, ' ', [&found, token](const std::string_view value) -> void { + if (value == token) { + found = true; + } + }); + return found; +} + +// OpenID Connect Core 1.0 Section 3.1.2.1: the none prompt value "MUST NOT be +// used with any other value", so it is only valid alone +auto prompt_is_valid(const std::string_view prompt) -> bool { + if (prompt.empty() || !space_list_contains(prompt, "none")) { + return true; + } + + std::size_t token_count{0}; + split(prompt, ' ', [&token_count](const std::string_view value) -> void { + if (!value.empty()) { + token_count += 1; + } + }); + return token_count == 1; +} + +// OpenID Connect Core 1.0 Section 11: obtaining a refresh token through the +// offline_access scope requires the end user's consent, which the none prompt +// forbids gathering, so the two cannot be requested together +auto offline_access_is_valid(const std::string_view scope, + const std::string_view prompt) -> bool { + return !space_list_contains(scope, "offline_access") || + !space_list_contains(prompt, "none"); +} + +// OpenID Connect Core 1.0 Section 3, module design Section 14: the +// Authorization Code flow is always permitted, the Hybrid "code id_token" flow +// only under the legacy profile, and every flow that returns an access token +// from the authorization endpoint, as well as the pure implicit "id_token" +// flow, is never permitted. A response_type is an unordered space-delimited set +// (OAuth 2.0 Section 3.1.1) +auto response_type_is_allowed(const std::string_view response_type, + const OIDCProfile profile) -> bool { + // An unset response_type is left to the OAuth layer, which applies the + // default "code", so only a value that is actually present is validated here + if (response_type.empty()) { + return true; + } + + bool has_token{false}; + bool has_code{false}; + bool has_id_token{false}; + bool has_unsupported{false}; + split(response_type, ' ', + [&has_token, &has_code, &has_id_token, + &has_unsupported](const std::string_view value) -> void { + if (value.empty()) { + return; + } + + has_token = true; + if (value == "code") { + has_code = true; + } else if (value == "id_token") { + has_id_token = true; + } else { + has_unsupported = true; + } + }); + + // A present value that carries no token, such as one that is only whitespace, + // is not a meaningful response type + if (!has_token || has_unsupported) { + return false; + } + + if (has_id_token) { + return has_code && profile == OIDCProfile::Legacy; + } + + return true; +} + +// OpenID Connect Core 1.0 Section 3.1.2.1: nonce is REQUIRED whenever an ID +// Token is returned from the authorization endpoint, which binds it to the +// session in the implicit and hybrid flows +auto response_type_requires_nonce(const std::string_view response_type) + -> bool { + bool requires_nonce{false}; + split(response_type, ' ', + [&requires_nonce](const std::string_view value) -> void { + if (value == "id_token") { + requires_nonce = true; + } + }); + return requires_nonce; +} + +// The only supported PKCE method is S256, whose code challenge is the base64url +// encoding of a SHA-256 digest (RFC 7636 Section 4.2, Appendix A), so it is +// exactly 43 characters drawn from the base64url alphabet. The "." and "~" of +// the wider unreserved grammar can only appear under the plain method and never +// in a redeemable S256 challenge +auto code_challenge_is_valid(const std::string_view code_challenge) -> bool { + return code_challenge.size() == 43 && + std::ranges::all_of(code_challenge, [](const char character) -> bool { + return (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || character == '-' || + character == '_'; + }); +} + +// RFC 9700 Section 2.1.1: the strict profile binds the authorization code to +// the client with PKCE using the S256 method, protecting against code +// interception, so a strict request carries a well-formed code_challenge and +// the S256 method. The legacy profile leaves this to the caller for older +// deployments +auto pkce_is_valid(const std::string_view code_challenge, + const std::string_view code_challenge_method, + const OIDCProfile profile) -> bool { + if (profile != OIDCProfile::Strict) { + return true; + } + + return code_challenge_method == "S256" && + code_challenge_is_valid(code_challenge); +} + +// OpenID Connect Core 1.0 Section 3.1.2.1: store a parsed OpenID Connect +// authentication parameter that the OAuth layer treats as an extension +auto assign_openid_parameter(OIDCAuthenticationRequest &result, + const std::string_view name, + const std::string_view value) -> void { + if (name == "nonce") { + result.nonce = value; + } else if (name == "display") { + result.display = value; + } else if (name == "prompt") { + result.prompt = value; + } else if (name == "max_age") { + result.max_age = value; + } else if (name == "ui_locales") { + result.ui_locales = value; + } else if (name == "id_token_hint") { + result.id_token_hint = value; + } else if (name == "login_hint") { + result.login_hint = value; + } else if (name == "acr_values") { + result.acr_values = value; + } else if (name == "claims") { + result.claims = value; + } else if (name == "request") { + result.request = value; + } else if (name == "response_mode") { + result.response_mode = value; + } +} + +// OpenID Connect Core 1.0 Section 3.1.2.1: collect a present OpenID Connect +// authentication parameter that the OAuth layer carries as an extension +auto append_extra_parameter(std::array &storage, + std::size_t &count, const std::string_view name, + const std::string_view value) -> void { + if (!value.empty()) { + storage[count] = OAuthParameter{.name = name, .value = value}; + count += 1; + } +} + +} // namespace + +auto oidc_nonce() -> std::array { return oauth_random_token(); } + +auto oidc_build_authentication_url(const std::string_view endpoint, + const OIDCAuthenticationRequest &request, + std::string &sink, const OIDCProfile profile) + -> bool { + // OpenID Connect Core 1.0 Section 3.1.2.1: the client identifier and the + // redirection URI are REQUIRED, so a request missing either cannot yield a + // usable URL + if (request.client_id.empty() || request.redirect_uri.empty()) { + return false; + } + + // OpenID Connect Core 1.0 Section 3.1.2.1: scope is REQUIRED and must contain + // the openid value, which is what makes the request an OpenID Connect one + if (!space_list_contains(request.scope, "openid")) { + return false; + } + + if (!prompt_is_valid(request.prompt)) { + return false; + } + + if (!offline_access_is_valid(request.scope, request.prompt)) { + return false; + } + + if (!response_type_is_allowed(request.response_type, profile) || + (response_type_requires_nonce(request.response_type) && + request.nonce.empty())) { + return false; + } + + if (!pkce_is_valid(request.code_challenge, request.code_challenge_method, + profile)) { + return false; + } + + std::array extra_storage; + std::size_t extra_count{0}; + append_extra_parameter(extra_storage, extra_count, "nonce", request.nonce); + append_extra_parameter(extra_storage, extra_count, "display", + request.display); + append_extra_parameter(extra_storage, extra_count, "prompt", request.prompt); + append_extra_parameter(extra_storage, extra_count, "max_age", + request.max_age); + append_extra_parameter(extra_storage, extra_count, "ui_locales", + request.ui_locales); + append_extra_parameter(extra_storage, extra_count, "id_token_hint", + request.id_token_hint); + append_extra_parameter(extra_storage, extra_count, "login_hint", + request.login_hint); + append_extra_parameter(extra_storage, extra_count, "acr_values", + request.acr_values); + append_extra_parameter(extra_storage, extra_count, "claims", request.claims); + append_extra_parameter(extra_storage, extra_count, "request", + request.request); + append_extra_parameter(extra_storage, extra_count, "response_mode", + request.response_mode); + + OAuthAuthorizationRequest base; + base.client_id = request.client_id; + base.redirect_uri = request.redirect_uri; + base.scope = request.scope; + base.state = request.state; + base.code_challenge = request.code_challenge; + base.code_challenge_method = request.code_challenge_method; + base.response_type = request.response_type; + base.request_uri = request.request_uri; + base.extra = + std::span{extra_storage.data(), extra_count}; + + oauth_build_authorization_url(endpoint, base, sink); + return true; +} + +auto oidc_authorization_url(const std::string_view authorization_endpoint, + const std::string_view client_id, + const std::string_view redirect_uri, + const std::string_view state, + const std::string_view code_challenge, + const std::string_view nonce) + -> std::optional { + // This convenience guarantees PKCE, so an empty code challenge, which would + // silently produce a non-PKCE URL, is refused (RFC 7636 Section 4.3) + if (code_challenge.empty()) { + return std::nullopt; + } + + OIDCAuthenticationRequest request; + request.client_id = client_id; + request.redirect_uri = redirect_uri; + request.scope = "openid"; + request.response_type = "code"; + request.state = state; + request.code_challenge = code_challenge; + request.code_challenge_method = "S256"; + request.nonce = nonce; + + std::string url; + if (!oidc_build_authentication_url(authorization_endpoint, request, url)) { + return std::nullopt; + } + + return url; +} + +auto oidc_parse_authentication_request(const std::string_view query, + std::string &storage, + OIDCAuthenticationRequest &result, + const OIDCProfile profile) -> bool { + // Reset so a parameter absent from this query cannot retain a stale value + // when the caller reuses the result across parses + result = OIDCAuthenticationRequest{}; + + OAuthAuthorizationRequest base; + const auto parsed{oauth_parse_authorization_request( + query, storage, base, + [&result](const std::string_view name, const std::string_view value) + -> void { assign_openid_parameter(result, name, value); })}; + if (!parsed) { + return false; + } + + result.client_id = base.client_id; + result.redirect_uri = base.redirect_uri; + result.scope = base.scope; + result.response_type = base.response_type; + result.state = base.state; + result.code_challenge = base.code_challenge; + result.code_challenge_method = base.code_challenge_method; + result.request_uri = base.request_uri; + + // The same OpenID Connect well-formedness the builder enforces, so parsing + // never accepts a request the module could not build: the client identifier + // and redirection URI are REQUIRED, the scope must contain openid, a none + // prompt must appear alone, offline_access cannot pair with a none prompt, + // the response_type is REQUIRED and limited by the profile, a returned ID + // Token requires a nonce, and the strict profile requires PKCE (OpenID + // Connect Core 1.0 Section 3.1.2.1, Section 3.3.2.11, Section 11, RFC 9700 + // Section 2.1.1) + return !result.client_id.empty() && !result.redirect_uri.empty() && + space_list_contains(result.scope, "openid") && + prompt_is_valid(result.prompt) && + offline_access_is_valid(result.scope, result.prompt) && + !result.response_type.empty() && + response_type_is_allowed(result.response_type, profile) && + !(response_type_requires_nonce(result.response_type) && + result.nonce.empty()) && + pkce_is_valid(result.code_challenge, result.code_challenge_method, + profile); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_claims.cc b/vendor/core/src/core/oidc/oidc_claims.cc new file mode 100644 index 00000000..196e8fd2 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_claims.cc @@ -0,0 +1,286 @@ +#include + +#include + +#include // std::array +#include // std::size_t +#include // std::function +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_USERINFO{JSON::Object::hash("userinfo"sv)}; +constexpr auto HASH_ID_TOKEN{JSON::Object::hash("id_token"sv)}; +constexpr auto HASH_ESSENTIAL{JSON::Object::hash("essential"sv)}; +constexpr auto HASH_VALUE{JSON::Object::hash("value"sv)}; +constexpr auto HASH_VALUES{JSON::Object::hash("values"sv)}; + +// OpenID Connect Core 1.0 Section 5.1: the standard claims +constexpr std::array STANDARD_CLAIMS{ + {"sub", + "name", + "given_name", + "family_name", + "middle_name", + "nickname", + "preferred_username", + "profile", + "picture", + "website", + "email", + "email_verified", + "gender", + "birthdate", + "zoneinfo", + "locale", + "phone_number", + "phone_number_verified", + "address", + "updated_at"}}; + +// OpenID Connect Core 1.0 Section 5.4: the profile scope claim set +constexpr std::array PROFILE_CLAIMS{ + {"name", "family_name", "given_name", "middle_name", "nickname", + "preferred_username", "profile", "picture", "website", "gender", + "birthdate", "zoneinfo", "locale", "updated_at"}}; + +constexpr std::array EMAIL_CLAIMS{ + {"email", "email_verified"}}; + +constexpr std::array ADDRESS_CLAIMS{{"address"}}; + +constexpr std::array PHONE_CLAIMS{ + {"phone_number", "phone_number_verified"}}; + +auto emit_claims(const std::span claims, + const std::function &on_claim) + -> void { + for (const auto claim : claims) { + on_claim(claim); + } +} + +// OpenID Connect Core 1.0 Section 5.5.1: a claim request is null in the default +// manner, or an object carrying essential, value, or values +auto build_claim_specification(const OIDCClaimRequest &request) -> JSON { + if (!request.essential && request.value == nullptr && + request.values.empty()) { + return JSON{nullptr}; + } + + auto specification{JSON::make_object()}; + if (request.essential) { + specification.assign_assume_new("essential", JSON{true}, HASH_ESSENTIAL); + } + + if (request.value != nullptr) { + specification.assign_assume_new("value", JSON{*request.value}, HASH_VALUE); + } + + if (!request.values.empty()) { + auto candidates{JSON::make_array()}; + for (const auto &candidate : request.values) { + candidates.push_back(JSON{candidate}); + } + + specification.assign_assume_new("values", std::move(candidates), + HASH_VALUES); + } + + return specification; +} + +auto assign_claim_requests(JSON &document, const std::string_view target, + const JSON::Object::hash_type hash, + const std::span claims) + -> void { + if (claims.empty()) { + return; + } + + auto member{JSON::make_object()}; + for (const auto &request : claims) { + member.assign(request.name, build_claim_specification(request)); + } + + document.assign_assume_new(std::string{target}, std::move(member), hash); +} + +// OpenID Connect Core 1.0 Section 5.5: the request entry for a claim under a +// target member, or no pointer when the target or claim is absent +auto claim_specification(const JSON &claims, const std::string_view target, + const std::string_view claim) -> const JSON * { + if (!claims.is_object()) { + return nullptr; + } + + const auto *target_object{claims.try_at(target)}; + if (target_object == nullptr || !target_object->is_object()) { + return nullptr; + } + + return target_object->try_at(claim); +} + +} // namespace + +auto oidc_is_standard_claim(const std::string_view name) noexcept -> bool { + for (const auto claim : STANDARD_CLAIMS) { + if (claim == name) { + return true; + } + } + + return false; +} + +auto oidc_scope_to_claims(const std::string_view scopes, + const std::function &on_claim) + -> void { + bool has_openid{false}; + bool has_profile{false}; + bool has_email{false}; + bool has_address{false}; + bool has_phone{false}; + + std::size_t position{0}; + while (position <= scopes.size()) { + const auto space{scopes.find(' ', position)}; + const auto value{space == std::string_view::npos + ? scopes.substr(position) + : scopes.substr(position, space - position)}; + if (value == "openid") { + has_openid = true; + } else if (value == "profile") { + has_profile = true; + } else if (value == "email") { + has_email = true; + } else if (value == "address") { + has_address = true; + } else if (value == "phone") { + has_phone = true; + } + + if (space == std::string_view::npos) { + break; + } + + position = space + 1; + } + + // OpenID Connect Core 1.0 Section 5.4: openid yields sub, and each profile + // scope yields its claim set. The standard scopes are disjoint, so a claim is + // reported at most once + if (has_openid) { + on_claim("sub"); + } + + if (has_profile) { + emit_claims(PROFILE_CLAIMS, on_claim); + } + + if (has_email) { + emit_claims(EMAIL_CLAIMS, on_claim); + } + + if (has_address) { + emit_claims(ADDRESS_CLAIMS, on_claim); + } + + if (has_phone) { + emit_claims(PHONE_CLAIMS, on_claim); + } +} + +auto oidc_build_claims_parameter( + const std::span userinfo_claims, + const std::span id_token_claims) -> JSON { + auto document{JSON::make_object()}; + assign_claim_requests(document, "userinfo", HASH_USERINFO, userinfo_claims); + assign_claim_requests(document, "id_token", HASH_ID_TOKEN, id_token_claims); + return document; +} + +auto oidc_claims_parameter_requests(const JSON &claims, + const std::string_view target, + const std::string_view claim) -> bool { + // OpenID Connect Core 1.0 Section 5.5: a claim entry is either null (the + // default manner) or an object, so a malformed value such as a string is not + // honored as a request + const auto *specification{claim_specification(claims, target, claim)}; + return specification != nullptr && + (specification->is_null() || specification->is_object()); +} + +auto oidc_claims_parameter_is_essential(const JSON &claims, + const std::string_view target, + const std::string_view claim) -> bool { + const auto *specification{claim_specification(claims, target, claim)}; + if (specification == nullptr || !specification->is_object()) { + return false; + } + + const auto *essential{specification->try_at("essential"sv, HASH_ESSENTIAL)}; + return essential != nullptr && essential->is_boolean() && + essential->to_boolean(); +} + +auto oidc_claims_parameter_value(const JSON &claims, + const std::string_view target, + const std::string_view claim) -> const JSON * { + const auto *specification{claim_specification(claims, target, claim)}; + if (specification == nullptr || !specification->is_object()) { + return nullptr; + } + + return specification->try_at("value"sv, HASH_VALUE); +} + +auto oidc_claims_parameter_accepts(const JSON &claims, + const std::string_view target, + const std::string_view claim, + const JSON &value) -> bool { + // OpenID Connect Core 1.0 Section 5.5: only a null or object entry is a valid + // request, so an absent or malformed one permits nothing + const auto *specification{claim_specification(claims, target, claim)}; + if (specification == nullptr || + !(specification->is_null() || specification->is_object())) { + return false; + } + + // A null request carries no value constraint, so it permits any value + if (specification->is_null()) { + return true; + } + + // OpenID Connect Core 1.0 Section 5.5.1: value requests an exact value and + // values a set of acceptable ones, so a request carrying neither is + // unconstrained, and a present but malformed values constraint permits + // nothing rather than silently opening the request up + const auto *requested_value{specification->try_at("value"sv, HASH_VALUE)}; + const auto *requested_values{specification->try_at("values"sv, HASH_VALUES)}; + if (requested_value == nullptr && requested_values == nullptr) { + return true; + } + + if (requested_value != nullptr && value == *requested_value) { + return true; + } + + if (requested_values != nullptr && requested_values->is_array()) { + for (const auto &candidate : requested_values->as_array()) { + if (candidate == value) { + return true; + } + } + } + + return false; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_discovery.cc b/vendor/core/src/core/oidc/oidc_discovery.cc new file mode 100644 index 00000000..5b542174 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_discovery.cc @@ -0,0 +1,168 @@ +#include + +#include +#include +#include +#include + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_LINKS{JSON::Object::hash("links"sv)}; +constexpr auto HASH_REL{JSON::Object::hash("rel"sv)}; +constexpr auto HASH_HREF{JSON::Object::hash("href"sv)}; + +constexpr std::string_view ISSUER_RELATION{ + "http://openid.net/specs/connect/1.0/issuer"}; + +// OpenID Connect Discovery 1.0 Section 2: an issuer identifier is an https URL +// with a host and no query or fragment. RFC 3986 Section 3.1 makes the scheme +// case-insensitive +auto is_issuer_identifier(const std::string_view value) -> bool { + try { + const URI uri{value}; + return uri.scheme().has_value() && + equals_ignore_case(uri.scheme().value(), "https") && + uri.host().has_value() && !uri.host().value().empty() && + !uri.query().has_value() && !uri.fragment().has_value(); + } catch (const URIParseError &) { + return false; + } +} + +} // namespace + +auto oidc_discovery_url(const std::string_view issuer) + -> std::optional { + // OpenID Connect Discovery 1.0 Section 4.1: the configuration URL is the + // issuer with "/.well-known/openid-configuration" appended, the appended form + // rather than the RFC 8414 inserted form + std::string sink; + if (!oauth_well_known_url( + issuer, OAuthWellKnownKind::OpenIDConfigurationAppended, sink)) { + return std::nullopt; + } + + return sink; +} + +auto oidc_webfinger_request(const std::string_view identifier) + -> std::optional { + OIDCWebFingerRequest request; + + // OpenID Connect Discovery 1.0 Section 2.1: an acct URI or a URL is kept, a + // bare user@host becomes an acct URI, and any other input is an https URL + if (identifier.starts_with("acct:") || + identifier.find("://") != std::string_view::npos) { + request.resource = identifier; + } else if (identifier.find('@') != std::string_view::npos) { + request.resource = "acct:"; + request.resource.append(identifier); + } else { + request.resource = "https://"; + request.resource.append(identifier); + } + + // OpenID Connect Discovery 1.0 Section 2.1: the host is the domain after the + // last "@" of an acct resource, or the authority of a URL resource. An acct + // URI carries no authority component, so the URI parser cannot expose its + // host, but a URL resource is parsed rather than scanned by hand. The host is + // copied out because the parsed URI does not outlive this scope + std::string host; + if (request.resource.starts_with("acct:")) { + const auto account{ + rsplit_once(std::string_view{request.resource}.substr(5), '@')}; + if (!account.has_value()) { + return std::nullopt; + } + + host = account.value().second; + } else { + try { + const URI resource{request.resource}; + // OpenID Connect Discovery 1.0 Section 2.1: a URL resource uses the https + // scheme and carries a host, so a non-https URL identifier is rejected. + // RFC 3986 Section 3.1 makes the scheme case-insensitive + if (!resource.scheme().has_value() || + !equals_ignore_case(resource.scheme().value(), "https") || + !resource.host().has_value() || resource.host().value().empty()) { + return std::nullopt; + } + + // RFC 3986 Section 3.2.2: an IPv6 host is wrapped in brackets so the + // WebFinger URL it is interpolated into stays valid + if (resource.is_ipv6()) { + host.push_back('['); + host.append(resource.host().value()); + host.push_back(']'); + } else { + host = resource.host().value(); + } + + // RFC 7033 Section 4: the WebFinger host is the full authority, so a + // non-default port on the resource is preserved rather than dropped + if (resource.port().has_value()) { + host.push_back(':'); + host.append(std::to_string(resource.port().value())); + } + } catch (const URIParseError &) { + return std::nullopt; + } + } + + if (host.empty()) { + return std::nullopt; + } + + request.url = "https://"; + request.url.append(host); + request.url.append("/.well-known/webfinger?"); + URI::append_query_parameter(request.url, "resource", request.resource); + URI::append_query_parameter(request.url, "rel", ISSUER_RELATION); + return request; +} + +auto oidc_webfinger_issuer(const JSON &descriptor) + -> std::optional { + if (!descriptor.is_object()) { + return std::nullopt; + } + + const auto *links{descriptor.try_at("links"sv, HASH_LINKS)}; + if (links == nullptr || !links->is_array()) { + return std::nullopt; + } + + for (const auto &link : links->as_array()) { + if (!link.is_object()) { + continue; + } + + const auto *relation{link.try_at("rel"sv, HASH_REL)}; + const auto *href{link.try_at("href"sv, HASH_HREF)}; + if (relation == nullptr || !relation->is_string() || + relation->to_string() != ISSUER_RELATION || href == nullptr || + !href->is_string()) { + continue; + } + + // OpenID Connect Discovery 1.0 Section 2: a matching link whose href is not + // a valid issuer identifier is skipped rather than returned, and the search + // continues + const std::string_view value{href->to_string()}; + if (is_issuer_identifier(value)) { + return value; + } + } + + return std::nullopt; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_encryption.cc b/vendor/core/src/core/oidc/oidc_encryption.cc new file mode 100644 index 00000000..d24df4e4 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_encryption.cc @@ -0,0 +1,24 @@ +#include + +#include + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +auto oidc_decrypt_nested_jwt(const std::string_view compact, + const JWKPrivate &key) + -> std::optional { + // OpenID Connect Core 1.0 Section 10.2: an encrypted token is a signed JWT + // wrapped in a JWE, so decryption yields the inner compact JWS + const auto object{JWE::from(compact)}; + if (!object.has_value()) { + return std::nullopt; + } + + return jwe_decrypt(object.value(), key); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_error.cc b/vendor/core/src/core/oidc/oidc_error.cc new file mode 100644 index 00000000..729ff2aa --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_error.cc @@ -0,0 +1,60 @@ +#include + +#include // std::optional, std::nullopt +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oidc_error_code(const OIDCAuthenticationError error) noexcept + -> std::string_view { + switch (error) { + case OIDCAuthenticationError::InteractionRequired: + return "interaction_required"; + case OIDCAuthenticationError::LoginRequired: + return "login_required"; + case OIDCAuthenticationError::AccountSelectionRequired: + return "account_selection_required"; + case OIDCAuthenticationError::ConsentRequired: + return "consent_required"; + case OIDCAuthenticationError::InvalidRequestURI: + return "invalid_request_uri"; + case OIDCAuthenticationError::InvalidRequestObject: + return "invalid_request_object"; + case OIDCAuthenticationError::RequestNotSupported: + return "request_not_supported"; + case OIDCAuthenticationError::RequestURINotSupported: + return "request_uri_not_supported"; + case OIDCAuthenticationError::RegistrationNotSupported: + return "registration_not_supported"; + } + + std::unreachable(); +} + +auto to_oidc_authentication_error(const std::string_view code) noexcept + -> std::optional { + if (code == "interaction_required") { + return OIDCAuthenticationError::InteractionRequired; + } else if (code == "login_required") { + return OIDCAuthenticationError::LoginRequired; + } else if (code == "account_selection_required") { + return OIDCAuthenticationError::AccountSelectionRequired; + } else if (code == "consent_required") { + return OIDCAuthenticationError::ConsentRequired; + } else if (code == "invalid_request_uri") { + return OIDCAuthenticationError::InvalidRequestURI; + } else if (code == "invalid_request_object") { + return OIDCAuthenticationError::InvalidRequestObject; + } else if (code == "request_not_supported") { + return OIDCAuthenticationError::RequestNotSupported; + } else if (code == "request_uri_not_supported") { + return OIDCAuthenticationError::RequestURINotSupported; + } else if (code == "registration_not_supported") { + return OIDCAuthenticationError::RegistrationNotSupported; + } else { + return std::nullopt; + } +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_hash.cc b/vendor/core/src/core/oidc/oidc_hash.cc new file mode 100644 index 00000000..7394b5b2 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_hash.cc @@ -0,0 +1,55 @@ +#include + +#include +#include + +#include // std::uint8_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +auto oidc_token_hash(const std::string_view token, const JWSAlgorithm algorithm) + -> std::optional { + // The correct digest for EdDSA depends on the signing curve, SHA-512 for + // Ed25519 but SHAKE256 for Ed448, which the algorithm alone does not convey. + // Selecting SHA-512 for every EdDSA value would silently produce a wrong + // binding for Ed448, so it is rejected rather than guessed + if (algorithm == JWSAlgorithm::EdDSA) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 3.1.3.6: "hash the octets of the ASCII + // representation ... with the hash algorithm used ... take the left-most half + // of the hash and base64url-encode it". The digest is selected by an explicit + // table rather than by slicing the algorithm name + switch (jws_algorithm_digest_bits(algorithm)) { + case 256: { + const auto digest{sha256_digest(token)}; + return base64url_encode(std::span{digest.data(), 16}); + } + case 384: { + const auto digest{sha384_digest(token)}; + return base64url_encode(std::span{digest.data(), 24}); + } + case 512: { + const auto digest{sha512_digest(token)}; + return base64url_encode(std::span{digest.data(), 32}); + } + default: + // A defensive fallback for a future algorithm whose digest size is not + // one of the three the currently defined algorithms use + return std::nullopt; + } +} + +auto oidc_verify_token_hash(const std::string_view token, + const JWSAlgorithm algorithm, + const std::string_view claim) -> bool { + const auto expected{oidc_token_hash(token, algorithm)}; + return expected.has_value() && secure_equals(expected.value(), claim); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_id_token.cc b/vendor/core/src/core/oidc/oidc_id_token.cc new file mode 100644 index 00000000..4bc2ae3d --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_id_token.cc @@ -0,0 +1,322 @@ +#include + +#include +#include +#include +#include +#include + +#include // std::chrono::system_clock, std::chrono::seconds, std::chrono::duration, std::chrono::duration_cast +#include // std::int64_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::out_of_range +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_ALG{JSON::Object::hash("alg"sv)}; +constexpr auto HASH_ISS{JSON::Object::hash("iss"sv)}; +constexpr auto HASH_SUB{JSON::Object::hash("sub"sv)}; +constexpr auto HASH_AUD{JSON::Object::hash("aud"sv)}; +constexpr auto HASH_EXP{JSON::Object::hash("exp"sv)}; +constexpr auto HASH_IAT{JSON::Object::hash("iat"sv)}; +constexpr auto HASH_NONCE{JSON::Object::hash("nonce"sv)}; +constexpr auto HASH_AZP{JSON::Object::hash("azp"sv)}; +constexpr auto HASH_ACR{JSON::Object::hash("acr"sv)}; +constexpr auto HASH_AUTH_TIME{JSON::Object::hash("auth_time"sv)}; +constexpr auto HASH_AT_HASH{JSON::Object::hash("at_hash"sv)}; +constexpr auto HASH_C_HASH{JSON::Object::hash("c_hash"sv)}; +constexpr auto HASH_ID_TOKEN{JSON::Object::hash("id_token"sv)}; + +auto epoch_seconds(const std::chrono::system_clock::time_point point) -> JSON { + return JSON{static_cast( + std::chrono::duration_cast(point.time_since_epoch()) + .count())}; +} + +// The OpenID Connect specific ID Token checks that layer on top of the base +// JSON Web Token verification (OpenID Connect Core 1.0 Section 3.1.3.7) +auto oidc_id_token_checks(const JWT &token, const std::string_view issuer, + const std::string_view client_id, + const std::chrono::system_clock::time_point now, + const OIDCValidationOptions &options) + -> std::optional { + const auto &payload{token.payload()}; + + // OpenID Connect Core 1.0 Section 2: the subject is REQUIRED in an ID Token + const auto subject{token.subject()}; + if (!subject.has_value()) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 3.1.3.7 step 5: when the audience carries + // more than one value the authorized party is REQUIRED and must be the + // client, and when it is present at all it must be the client + const auto *audience{payload.try_at("aud"sv, HASH_AUD)}; + + // A malformed audience array, one carrying a non-string member, is rejected + // rather than accepted because one member happened to match the client + // (OpenID Connect Core 1.0 Section 2) + if (audience != nullptr && audience->is_array() && + !audience->is_array_of_strings()) { + return std::nullopt; + } + + const bool multiple_audiences{audience != nullptr && audience->is_array() && + audience->size() > 1}; + const auto *authorized_party{payload.try_at("azp"sv, HASH_AZP)}; + if (multiple_audiences) { + if (authorized_party == nullptr || !authorized_party->is_string() || + !secure_equals(authorized_party->to_string(), client_id)) { + return std::nullopt; + } + } else if (authorized_party != nullptr) { + if (!authorized_party->is_string() || + !secure_equals(authorized_party->to_string(), client_id)) { + return std::nullopt; + } + } + + // OpenID Connect Core 1.0 Section 3.1.3.7 step 11: the nonce is echoed and + // compared in constant time when one was sent + if (options.nonce.has_value()) { + const auto *nonce{payload.try_at("nonce"sv, HASH_NONCE)}; + if (nonce == nullptr || !nonce->is_string() || + !secure_equals(nonce->to_string(), options.nonce.value())) { + return std::nullopt; + } + } + + // OpenID Connect Core 1.0 Section 2: iat is REQUIRED in an ID Token, so its + // presence and validity are enforced unconditionally rather than as an opt-in + const auto issued_at{token.issued_at()}; + if (!issued_at.has_value()) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 3.1.3.7 step 10: the optional issued-at age + // policy + if (options.maximum_issued_at_age.has_value() && + now - issued_at.value() > options.maximum_issued_at_age.value()) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 3.1.3.7 step 12: the authentication context + // class must be acceptable when a set was requested + const auto *class_reference{payload.try_at("acr"sv, HASH_ACR)}; + if (!options.acceptable_authentication_context_classes.empty()) { + if (class_reference == nullptr || !class_reference->is_string()) { + return std::nullopt; + } + + bool acceptable{false}; + for (const auto candidate : + options.acceptable_authentication_context_classes) { + if (candidate == class_reference->to_string()) { + acceptable = true; + break; + } + } + + if (!acceptable) { + return std::nullopt; + } + } + + // OpenID Connect Core 1.0 Section 3.1.3.7 step 13: re-authentication is + // forced when the authentication is older than the requested maximum age + std::optional authentication_time; + const auto *auth_time{payload.try_at("auth_time"sv, HASH_AUTH_TIME)}; + if (auth_time != nullptr && auth_time->is_number()) { + // A NumericDate may be non-integer (RFC 7519 Section 2), and a value beyond + // the range of a double cannot stand for a usable timestamp, so it is + // treated as absent rather than aborting, matching how the JWT layer reads + // the registered time claims + try { + authentication_time = from_unix_timestamp( + std::chrono::duration{auth_time->as_real()}); + } catch (const std::out_of_range &) { + authentication_time = std::nullopt; + } + } + + if (options.maximum_authentication_age.has_value()) { + // An authentication time in the future has not happened yet, so it cannot + // satisfy a freshness window and is rejected before the age comparison + if (!authentication_time.has_value() || authentication_time.value() > now || + now - authentication_time.value() > + options.maximum_authentication_age.value()) { + return std::nullopt; + } + } + + // OpenID Connect Core 1.0 Sections 3.1.3.6, 3.2.2.10, and 3.3.2.11: the + // binding hash is REQUIRED in the front-channel flows. A required binding + // must be a string claim actually verified against its token, so an absent or + // non-string claim, or a missing token to verify against, all fail. When it + // is not required, a present claim is still verified whenever the token is + // given + const auto *at_hash{payload.try_at("at_hash"sv, HASH_AT_HASH)}; + if (options.require_access_token_hash) { + if (at_hash == nullptr || !at_hash->is_string() || + !options.access_token.has_value() || !token.algorithm().has_value() || + !oidc_verify_token_hash(options.access_token.value(), + token.algorithm().value(), + at_hash->to_string())) { + return std::nullopt; + } + } else if (at_hash != nullptr && options.access_token.has_value() && + (!at_hash->is_string() || !token.algorithm().has_value() || + !oidc_verify_token_hash(options.access_token.value(), + token.algorithm().value(), + at_hash->to_string()))) { + return std::nullopt; + } + + const auto *code_hash{payload.try_at("c_hash"sv, HASH_C_HASH)}; + if (options.require_code_hash) { + if (code_hash == nullptr || !code_hash->is_string() || + !options.code.has_value() || !token.algorithm().has_value() || + !oidc_verify_token_hash(options.code.value(), token.algorithm().value(), + code_hash->to_string())) { + return std::nullopt; + } + } else if (code_hash != nullptr && options.code.has_value() && + (!code_hash->is_string() || !token.algorithm().has_value() || + !oidc_verify_token_hash(options.code.value(), + token.algorithm().value(), + code_hash->to_string()))) { + return std::nullopt; + } + + OIDCIdentity identity; + identity.subject = std::string{subject.value()}; + identity.issuer = std::string{issuer}; + if (class_reference != nullptr && class_reference->is_string()) { + identity.authentication_context_class = + std::string{class_reference->to_string()}; + } + + identity.authentication_time = authentication_time; + return identity; +} + +} // namespace + +auto oidc_parse_id_token(const JSON &token_response) + -> std::optional { + if (!token_response.is_object()) { + return std::nullopt; + } + + const auto *member{token_response.try_at("id_token"sv, HASH_ID_TOKEN)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return member->to_string(); +} + +auto oidc_validate_id_token( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view issuer, const std::string_view client_id, + const std::chrono::system_clock::time_point now, + const OIDCValidationOptions &options, const JWTClockSkew clock_skew) + -> std::optional { + // The base verification pins the algorithm and checks the signature, issuer, + // audience, expiration, not-before, and issued-at (OpenID Connect Core 1.0 + // Section 3.1.3.7 steps 6 through 9) + const auto error{jwt_verify(token, keys, allowed_algorithms, issuer, + client_id, now, clock_skew)}; + if (error.has_value()) { + return std::nullopt; + } + + return oidc_id_token_checks(token, issuer, client_id, now, options); +} + +auto oidc_validate_id_token( + JWKSProvider &provider, const JWT &token, + const std::span allowed_algorithms, + const std::string_view issuer, const std::string_view client_id, + const OIDCValidationOptions &options) -> std::optional { + // The provider owns the clock, and the OpenID Connect steps run against the + // same reading the signature verification used + std::chrono::system_clock::time_point resolved_now; + const auto error{provider.verify(token, allowed_algorithms, issuer, client_id, + std::nullopt, std::nullopt, resolved_now)}; + if (error.has_value()) { + return std::nullopt; + } + + return oidc_id_token_checks(token, issuer, client_id, resolved_now, options); +} + +auto oidc_mint_id_token(const OIDCIdTokenClaims &claims, const JWKPrivate &key, + const JWSAlgorithm algorithm) + -> std::optional { + auto header{JSON::make_object()}; + header.assign_assume_new("alg", JSON{jws_algorithm_name(algorithm)}, + HASH_ALG); + + auto payload{JSON::make_object()}; + payload.assign_assume_new("iss", JSON{claims.issuer}, HASH_ISS); + payload.assign_assume_new("sub", JSON{claims.subject}, HASH_SUB); + payload.assign_assume_new("aud", JSON{claims.audience}, HASH_AUD); + payload.assign_assume_new("exp", epoch_seconds(claims.expiration), HASH_EXP); + payload.assign_assume_new("iat", epoch_seconds(claims.issued_at), HASH_IAT); + + if (claims.nonce.has_value()) { + payload.assign_assume_new("nonce", JSON{claims.nonce.value()}, HASH_NONCE); + } + + if (claims.authorized_party.has_value()) { + payload.assign_assume_new("azp", JSON{claims.authorized_party.value()}, + HASH_AZP); + } + + if (claims.authentication_context_class.has_value()) { + payload.assign_assume_new( + "acr", JSON{claims.authentication_context_class.value()}, HASH_ACR); + } + + if (claims.authentication_time.has_value()) { + payload.assign_assume_new("auth_time", + epoch_seconds(claims.authentication_time.value()), + HASH_AUTH_TIME); + } + + // OpenID Connect Core 1.0 Section 3.1.3.6: at_hash binds the access token and + // c_hash binds the code, both hashed under the ID Token signing algorithm. + // When a token to bind is supplied but its hash cannot be computed for the + // algorithm, minting fails rather than emitting a token missing a REQUIRED + // binding + if (claims.access_token.has_value()) { + const auto hash{oidc_token_hash(claims.access_token.value(), algorithm)}; + if (!hash.has_value()) { + return std::nullopt; + } + + payload.assign_assume_new("at_hash", JSON{hash.value()}, HASH_AT_HASH); + } + + if (claims.code.has_value()) { + const auto hash{oidc_token_hash(claims.code.value(), algorithm)}; + if (!hash.has_value()) { + return std::nullopt; + } + + payload.assign_assume_new("c_hash", JSON{hash.value()}, HASH_C_HASH); + } + + return jwt_sign(header, payload, key); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_logout.cc b/vendor/core/src/core/oidc/oidc_logout.cc new file mode 100644 index 00000000..a2da6839 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_logout.cc @@ -0,0 +1,195 @@ +#include + +#include +#include +#include +#include + +#include "oidc_verify.h" + +#include // std::chrono::system_clock +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_SUB{JSON::Object::hash("sub"sv)}; +constexpr auto HASH_SID{JSON::Object::hash("sid"sv)}; +constexpr auto HASH_JTI{JSON::Object::hash("jti"sv)}; +constexpr auto HASH_EVENTS{JSON::Object::hash("events"sv)}; + +constexpr std::string_view BACKCHANNEL_LOGOUT_EVENT{ + "http://schemas.openid.net/event/backchannel-logout"}; +constexpr auto HASH_BACKCHANNEL_LOGOUT_EVENT{ + JSON::Object::hash(BACKCHANNEL_LOGOUT_EVENT)}; + +// The query is opened lazily on the first present parameter, so an all-empty +// request leaves the endpoint untouched rather than appending a dangling +// separator, and an endpoint that already carries a query is continued rather +// than opened again +auto append_logout_parameter(std::string &sink, const std::string_view endpoint, + const std::string_view name, + const std::string_view value, bool &query_opened) + -> void { + if (value.empty()) { + return; + } + + if (!query_opened) { + if (endpoint.find('?') == std::string_view::npos) { + sink.push_back('?'); + } else if (sink.back() != '?' && sink.back() != '&') { + sink.push_back('&'); + } + + query_opened = true; + } + + URI::append_query_parameter(sink, name, value); +} + +} // namespace + +auto oidc_build_logout_url(const std::string_view end_session_endpoint, + const OIDCLogoutRequest &request, std::string &sink) + -> void { + sink.append(end_session_endpoint); + + bool query_opened{false}; + append_logout_parameter(sink, end_session_endpoint, "id_token_hint", + request.id_token_hint, query_opened); + append_logout_parameter(sink, end_session_endpoint, "logout_hint", + request.logout_hint, query_opened); + append_logout_parameter(sink, end_session_endpoint, "client_id", + request.client_id, query_opened); + append_logout_parameter(sink, end_session_endpoint, + "post_logout_redirect_uri", + request.post_logout_redirect_uri, query_opened); + append_logout_parameter(sink, end_session_endpoint, "state", request.state, + query_opened); + append_logout_parameter(sink, end_session_endpoint, "ui_locales", + request.ui_locales, query_opened); +} + +auto oidc_validate_logout_token( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view issuer, const std::string_view client_id, + const std::chrono::system_clock::time_point now, + const JWTClockSkew clock_skew) -> bool { + // OpenID Connect Back-Channel Logout 1.0 Section 2.6 step: the algorithm is + // pinned and the key selected by identifier, never taken from the header + // alone, and alg none is never allowed + if (!oidc_verify_selected_signature(token, keys, allowed_algorithms)) { + return false; + } + + // OpenID Connect Back-Channel Logout 1.0 Section 2.4: the typ header SHOULD + // be logout+jwt, so it is validated only when present + if (token.type().has_value() && !token.has_type("logout+jwt")) { + return false; + } + + // OpenID Connect Back-Channel Logout 1.0 Section 2.6: iss and aud as for an + // ID Token + const auto token_issuer{token.issuer()}; + if (!token_issuer.has_value() || token_issuer.value() != issuer) { + return false; + } + + if (!token.has_audience(client_id)) { + return false; + } + + // OpenID Connect Back-Channel Logout 1.0 Section 2.4: iat is REQUIRED and + // must not be in the future + const auto issued_at{token.issued_at()}; + if (!issued_at.has_value() || + issued_at.value() > now + clock_skew.issued_at) { + return false; + } + + // OpenID Connect Back-Channel Logout 1.0 Section 2.4: exp is REQUIRED and is + // validated as for an ID Token, so a token at or past its expiration (with + // skew) is rejected + const auto expires_at{token.expires_at()}; + if (!expires_at.has_value() || + now >= expires_at.value() + clock_skew.expiration) { + return false; + } + + const auto &payload{token.payload()}; + + // OpenID Connect Back-Channel Logout 1.0 Section 2.4: a sub, a sid, or both, + // each a string when present + const auto *subject{payload.try_at("sub"sv, HASH_SUB)}; + const auto *session{payload.try_at("sid"sv, HASH_SID)}; + const auto has_subject{subject != nullptr && subject->is_string()}; + const auto has_session{session != nullptr && session->is_string()}; + if (!has_subject && !has_session) { + return false; + } + + // The events claim is an object carrying the back-channel logout member, + // whose value is an object + const auto *events{payload.try_at("events"sv, HASH_EVENTS)}; + if (events == nullptr || !events->is_object()) { + return false; + } + + const auto *logout_event{ + events->try_at(BACKCHANNEL_LOGOUT_EVENT, HASH_BACKCHANNEL_LOGOUT_EVENT)}; + if (logout_event == nullptr || !logout_event->is_object()) { + return false; + } + + // A logout token MUST NOT contain a nonce, which prevents it being confused + // with an ID Token, and a jti string is REQUIRED for replay detection + const auto *token_identifier{payload.try_at("jti"sv, HASH_JTI)}; + if (payload.defines("nonce") || token_identifier == nullptr || + !token_identifier->is_string()) { + return false; + } + + return true; +} + +auto oidc_front_channel_pairing_is_valid(const std::string_view issuer, + const std::string_view session_id) + -> bool { + // OpenID Connect Front-Channel Logout 1.0 Section 3: iss and sid are included + // together or not at all + return issuer.empty() == session_id.empty(); +} + +auto oidc_session_state(const std::string_view client_id, + const std::string_view origin, + const std::string_view provider_browser_state, + const std::string_view salt) -> std::string { + // OpenID Connect Session Management 1.0 Section 4.2: the value is + // base64url(SHA256(client_id + " " + origin + " " + op_browser_state + " " + + // salt)) + "." + salt + std::string message; + message.reserve(client_id.size() + origin.size() + + provider_browser_state.size() + salt.size() + 3); + message.append(client_id); + message.push_back(' '); + message.append(origin); + message.push_back(' '); + message.append(provider_browser_state); + message.push_back(' '); + message.append(salt); + + const auto digest{sha256_digest(message)}; + std::string result{base64url_encode(digest)}; + result.push_back('.'); + result.append(salt); + return result; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_metadata.cc b/vendor/core/src/core/oidc/oidc_metadata.cc new file mode 100644 index 00000000..9245ba6f --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_metadata.cc @@ -0,0 +1,349 @@ +#include + +#include +#include +#include +#include +#include + +#include // std::optional, std::nullopt +#include // std::string_view +#include // std::move + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_USERINFO_ENDPOINT{ + JSON::Object::hash("userinfo_endpoint"sv)}; +constexpr auto HASH_END_SESSION_ENDPOINT{ + JSON::Object::hash("end_session_endpoint"sv)}; +constexpr auto HASH_CHECK_SESSION_IFRAME{ + JSON::Object::hash("check_session_iframe"sv)}; +constexpr auto HASH_SUBJECT_TYPES{ + JSON::Object::hash("subject_types_supported"sv)}; +constexpr auto HASH_ID_TOKEN_SIGNING_ALGS{ + JSON::Object::hash("id_token_signing_alg_values_supported"sv)}; +constexpr auto HASH_ID_TOKEN_ENCRYPTION_ALGS{ + JSON::Object::hash("id_token_encryption_alg_values_supported"sv)}; +constexpr auto HASH_ID_TOKEN_ENCRYPTION_ENCS{ + JSON::Object::hash("id_token_encryption_enc_values_supported"sv)}; +constexpr auto HASH_USERINFO_SIGNING_ALGS{ + JSON::Object::hash("userinfo_signing_alg_values_supported"sv)}; +constexpr auto HASH_USERINFO_ENCRYPTION_ALGS{ + JSON::Object::hash("userinfo_encryption_alg_values_supported"sv)}; +constexpr auto HASH_USERINFO_ENCRYPTION_ENCS{ + JSON::Object::hash("userinfo_encryption_enc_values_supported"sv)}; +constexpr auto HASH_REQUEST_OBJECT_SIGNING_ALGS{ + JSON::Object::hash("request_object_signing_alg_values_supported"sv)}; +constexpr auto HASH_REQUEST_OBJECT_ENCRYPTION_ALGS{ + JSON::Object::hash("request_object_encryption_alg_values_supported"sv)}; +constexpr auto HASH_REQUEST_OBJECT_ENCRYPTION_ENCS{ + JSON::Object::hash("request_object_encryption_enc_values_supported"sv)}; +constexpr auto HASH_CLAIMS_SUPPORTED{JSON::Object::hash("claims_supported"sv)}; +constexpr auto HASH_ACR_VALUES{JSON::Object::hash("acr_values_supported"sv)}; +constexpr auto HASH_CLAIM_TYPES{JSON::Object::hash("claim_types_supported"sv)}; +constexpr auto HASH_DISPLAY_VALUES{ + JSON::Object::hash("display_values_supported"sv)}; +constexpr auto HASH_CLAIMS_LOCALES{ + JSON::Object::hash("claims_locales_supported"sv)}; +constexpr auto HASH_UI_LOCALES{JSON::Object::hash("ui_locales_supported"sv)}; +constexpr auto HASH_SCOPES_SUPPORTED{JSON::Object::hash("scopes_supported"sv)}; +constexpr auto HASH_CLAIMS_PARAMETER{ + JSON::Object::hash("claims_parameter_supported"sv)}; +constexpr auto HASH_REQUEST_PARAMETER{ + JSON::Object::hash("request_parameter_supported"sv)}; +constexpr auto HASH_REQUEST_URI_PARAMETER{ + JSON::Object::hash("request_uri_parameter_supported"sv)}; +constexpr auto HASH_REQUIRE_REQUEST_URI_REGISTRATION{ + JSON::Object::hash("require_request_uri_registration"sv)}; + +auto string_member(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) + -> std::optional { + if (!data.is_object()) { + return std::nullopt; + } + + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return std::string_view{member->to_string()}; +} + +// A required "supported" list, a present and non-empty array of strings +auto is_required_string_array(const JSON *member) -> bool { + return member != nullptr && member->is_array_of_strings() && !member->empty(); +} + +auto validate_provider_metadata(const OAuthServerMetadata &oauth) -> void { + const auto &data{oauth.data()}; + + // OpenID Connect Discovery 1.0 Section 3: OpenID Connect tightens jwks_uri + // from OPTIONAL (RFC 8414) to REQUIRED, since the RP needs the signing keys + if (!oauth.jwks_uri().has_value()) { + throw OIDCMetadataParseError{}; + } + + // OpenID Connect Discovery 1.0 Section 3: subject_types_supported is REQUIRED + const auto *subject_types{ + data.try_at("subject_types_supported"sv, HASH_SUBJECT_TYPES)}; + if (!is_required_string_array(subject_types)) { + throw OIDCMetadataParseError{}; + } + + // OpenID Connect Discovery 1.0 Section 3: + // id_token_signing_alg_values_supported is REQUIRED and "The algorithm RS256 + // MUST be included". "The value none MAY be supported but MUST NOT be used + // unless the Response Type used returns no ID Token from the Authorization + // Endpoint", so a provider is allowed to advertise it here, and the module + // enforces the never-accept-none rule when an ID Token is validated rather + // than when the metadata is parsed + const auto *id_token_algs{data.try_at( + "id_token_signing_alg_values_supported"sv, HASH_ID_TOKEN_SIGNING_ALGS)}; + if (!is_required_string_array(id_token_algs) || + !id_token_algs->contains("RS256")) { + throw OIDCMetadataParseError{}; + } +} + +auto is_https_url(const std::string_view value) -> bool { + // OpenID Connect Discovery 1.0 Section 3: every advertised endpoint is a URL + // using the https scheme + try { + const URI uri{value}; + // A fragment is never sent in an HTTP request, so an endpoint carrying one + // would advertise a location clients cannot reach and is rejected. RFC 3986 + // Section 3.1 makes the scheme case-insensitive + return uri.scheme().has_value() && + equals_ignore_case(uri.scheme().value(), "https") && + uri.host().has_value() && !uri.host().value().empty() && + !uri.fragment().has_value(); + } catch (const URIParseError &) { + return false; + } +} + +} // namespace + +OIDCProviderMetadata::OIDCProviderMetadata(JSON &&data, + const std::string_view issuer) + : oauth_{std::move(data), issuer} { + validate_provider_metadata(this->oauth_); +} + +auto OIDCProviderMetadata::from(JSON &&data, const std::string_view issuer) + -> std::optional { + try { + return OIDCProviderMetadata{std::move(data), issuer}; + } catch (const OAuthMetadataParseError &) { + return std::nullopt; + } catch (const OIDCMetadataParseError &) { + return std::nullopt; + } +} + +auto OIDCProviderMetadata::issuer() const -> std::string_view { + return this->oauth_.issuer(); +} + +auto OIDCProviderMetadata::authorization_endpoint() const + -> std::optional { + return this->oauth_.authorization_endpoint(); +} + +auto OIDCProviderMetadata::token_endpoint() const + -> std::optional { + return this->oauth_.token_endpoint(); +} + +auto OIDCProviderMetadata::userinfo_endpoint() const + -> std::optional { + return string_member(this->oauth_.data(), "userinfo_endpoint"sv, + HASH_USERINFO_ENDPOINT); +} + +auto OIDCProviderMetadata::registration_endpoint() const + -> std::optional { + return this->oauth_.registration_endpoint(); +} + +auto OIDCProviderMetadata::jwks_uri() const -> std::string_view { + return this->oauth_.jwks_uri().value(); +} + +auto OIDCProviderMetadata::end_session_endpoint() const + -> std::optional { + return string_member(this->oauth_.data(), "end_session_endpoint"sv, + HASH_END_SESSION_ENDPOINT); +} + +auto OIDCProviderMetadata::check_session_iframe() const + -> std::optional { + return string_member(this->oauth_.data(), "check_session_iframe"sv, + HASH_CHECK_SESSION_IFRAME); +} + +auto OIDCProviderMetadata::supports_subject_type( + const std::string_view value) const -> bool { + return this->oauth_.data().array_member_contains("subject_types_supported"sv, + HASH_SUBJECT_TYPES, value); +} + +auto OIDCProviderMetadata::supports_id_token_signing_alg( + const std::string_view value) const -> bool { + return this->oauth_.data().array_member_contains( + "id_token_signing_alg_values_supported"sv, HASH_ID_TOKEN_SIGNING_ALGS, + value); +} + +auto OIDCProviderMetadata::supports_response_type( + const std::string_view value) const -> bool { + return this->oauth_.supports_response_type(value); +} + +auto OIDCProviderMetadata::supports_scope(const std::string_view value) const + -> bool { + return this->oauth_.data().array_member_contains( + "scopes_supported"sv, HASH_SCOPES_SUPPORTED, value); +} + +auto OIDCProviderMetadata::supports_claim(const std::string_view value) const + -> bool { + return this->oauth_.data().array_member_contains( + "claims_supported"sv, HASH_CLAIMS_SUPPORTED, value); +} + +auto OIDCProviderMetadata::oauth() const -> const OAuthServerMetadata & { + return this->oauth_; +} + +auto OIDCProviderMetadata::data() const -> const JSON & { + return this->oauth_.data(); +} + +auto oidc_make_provider_metadata(const OIDCProviderMetadataConfig &config) + -> std::optional { + // OpenID Connect Discovery 1.0 Section 3: jwks_uri, subject_types_supported, + // and id_token_signing_alg_values_supported are REQUIRED for OpenID Connect + if (config.base.jwks_uri.empty() || config.subject_types_supported.empty() || + config.id_token_signing_alg_values_supported.empty()) { + return std::nullopt; + } + + // OpenID Connect Discovery 1.0 Section 3: RS256 MUST be included. The module + // never publishes none even though the specification permits advertising it + // (module design Section 14) + bool advertises_rs256{false}; + for (const auto algorithm : config.id_token_signing_alg_values_supported) { + if (algorithm == "none") { + return std::nullopt; + } + + if (algorithm == "RS256") { + advertises_rs256 = true; + } + } + + if (!advertises_rs256) { + return std::nullopt; + } + + // OpenID Connect Discovery 1.0 Section 3: the OpenID Connect endpoints use + // the https scheme + if ((!config.userinfo_endpoint.empty() && + !is_https_url(config.userinfo_endpoint)) || + (!config.end_session_endpoint.empty() && + !is_https_url(config.end_session_endpoint)) || + (!config.check_session_iframe.empty() && + !is_https_url(config.check_session_iframe))) { + return std::nullopt; + } + + auto base_document{oauth_make_server_metadata(config.base)}; + if (!base_document.has_value()) { + return std::nullopt; + } + + auto &document{base_document.value()}; + document.assign_if_nonempty("userinfo_endpoint", HASH_USERINFO_ENDPOINT, + config.userinfo_endpoint); + document.assign_if_nonempty("end_session_endpoint", HASH_END_SESSION_ENDPOINT, + config.end_session_endpoint); + document.assign_if_nonempty("check_session_iframe", HASH_CHECK_SESSION_IFRAME, + config.check_session_iframe); + document.assign_if_nonempty("subject_types_supported", HASH_SUBJECT_TYPES, + config.subject_types_supported); + document.assign_if_nonempty("id_token_signing_alg_values_supported", + HASH_ID_TOKEN_SIGNING_ALGS, + config.id_token_signing_alg_values_supported); + document.assign_if_nonempty("id_token_encryption_alg_values_supported", + HASH_ID_TOKEN_ENCRYPTION_ALGS, + config.id_token_encryption_alg_values_supported); + document.assign_if_nonempty("id_token_encryption_enc_values_supported", + HASH_ID_TOKEN_ENCRYPTION_ENCS, + config.id_token_encryption_enc_values_supported); + document.assign_if_nonempty("userinfo_signing_alg_values_supported", + HASH_USERINFO_SIGNING_ALGS, + config.userinfo_signing_alg_values_supported); + document.assign_if_nonempty("userinfo_encryption_alg_values_supported", + HASH_USERINFO_ENCRYPTION_ALGS, + config.userinfo_encryption_alg_values_supported); + document.assign_if_nonempty("userinfo_encryption_enc_values_supported", + HASH_USERINFO_ENCRYPTION_ENCS, + config.userinfo_encryption_enc_values_supported); + document.assign_if_nonempty( + "request_object_signing_alg_values_supported", + HASH_REQUEST_OBJECT_SIGNING_ALGS, + config.request_object_signing_alg_values_supported); + document.assign_if_nonempty( + "request_object_encryption_alg_values_supported", + HASH_REQUEST_OBJECT_ENCRYPTION_ALGS, + config.request_object_encryption_alg_values_supported); + document.assign_if_nonempty( + "request_object_encryption_enc_values_supported", + HASH_REQUEST_OBJECT_ENCRYPTION_ENCS, + config.request_object_encryption_enc_values_supported); + document.assign_if_nonempty("claims_supported", HASH_CLAIMS_SUPPORTED, + config.claims_supported); + document.assign_if_nonempty("acr_values_supported", HASH_ACR_VALUES, + config.acr_values_supported); + document.assign_if_nonempty("claim_types_supported", HASH_CLAIM_TYPES, + config.claim_types_supported); + document.assign_if_nonempty("display_values_supported", HASH_DISPLAY_VALUES, + config.display_values_supported); + document.assign_if_nonempty("claims_locales_supported", HASH_CLAIMS_LOCALES, + config.claims_locales_supported); + document.assign_if_nonempty("ui_locales_supported", HASH_UI_LOCALES, + config.ui_locales_supported); + + // OpenID Connect Discovery 1.0 Section 3: each capability flag is emitted + // only when it differs from its default, keeping the document minimal + if (config.claims_parameter_supported) { + document.assign_assume_new("claims_parameter_supported", JSON{true}, + HASH_CLAIMS_PARAMETER); + } + + if (config.request_parameter_supported) { + document.assign_assume_new("request_parameter_supported", JSON{true}, + HASH_REQUEST_PARAMETER); + } + + if (!config.request_uri_parameter_supported) { + document.assign_assume_new("request_uri_parameter_supported", JSON{false}, + HASH_REQUEST_URI_PARAMETER); + } + + if (config.require_request_uri_registration) { + document.assign_assume_new("require_request_uri_registration", JSON{true}, + HASH_REQUIRE_REQUEST_URI_REGISTRATION); + } + + return document; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_registration.cc b/vendor/core/src/core/oidc/oidc_registration.cc new file mode 100644 index 00000000..390bce47 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_registration.cc @@ -0,0 +1,287 @@ +#include + +#include +#include +#include +#include +#include + +#include // std::chrono::seconds +#include // std::numeric_limits +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view +#include // std::move + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_REDIRECT_URIS{JSON::Object::hash("redirect_uris"sv)}; +constexpr auto HASH_APPLICATION_TYPE{JSON::Object::hash("application_type"sv)}; +constexpr auto HASH_SUBJECT_TYPE{JSON::Object::hash("subject_type"sv)}; +constexpr auto HASH_SECTOR_IDENTIFIER_URI{ + JSON::Object::hash("sector_identifier_uri"sv)}; +constexpr auto HASH_ID_TOKEN_SIGNED_ALG{ + JSON::Object::hash("id_token_signed_response_alg"sv)}; +constexpr auto HASH_ID_TOKEN_ENCRYPTED_ALG{ + JSON::Object::hash("id_token_encrypted_response_alg"sv)}; +constexpr auto HASH_USERINFO_SIGNED_ALG{ + JSON::Object::hash("userinfo_signed_response_alg"sv)}; +constexpr auto HASH_DEFAULT_MAX_AGE{JSON::Object::hash("default_max_age"sv)}; +constexpr auto HASH_REQUIRE_AUTH_TIME{ + JSON::Object::hash("require_auth_time"sv)}; +constexpr auto HASH_INITIATE_LOGIN_URI{ + JSON::Object::hash("initiate_login_uri"sv)}; +constexpr auto HASH_POST_LOGOUT_REDIRECT_URIS{ + JSON::Object::hash("post_logout_redirect_uris"sv)}; + +auto string_member(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) + -> std::optional { + const auto *member{data.try_at(name, hash)}; + if (member == nullptr || !member->is_string()) { + return std::nullopt; + } + + return std::string_view{member->to_string()}; +} + +// A member that is absent, or present and a string. A present member of any +// other type is malformed for a field the accessors read as a string +auto absent_or_string(const JSON &data, const JSON::StringView name, + const JSON::Object::hash_type hash) -> bool { + const auto *member{data.try_at(name, hash)}; + return member == nullptr || member->is_string(); +} + +// RFC 6749 Section 3.1.2: a redirection URI is an absolute URI without a +// fragment +auto is_absolute_uri_without_fragment(const std::string_view value) -> bool { + try { + const URI uri{value}; + return uri.scheme().has_value() && !uri.fragment().has_value(); + } catch (const URIParseError &) { + return false; + } +} + +auto is_https_url_with_host(const std::string_view value) -> bool { + try { + const URI uri{value}; + // RFC 3986 Section 3.1: the scheme is case-insensitive, so an uppercase or + // mixed-case HTTPS is still https + return uri.scheme().has_value() && + equals_ignore_case(uri.scheme().value(), "https") && + uri.host().has_value() && !uri.host().value().empty(); + } catch (const URIParseError &) { + return false; + } +} + +auto validate_client_metadata(const OAuthClientMetadata &oauth) -> void { + const auto &data{oauth.data()}; + + // OpenID Connect Dynamic Client Registration 1.0 Section 2: redirect_uris is + // REQUIRED (OpenID Connect tightens the OAuth OPTIONAL), and each entry is an + // absolute URI without a fragment (RFC 6749 Section 3.1.2), so an unusable + // callback is never treated as registered + const auto *redirect_uris{data.try_at("redirect_uris"sv, HASH_REDIRECT_URIS)}; + if (redirect_uris == nullptr || !redirect_uris->is_array() || + redirect_uris->empty()) { + throw OIDCRegistrationParseError{}; + } + + for (const auto &element : redirect_uris->as_array()) { + if (!element.is_string() || + !is_absolute_uri_without_fragment(element.to_string())) { + throw OIDCRegistrationParseError{}; + } + } + + // A present member whose JSON type is wrong is rejected rather than read as + // its default, so malformed metadata cannot silently weaken the settings + if (!absent_or_string(data, "application_type"sv, HASH_APPLICATION_TYPE) || + !absent_or_string(data, "subject_type"sv, HASH_SUBJECT_TYPE) || + !absent_or_string(data, "id_token_signed_response_alg"sv, + HASH_ID_TOKEN_SIGNED_ALG) || + !absent_or_string(data, "id_token_encrypted_response_alg"sv, + HASH_ID_TOKEN_ENCRYPTED_ALG) || + !absent_or_string(data, "userinfo_signed_response_alg"sv, + HASH_USERINFO_SIGNED_ALG)) { + throw OIDCRegistrationParseError{}; + } + + const auto *max_age{data.try_at("default_max_age"sv, HASH_DEFAULT_MAX_AGE)}; + if (max_age != nullptr && + (!max_age->is_integer() || max_age->to_integer() < 0)) { + throw OIDCRegistrationParseError{}; + } + + const auto *require_auth_time{ + data.try_at("require_auth_time"sv, HASH_REQUIRE_AUTH_TIME)}; + if (require_auth_time != nullptr && !require_auth_time->is_boolean()) { + throw OIDCRegistrationParseError{}; + } + + const auto *post_logout{data.try_at("post_logout_redirect_uris"sv, + HASH_POST_LOGOUT_REDIRECT_URIS)}; + if (post_logout != nullptr && !post_logout->is_array_of_strings()) { + throw OIDCRegistrationParseError{}; + } + + // OpenID Connect Dynamic Client Registration 1.0 Section 5: the + // sector_identifier_uri is fetched over https by the OpenID Provider, so a + // non-https or hostless value would point it at an insecure or invalid + // location + const auto *sector{ + data.try_at("sector_identifier_uri"sv, HASH_SECTOR_IDENTIFIER_URI)}; + if (sector != nullptr && + (!sector->is_string() || !is_https_url_with_host(sector->to_string()))) { + throw OIDCRegistrationParseError{}; + } + + // OpenID Connect Core 1.0 Section 4: the initiate_login_uri is a third-party + // login target that "MUST use the https scheme" + const auto *initiate_login{ + data.try_at("initiate_login_uri"sv, HASH_INITIATE_LOGIN_URI)}; + if (initiate_login != nullptr && + (!initiate_login->is_string() || + !is_https_url_with_host(initiate_login->to_string()))) { + throw OIDCRegistrationParseError{}; + } +} + +} // namespace + +OIDCClientMetadata::OIDCClientMetadata(JSON &&data) : oauth_{std::move(data)} { + validate_client_metadata(this->oauth_); +} + +auto OIDCClientMetadata::from(JSON &&data) + -> std::optional { + try { + return OIDCClientMetadata{std::move(data)}; + } catch (const OAuthRegistrationParseError &) { + return std::nullopt; + } catch (const OIDCRegistrationParseError &) { + return std::nullopt; + } +} + +auto OIDCClientMetadata::has_redirect_uri(const std::string_view value) const + -> bool { + return this->oauth_.has_redirect_uri(value); +} + +auto OIDCClientMetadata::application_type() const -> std::string_view { + // OpenID Connect Dynamic Client Registration 1.0 Section 2: the default is + // web + const auto value{string_member(this->oauth_.data(), "application_type"sv, + HASH_APPLICATION_TYPE)}; + return value.value_or("web"); +} + +auto OIDCClientMetadata::subject_type() const + -> std::optional { + return string_member(this->oauth_.data(), "subject_type"sv, + HASH_SUBJECT_TYPE); +} + +auto OIDCClientMetadata::sector_identifier_uri() const + -> std::optional { + return string_member(this->oauth_.data(), "sector_identifier_uri"sv, + HASH_SECTOR_IDENTIFIER_URI); +} + +auto OIDCClientMetadata::id_token_signed_response_alg() const + -> std::string_view { + // OpenID Connect Dynamic Client Registration 1.0 Section 2: the default is + // RS256 + const auto value{string_member(this->oauth_.data(), + "id_token_signed_response_alg"sv, + HASH_ID_TOKEN_SIGNED_ALG)}; + return value.value_or("RS256"); +} + +auto OIDCClientMetadata::id_token_encrypted_response_alg() const + -> std::optional { + return string_member(this->oauth_.data(), "id_token_encrypted_response_alg"sv, + HASH_ID_TOKEN_ENCRYPTED_ALG); +} + +auto OIDCClientMetadata::userinfo_signed_response_alg() const + -> std::optional { + return string_member(this->oauth_.data(), "userinfo_signed_response_alg"sv, + HASH_USERINFO_SIGNED_ALG); +} + +auto OIDCClientMetadata::default_max_age() const + -> std::optional { + const auto *member{ + this->oauth_.data().try_at("default_max_age"sv, HASH_DEFAULT_MAX_AGE)}; + if (member == nullptr || !member->is_integer() || member->to_integer() < 0 || + member->to_integer() > + std::numeric_limits::max()) { + return std::nullopt; + } + + return std::chrono::seconds{member->to_integer()}; +} + +auto OIDCClientMetadata::require_auth_time() const -> bool { + const auto *member{this->oauth_.data().try_at("require_auth_time"sv, + HASH_REQUIRE_AUTH_TIME)}; + return member != nullptr && member->is_boolean() && member->to_boolean(); +} + +auto OIDCClientMetadata::initiate_login_uri() const + -> std::optional { + return string_member(this->oauth_.data(), "initiate_login_uri"sv, + HASH_INITIATE_LOGIN_URI); +} + +auto OIDCClientMetadata::has_post_logout_redirect_uri( + const std::string_view value) const -> bool { + return this->oauth_.data().array_member_contains( + "post_logout_redirect_uris"sv, HASH_POST_LOGOUT_REDIRECT_URIS, value); +} + +auto OIDCClientMetadata::oauth() const -> const OAuthClientMetadata & { + return this->oauth_; +} + +auto OIDCClientMetadata::data() const -> const JSON & { + return this->oauth_.data(); +} + +auto oidc_sector_identifier_contains( + const JSON §or_document, + const std::span redirect_uris) -> bool { + // OpenID Connect Dynamic Client Registration 1.0 Section 5: the document is a + // JSON array of redirection URIs, so a non-string element makes it malformed, + // and every registered URI must appear in it + if (!sector_document.is_array_of_strings()) { + return false; + } + + for (const auto redirect_uri : redirect_uris) { + bool found{false}; + for (const auto &element : sector_document.as_array()) { + if (element.to_string() == redirect_uri) { + found = true; + break; + } + } + + if (!found) { + return false; + } + } + + return true; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_request_object.cc b/vendor/core/src/core/oidc/oidc_request_object.cc new file mode 100644 index 00000000..af236b2f --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_request_object.cc @@ -0,0 +1,73 @@ +#include + +#include +#include + +#include "oidc_verify.h" + +#include // std::optional, std::nullopt +#include // std::span +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_ALG{JSON::Object::hash("alg"sv)}; +constexpr auto HASH_ISS{JSON::Object::hash("iss"sv)}; + +} // namespace + +auto oidc_request_object_pairing_is_valid(const std::string_view request, + const std::string_view request_uri) + -> bool { + // OpenID Connect Core 1.0 Section 6: the request and request_uri parameters + // MUST NOT both be used in the same request + return request.empty() || request_uri.empty(); +} + +auto oidc_build_request_object(const JSON ¶meters, const JWKPrivate &key, + const JWSAlgorithm algorithm) + -> std::optional { + if (!parameters.is_object()) { + return std::nullopt; + } + + auto header{JSON::make_object()}; + header.assign_assume_new("alg", JSON{jws_algorithm_name(algorithm)}, + HASH_ALG); + return jwt_sign(header, parameters, key); +} + +auto oidc_verify_request_object( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view client_id, const std::string_view provider_issuer) + -> std::optional { + // OpenID Connect Core 1.0 Section 6.3: the algorithm is pinned and the key is + // selected by identifier, never taken from the header alone + if (!oidc_verify_selected_signature(token, keys, allowed_algorithms)) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 6.1: the request object is issued by the + // client and addressed to the OpenID Provider, so iss and aud are REQUIRED + // and must match the client and the provider respectively + const auto &payload{token.payload()}; + const auto *issuer{payload.try_at("iss"sv, HASH_ISS)}; + if (issuer == nullptr || !issuer->is_string() || + issuer->to_string() != client_id) { + return std::nullopt; + } + + if (!token.has_audience(provider_issuer)) { + return std::nullopt; + } + + return payload; +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_subject.cc b/vendor/core/src/core/oidc/oidc_subject.cc new file mode 100644 index 00000000..cf77ffc5 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_subject.cc @@ -0,0 +1,56 @@ +#include + +#include + +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oidc_subject_type_name(const OIDCSubjectType type) noexcept + -> std::string_view { + switch (type) { + case OIDCSubjectType::Public: + return "public"; + case OIDCSubjectType::Pairwise: + return "pairwise"; + } + + std::unreachable(); +} + +auto to_oidc_subject_type(const std::string_view name) noexcept + -> std::optional { + if (name == "public") { + return OIDCSubjectType::Public; + } else if (name == "pairwise") { + return OIDCSubjectType::Pairwise; + } else { + return std::nullopt; + } +} + +auto oidc_pairwise_subject(const std::string_view sector_identifier, + const std::string_view local_account_identifier, + const std::string_view provider_secret) + -> std::string { + // OpenID Connect Core 1.0 Section 8.1: the pairwise value is derived from the + // sector identifier, a local account identifier, and a provider secret. The + // HMAC input is length-prefixed so the field boundary cannot collide with a + // separator that also appears in the data, keeping distinct pairs distinct + const auto sector_length{std::to_string(sector_identifier.size())}; + std::string message; + message.reserve(sector_length.size() + 1 + sector_identifier.size() + + local_account_identifier.size()); + message.append(sector_length); + message.push_back(':'); + message.append(sector_identifier); + message.append(local_account_identifier); + + const auto digest{hmac_sha256_digest(provider_secret, message)}; + return base64url_encode(digest); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_userinfo.cc b/vendor/core/src/core/oidc/oidc_userinfo.cc new file mode 100644 index 00000000..3da17dd2 --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_userinfo.cc @@ -0,0 +1,83 @@ +#include + +#include +#include +#include + +#include "oidc_verify.h" + +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +using namespace std::literals::string_view_literals; + +constexpr auto HASH_SUB{JSON::Object::hash("sub"sv)}; +constexpr auto HASH_ISS{JSON::Object::hash("iss"sv)}; +constexpr auto HASH_AUD{JSON::Object::hash("aud"sv)}; + +} // namespace + +auto oidc_build_userinfo(const std::string_view subject, + const JSON &additional_claims) -> JSON { + auto document{additional_claims.is_object() ? additional_claims + : JSON::make_object()}; + // OpenID Connect Core 1.0 Section 5.3.2: the sub claim is REQUIRED in a + // UserInfo response + document.assign("sub", JSON{subject}); + return document; +} + +auto oidc_userinfo_matches_subject(const JSON &userinfo, + const std::string_view expected_subject) + -> bool { + if (!userinfo.is_object()) { + return false; + } + + const auto *subject{userinfo.try_at("sub"sv, HASH_SUB)}; + return subject != nullptr && subject->is_string() && + secure_equals(subject->to_string(), expected_subject); +} + +auto oidc_verify_userinfo( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view expected_subject, + const std::string_view expected_issuer, + const std::string_view expected_client_id) -> std::optional { + // OpenID Connect Core 1.0 Section 5.3.2: the algorithm is pinned and the key + // selected by identifier, never taken from the token header alone + if (!oidc_verify_selected_signature(token, keys, allowed_algorithms)) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 5.3.2: the sub must match the ID Token sub + if (!oidc_userinfo_matches_subject(token.payload(), expected_subject)) { + return std::nullopt; + } + + // OpenID Connect Core 1.0 Section 5.3.2: a signed response SHOULD carry iss + // and aud, so when present the iss must be this provider and the aud must + // include this client, which stops a response minted for another client from + // being accepted here. The raw members are inspected so a present but + // non-string claim fails closed rather than being read as absent + const auto *issuer{token.payload().try_at("iss"sv, HASH_ISS)}; + if (issuer != nullptr && + (!issuer->is_string() || issuer->to_string() != expected_issuer)) { + return std::nullopt; + } + + const auto *audience{token.payload().try_at("aud"sv, HASH_AUD)}; + if (audience != nullptr && !token.has_audience(expected_client_id)) { + return std::nullopt; + } + + return token.payload(); +} + +} // namespace sourcemeta::core diff --git a/vendor/core/src/core/oidc/oidc_verify.h b/vendor/core/src/core/oidc/oidc_verify.h new file mode 100644 index 00000000..9d808ebd --- /dev/null +++ b/vendor/core/src/core/oidc/oidc_verify.h @@ -0,0 +1,44 @@ +#ifndef SOURCEMETA_CORE_OIDC_VERIFY_H_ +#define SOURCEMETA_CORE_OIDC_VERIFY_H_ + +#include + +#include // std::ranges::find +#include // std::span + +namespace sourcemeta::core { + +// The signature-verification step shared by the OpenID Connect token paths (the +// UserInfo response, the request object, and the logout token). The algorithm +// is pinned to the allow-list and never taken from the header alone, defeating +// algorithm confusion, and a present key identifier selects exactly one key, +// matching jwt_verify so key rotation and revocation stay consistent across the +// paths rather than each maintaining its own selection +inline auto oidc_verify_selected_signature( + const JWT &token, const JWKS &keys, + const std::span allowed_algorithms) -> bool { + const auto algorithm{token.algorithm()}; + if (!algorithm.has_value() || + std::ranges::find(allowed_algorithms, algorithm.value()) == + allowed_algorithms.end()) { + return false; + } + + const auto key_id{token.key_id()}; + if (key_id.has_value()) { + const auto *key{keys.find(key_id.value())}; + return key != nullptr && jwt_verify_signature(token, *key); + } + + for (const auto &key : keys) { + if (jwt_verify_signature(token, key)) { + return true; + } + } + + return false; +} + +} // namespace sourcemeta::core + +#endif diff --git a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h index a0e5b17c..f000a6a6 100644 --- a/vendor/core/src/core/uri/include/sourcemeta/core/uri.h +++ b/vendor/core/src/core/uri/include/sourcemeta/core/uri.h @@ -834,6 +834,39 @@ class SOURCEMETA_CORE_URI_EXPORT URI { } } + /// Append a percent-encoded name and value pair to a query, form body, or + /// fragment under construction (RFC 3986 Section 2.1), joining it to any + /// preceding pair. The caller writes the opening character of a fresh + /// parameter list, the "?" of a query unless overridden through the opener, + /// and the sink must not alias the name or value. For example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// std::string query{"https://example.com/authorize?"}; + /// sourcemeta::core::URI::append_query_parameter(query, "response_type", + /// "code"); + /// sourcemeta::core::URI::append_query_parameter(query, "scope", + /// "openid profile"); + /// assert(query == + /// "https://example.com/authorize?response_type=code" + /// "&scope=openid%20profile"); + /// ``` + template + static auto append_query_parameter(Output &sink, const std::string_view name, + const std::string_view value, + const char opener = '?') -> void { + if (!sink.empty() && sink.back() != opener && sink.back() != '&') { + sink.push_back('&'); + } + + URI::escape(name, sink); + sink.push_back('='); + URI::escape(value, sink); + } + /// Percent-decode every escape sequence in a string per RFC 3986, leaving /// malformed sequences untouched. For example: /// diff --git a/vendor/core/src/lang/numeric/include/sourcemeta/core/numeric_uint128.h b/vendor/core/src/lang/numeric/include/sourcemeta/core/numeric_uint128.h index 763450a8..b5c0bb6a 100644 --- a/vendor/core/src/lang/numeric/include/sourcemeta/core/numeric_uint128.h +++ b/vendor/core/src/lang/numeric/include/sourcemeta/core/numeric_uint128.h @@ -28,92 +28,103 @@ struct uint128_t { /// The upper 64 bits of the value. std::uint64_t high; - uint128_t() noexcept : low{0}, high{0} {} + constexpr uint128_t() noexcept : low{0}, high{0} {} /// Construct from a signed integer, sign-extending negative values. - uint128_t(int value) noexcept + constexpr uint128_t(int value) noexcept : low{static_cast(value)}, high{value < 0 ? UINT64_MAX : 0} {} /// Construct from an unsigned integer. - uint128_t(unsigned int value) noexcept + constexpr uint128_t(unsigned int value) noexcept : low{static_cast(value)}, high{0} {} /// Construct from a 64-bit unsigned integer. - uint128_t(std::uint64_t value) noexcept : low{value}, high{0} {} + constexpr uint128_t(std::uint64_t value) noexcept : low{value}, high{0} {} /// Construct from a 64-bit signed integer, sign-extending negative values. - uint128_t(std::int64_t value) noexcept + constexpr uint128_t(std::int64_t value) noexcept : low{static_cast(value)}, high{value < 0 ? UINT64_MAX : 0} {} /// Construct from separate upper and lower 64-bit halves. - uint128_t(std::uint64_t high_part, std::uint64_t low_part) noexcept + constexpr uint128_t(std::uint64_t high_part, std::uint64_t low_part) noexcept : low{low_part}, high{high_part} {} - explicit operator std::uint64_t() const noexcept { return this->low; } - explicit operator std::int64_t() const noexcept { + constexpr explicit operator std::uint64_t() const noexcept { + return this->low; + } + constexpr explicit operator std::int64_t() const noexcept { return static_cast(this->low); } - explicit operator bool() const noexcept { + constexpr explicit operator bool() const noexcept { return this->low != 0 || this->high != 0; } - auto operator+=(const uint128_t &other) noexcept -> uint128_t & { + constexpr auto operator+=(const uint128_t &other) noexcept -> uint128_t & { auto old_low = this->low; this->low += other.low; this->high += other.high + (this->low < old_low ? 1 : 0); return *this; } - auto operator-=(const uint128_t &other) noexcept -> uint128_t & { + constexpr auto operator-=(const uint128_t &other) noexcept -> uint128_t & { const auto old_low = this->low; this->low -= other.low; this->high -= other.high + (old_low < other.low ? 1 : 0); return *this; } - auto operator*=(const uint128_t &other) noexcept -> uint128_t & { + constexpr auto operator*=(const uint128_t &other) noexcept -> uint128_t & { *this = *this * other; return *this; } - auto operator%=(const uint128_t &other) noexcept -> uint128_t & { + constexpr auto operator%=(const uint128_t &other) noexcept -> uint128_t & { *this = *this % other; return *this; } - friend auto operator+(uint128_t left, const uint128_t &right) noexcept + friend constexpr auto operator+(uint128_t left, + const uint128_t &right) noexcept -> uint128_t { left += right; return left; } - friend auto operator-(uint128_t left, const uint128_t &right) noexcept + friend constexpr auto operator-(uint128_t left, + const uint128_t &right) noexcept -> uint128_t { left -= right; return left; } - friend auto operator*(const uint128_t &left, const uint128_t &right) noexcept + friend constexpr auto operator*(const uint128_t &left, + const uint128_t &right) noexcept -> uint128_t { std::uint64_t result_high; + std::uint64_t result_low; + // The intrinsic is not usable during constant evaluation, so fall through + // to the portable computation, which yields the same value #if defined(_MSC_VER) - auto result_low = _umul128(left.low, right.low, &result_high); -#else - const std::uint64_t left_low{left.low & 0xFFFFFFFF}; - const std::uint64_t left_high{left.low >> 32}; - const std::uint64_t right_low{right.low & 0xFFFFFFFF}; - const std::uint64_t right_high{right.low >> 32}; - const std::uint64_t cross_1{left_high * right_low}; - const std::uint64_t cross_2{left_low * right_high}; - const std::uint64_t mid{cross_1 + (left_low * right_low >> 32)}; - const std::uint64_t mid_2{(mid & 0xFFFFFFFF) + cross_2}; - result_high = left_high * right_high + (mid >> 32) + (mid_2 >> 32); - auto result_low = (mid_2 << 32) | ((left_low * right_low) & 0xFFFFFFFF); + if !consteval { + result_low = _umul128(left.low, right.low, &result_high); + } else #endif + { + const std::uint64_t left_low{left.low & 0xFFFFFFFF}; + const std::uint64_t left_high{left.low >> 32}; + const std::uint64_t right_low{right.low & 0xFFFFFFFF}; + const std::uint64_t right_high{right.low >> 32}; + const std::uint64_t cross_1{left_high * right_low}; + const std::uint64_t cross_2{left_low * right_high}; + const std::uint64_t mid{cross_1 + (left_low * right_low >> 32)}; + const std::uint64_t mid_2{(mid & 0xFFFFFFFF) + cross_2}; + result_high = left_high * right_high + (mid >> 32) + (mid_2 >> 32); + result_low = (mid_2 << 32) | ((left_low * right_low) & 0xFFFFFFFF); + } result_high += left.low * right.high + left.high * right.low; return {result_high, result_low}; } - friend auto operator/(const uint128_t ÷nd, - std::uint64_t divisor) noexcept -> uint128_t { + friend constexpr auto operator/(const uint128_t ÷nd, + std::uint64_t divisor) noexcept -> uint128_t { if (dividend.high == 0) { return {0, dividend.low / divisor}; } @@ -121,37 +132,44 @@ struct uint128_t { auto quotient_high = dividend.high / divisor; auto remainder_high = dividend.high % divisor; std::uint64_t quotient_low; - std::uint64_t remainder; // The high half of the partial dividend must stay below the divisor, or the // hardware 128-by-64 division would overflow its 64-bit quotient. The // remainder of the earlier division guarantees this assert(remainder_high < divisor); + // The intrinsic is not usable during constant evaluation, so fall through + // to the portable computation, which yields the same value #if defined(_MSC_VER) - quotient_low = _udiv128(remainder_high, dividend.low, divisor, &remainder); -#else - quotient_low = 0; - auto current_remainder = remainder_high; - for (int bit = 63; bit >= 0; --bit) { - const auto carry = current_remainder >> 63; - current_remainder = - (current_remainder << 1) | ((dividend.low >> bit) & 1); - if (carry || current_remainder >= divisor) { - current_remainder -= divisor; - quotient_low |= static_cast(1) << bit; + if !consteval { + std::uint64_t remainder; + quotient_low = + _udiv128(remainder_high, dividend.low, divisor, &remainder); + } else +#endif + { + quotient_low = 0; + auto current_remainder = remainder_high; + for (int bit = 63; bit >= 0; --bit) { + const auto carry = current_remainder >> 63; + current_remainder = + (current_remainder << 1) | ((dividend.low >> bit) & 1); + if (carry || current_remainder >= divisor) { + current_remainder -= divisor; + quotient_low |= static_cast(1) << bit; + } } } -#endif return {quotient_high, quotient_low}; } - friend auto operator/(const uint128_t ÷nd, - const uint128_t &divisor) noexcept -> uint128_t { + friend constexpr auto operator/(const uint128_t ÷nd, + const uint128_t &divisor) noexcept + -> uint128_t { assert(divisor.high == 0); return dividend / divisor.low; } - friend auto operator%(const uint128_t ÷nd, - std::uint64_t divisor) noexcept -> uint128_t { + friend constexpr auto operator%(const uint128_t ÷nd, + std::uint64_t divisor) noexcept -> uint128_t { if (dividend.high == 0) { return {0, dividend.low % divisor}; } @@ -162,59 +180,65 @@ struct uint128_t { // hardware 128-by-64 division would overflow its 64-bit quotient. The // remainder of the earlier division guarantees this assert(remainder_high < divisor); + // The intrinsic is not usable during constant evaluation, so fall through + // to the portable computation, which yields the same value #if defined(_MSC_VER) - _udiv128(remainder_high, dividend.low, divisor, &remainder); -#else - remainder = remainder_high; - for (int bit = 63; bit >= 0; --bit) { - const auto carry = remainder >> 63; - remainder = (remainder << 1) | ((dividend.low >> bit) & 1); - if (carry || remainder >= divisor) { - remainder -= divisor; + if !consteval { + _udiv128(remainder_high, dividend.low, divisor, &remainder); + } else +#endif + { + remainder = remainder_high; + for (int bit = 63; bit >= 0; --bit) { + const auto carry = remainder >> 63; + remainder = (remainder << 1) | ((dividend.low >> bit) & 1); + if (carry || remainder >= divisor) { + remainder -= divisor; + } } } -#endif return {0, remainder}; } - friend auto operator%(const uint128_t ÷nd, - const uint128_t &divisor) noexcept -> uint128_t { + friend constexpr auto operator%(const uint128_t ÷nd, + const uint128_t &divisor) noexcept + -> uint128_t { assert(divisor.high == 0); return dividend % divisor.low; } - friend auto operator<(const uint128_t &left, const uint128_t &right) noexcept - -> bool { + friend constexpr auto operator<(const uint128_t &left, + const uint128_t &right) noexcept -> bool { return left.high < right.high || (left.high == right.high && left.low < right.low); } - friend auto operator>(const uint128_t &left, const uint128_t &right) noexcept - -> bool { + friend constexpr auto operator>(const uint128_t &left, + const uint128_t &right) noexcept -> bool { return right < left; } - friend auto operator<=(const uint128_t &left, const uint128_t &right) noexcept - -> bool { + friend constexpr auto operator<=(const uint128_t &left, + const uint128_t &right) noexcept -> bool { return !(right < left); } - friend auto operator>=(const uint128_t &left, const uint128_t &right) noexcept - -> bool { + friend constexpr auto operator>=(const uint128_t &left, + const uint128_t &right) noexcept -> bool { return !(left < right); } - friend auto operator==(const uint128_t &left, const uint128_t &right) noexcept - -> bool { + friend constexpr auto operator==(const uint128_t &left, + const uint128_t &right) noexcept -> bool { return left.high == right.high && left.low == right.low; } - friend auto operator!=(const uint128_t &left, const uint128_t &right) noexcept - -> bool { + friend constexpr auto operator!=(const uint128_t &left, + const uint128_t &right) noexcept -> bool { return !(left == right); } - friend auto operator<<(const uint128_t &value, int shift) noexcept + friend constexpr auto operator<<(const uint128_t &value, int shift) noexcept -> uint128_t { if (shift == 0) { return value; @@ -228,7 +252,7 @@ struct uint128_t { } } - friend auto operator>>(const uint128_t &value, int shift) noexcept + friend constexpr auto operator>>(const uint128_t &value, int shift) noexcept -> uint128_t { if (shift == 0) { return value; @@ -242,18 +266,20 @@ struct uint128_t { } } - friend auto operator|(const uint128_t &left, const uint128_t &right) noexcept + friend constexpr auto operator|(const uint128_t &left, + const uint128_t &right) noexcept -> uint128_t { return {left.high | right.high, left.low | right.low}; } - auto operator|=(const uint128_t &other) noexcept -> uint128_t & { + constexpr auto operator|=(const uint128_t &other) noexcept -> uint128_t & { this->high |= other.high; this->low |= other.low; return *this; } - friend auto operator&(const uint128_t &left, const uint128_t &right) noexcept + friend constexpr auto operator&(const uint128_t &left, + const uint128_t &right) noexcept -> uint128_t { return {left.high & right.high, left.low & right.low}; } diff --git a/vendor/core/src/lang/text/include/sourcemeta/core/text.h b/vendor/core/src/lang/text/include/sourcemeta/core/text.h index a4e1bb12..ce6d28fa 100644 --- a/vendor/core/src/lang/text/include/sourcemeta/core/text.h +++ b/vendor/core/src/lang/text/include/sourcemeta/core/text.h @@ -477,6 +477,26 @@ auto split_once(const std::string_view input, const std::string_view delimiter) noexcept -> std::optional>; +/// @ingroup text +/// +/// Split `input` at the last occurrence of `delimiter`, returning the +/// parts before and after it. Return `std::nullopt` when the delimiter is +/// absent. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto parts{sourcemeta::core::rsplit_once("a.b.c", '.')}; +/// assert(parts.has_value()); +/// assert(parts->first == "a.b"); +/// assert(parts->second == "c"); +/// assert(!sourcemeta::core::rsplit_once("no separator", '.').has_value()); +/// ``` +SOURCEMETA_CORE_TEXT_EXPORT +auto rsplit_once(const std::string_view input, const char delimiter) noexcept + -> std::optional>; + /// @ingroup text /// /// Iterate the parts of `input` separated by `delimiter`, invoking diff --git a/vendor/core/src/lang/text/text.cc b/vendor/core/src/lang/text/text.cc index 8943a78a..fc631d62 100644 --- a/vendor/core/src/lang/text/text.cc +++ b/vendor/core/src/lang/text/text.cc @@ -144,6 +144,19 @@ auto split_once(const std::string_view input, const char delimiter) noexcept return std::pair{before, after}; } +auto rsplit_once(const std::string_view input, const char delimiter) noexcept + -> std::optional> { + const auto position{input.rfind(delimiter)}; + if (position == std::string_view::npos) { + return std::nullopt; + } + std::string_view before{input}; + before.remove_suffix(input.size() - position); + std::string_view after{input}; + after.remove_prefix(position + 1); + return std::pair{before, after}; +} + auto split_once(const std::string_view input, const std::string_view delimiter) noexcept -> std::optional> {